Index: trunk/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java	(revision 17862)
@@ -2,8 +2,11 @@
 package org.openstreetmap.josm.data.cache;
 
+import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.io.InputStream;
 import java.net.HttpURLConnection;
 import java.net.URL;
+import java.nio.file.Files;
 import java.security.SecureRandom;
 import java.util.Collections;
@@ -18,6 +21,4 @@
 import java.util.regex.Matcher;
 
-import org.apache.commons.jcs3.access.behavior.ICacheAccess;
-import org.apache.commons.jcs3.engine.behavior.ICacheElement;
 import org.openstreetmap.josm.data.cache.ICachedLoaderListener.LoadResult;
 import org.openstreetmap.josm.data.imagery.TileJobOptions;
@@ -27,4 +28,7 @@
 import org.openstreetmap.josm.tools.Logging;
 import org.openstreetmap.josm.tools.Utils;
+
+import org.apache.commons.jcs3.access.behavior.ICacheAccess;
+import org.apache.commons.jcs3.engine.behavior.ICacheElement;
 
 /**
@@ -295,4 +299,41 @@
             attributes = new CacheEntryAttributes();
         }
+        final URL url = this.getUrlNoException();
+        if (url == null) {
+            return false;
+        }
+
+        if (url.getProtocol().contains("http")) {
+            return loadObjectHttp();
+        }
+        if (url.getProtocol().contains("file")) {
+            return loadObjectFile(url);
+        }
+
+        return false;
+    }
+
+    private boolean loadObjectFile(URL url) {
+        String fileName = url.toExternalForm();
+        File file = new File(fileName.substring("file:/".length() - 1));
+        if (!file.exists()) {
+            file = new File(fileName.substring("file://".length() - 1));
+        }
+        try (InputStream fileInputStream = Files.newInputStream(file.toPath())) {
+            cacheData = createCacheEntry(Utils.readBytesFromStream(fileInputStream));
+            cache.put(getCacheKey(), cacheData, attributes);
+            return true;
+        } catch (IOException e) {
+            Logging.error(e);
+            attributes.setError(e);
+            attributes.setException(e);
+        }
+        return false;
+    }
+
+    /**
+     * @return true if object was successfully downloaded via http, false, if there was a loading failure
+     */
+    private boolean loadObjectHttp() {
         try {
             // if we have object in cache, and host doesn't support If-Modified-Since nor If-None-Match
@@ -554,4 +595,5 @@
             return getUrl();
         } catch (IOException e) {
+            Logging.trace(e);
             return null;
         }
Index: trunk/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java	(revision 17862)
@@ -62,5 +62,7 @@
         WMS_ENDPOINT("wms_endpoint"),
         /** WMTS stores GetCapabilities URL. Does not store any information about the layer **/
-        WMTS("wmts");
+        WMTS("wmts"),
+        /** Mapbox Vector Tiles entry*/
+        MVT("mvt");
 
         private final String typeString;
@@ -655,5 +657,5 @@
         defaultMinZoom = 0;
         for (ImageryType type : ImageryType.values()) {
-            Matcher m = Pattern.compile(type.getTypeString()+"(?:\\[(?:(\\d+)[,-])?(\\d+)\\])?:(.*)").matcher(url);
+            Matcher m = Pattern.compile(type.getTypeString()+"(?:\\[(?:(\\d+)[,-])?(\\d+)])?:(.*)").matcher(url);
             if (m.matches()) {
                 this.url = m.group(3);
@@ -670,5 +672,5 @@
 
         if (serverProjections.isEmpty()) {
-            Matcher m = Pattern.compile(".*\\{PROJ\\(([^)}]+)\\)\\}.*").matcher(url.toUpperCase(Locale.ENGLISH));
+            Matcher m = Pattern.compile(".*\\{PROJ\\(([^)}]+)\\)}.*").matcher(url.toUpperCase(Locale.ENGLISH));
             if (m.matches()) {
                 setServerProjections(Arrays.asList(m.group(1).split(",", -1)));
Index: trunk/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java	(revision 17862)
@@ -11,4 +11,5 @@
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
@@ -34,4 +35,6 @@
 import org.openstreetmap.josm.data.cache.ICachedLoaderListener;
 import org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob;
+import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTFile;
 import org.openstreetmap.josm.data.preferences.LongProperty;
 import org.openstreetmap.josm.tools.HttpClient;
@@ -150,5 +153,5 @@
         if (statusCode == 200 && headers.containsKey("Content-Type") && !headers.get("Content-Type").isEmpty()) {
             String contentType = headers.get("Content-Type").stream().findAny().get();
-            if (contentType != null && !contentType.startsWith("image")) {
+            if (contentType != null && !contentType.startsWith("image") && !MVTFile.MIMETYPE.contains(contentType.toLowerCase(Locale.ROOT))) {
                 Logging.warn("Image not returned for tile: " + url + " content type was: " + contentType);
                 // not an image - do not store response in cache, so next time it will be queried again from the server
@@ -321,8 +324,9 @@
         if (object != null) {
             byte[] content = object.getContent();
-            if (content.length > 0) {
+            if (content.length > 0 || tile instanceof VectorTile) {
                 try (ByteArrayInputStream in = new ByteArrayInputStream(content)) {
                     tile.loadImage(in);
-                    if (tile.getImage() == null) {
+                    if ((!(tile instanceof VectorTile) && tile.getImage() == null)
+                        || ((tile instanceof VectorTile) && !tile.isLoaded())) {
                         String s = new String(content, StandardCharsets.UTF_8);
                         Matcher m = SERVICE_EXCEPTION_PATTERN.matcher(s);
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/VectorTile.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/VectorTile.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/VectorTile.java	(revision 17862)
@@ -0,0 +1,25 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile;
+
+import java.util.Collection;
+
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
+
+/**
+ * An interface that is used to draw vector tiles, instead of using images
+ * @author Taylor Smock
+ * @since xxx
+ */
+public interface VectorTile {
+    /**
+     * Get the layers for this vector tile
+     * @return A collection of layers
+     */
+    Collection<Layer> getLayers();
+
+    /**
+     * Get the extent of the tile (in pixels)
+     * @return The tile extent (pixels)
+     */
+    int getExtent();
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Command.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Command.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Command.java	(revision 17862)
@@ -0,0 +1,48 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+/**
+ * Command integers for Mapbox Vector Tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public enum Command {
+    /**
+     * For {@link GeometryTypes#POINT}, each {@link #MoveTo} is a new point.
+     * For {@link GeometryTypes#LINESTRING} and {@link GeometryTypes#POLYGON}, each {@link #MoveTo} is a new geometry of the same type.
+     */
+    MoveTo((byte) 1, (byte) 2),
+    /**
+     * While not explicitly prohibited for {@link GeometryTypes#POINT}, it should be ignored.
+     * For {@link GeometryTypes#LINESTRING} and {@link GeometryTypes#POLYGON}, each {@link #LineTo} extends that geometry.
+     */
+    LineTo((byte) 2, (byte) 2),
+    /**
+     * This is only explicitly valid for {@link GeometryTypes#POLYGON}. It closes the {@link GeometryTypes#POLYGON}.
+     */
+    ClosePath((byte) 7, (byte) 0);
+
+    private final byte id;
+    private final byte parameters;
+
+    Command(byte id, byte parameters) {
+        this.id = id;
+        this.parameters = parameters;
+    }
+
+    /**
+     * Get the command id
+     * @return The id
+     */
+    public byte getId() {
+        return this.id;
+    }
+
+    /**
+     * Get the number of parameters
+     * @return The number of parameters
+     */
+    public byte getParameterNumber() {
+        return this.parameters;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/CommandInteger.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/CommandInteger.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/CommandInteger.java	(revision 17862)
@@ -0,0 +1,62 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.util.stream.Stream;
+
+/**
+ * An indicator for a command to be executed
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class CommandInteger {
+    private final Command type;
+    private final short[] parameters;
+    private int added;
+
+    /**
+     * Create a new command
+     * @param command the command (treated as an unsigned int)
+     */
+    public CommandInteger(final int command) {
+        // Technically, the int is unsigned, but it is easier to work with the long
+        final long unsigned = Integer.toUnsignedLong(command);
+        this.type = Stream.of(Command.values()).filter(e -> e.getId() == (unsigned & 0x7)).findAny()
+                .orElseThrow(InvalidMapboxVectorTileException::new);
+        // This is safe, since we are shifting right 3 when we converted an int to a long (for unsigned).
+        // So we <i>cannot</i> lose anything.
+        final int operationsInt = (int) (unsigned >> 3);
+        this.parameters = new short[operationsInt * this.type.getParameterNumber()];
+    }
+
+    /**
+     * Add a parameter
+     * @param parameterInteger The parameter to add (converted to {@link short}).
+     */
+    public void addParameter(Number parameterInteger) {
+        this.parameters[added++] = parameterInteger.shortValue();
+    }
+
+    /**
+     * Get the operations for the command
+     * @return The operations
+     */
+    public short[] getOperations() {
+        return this.parameters;
+    }
+
+    /**
+     * Get the command type
+     * @return the command type
+     */
+    public Command getType() {
+        return this.type;
+    }
+
+    /**
+     * Get the expected parameter length
+     * @return The expected parameter size
+     */
+    public boolean hasAllExpectedParameters() {
+            return this.added >= this.parameters.length;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java	(revision 17862)
@@ -0,0 +1,175 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.io.IOException;
+import java.text.NumberFormat;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.openstreetmap.josm.data.osm.TagMap;
+import org.openstreetmap.josm.data.protobuf.ProtobufPacked;
+import org.openstreetmap.josm.data.protobuf.ProtobufParser;
+import org.openstreetmap.josm.data.protobuf.ProtobufRecord;
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * A Feature for a {@link Layer}
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class Feature {
+    private static final byte ID_FIELD = 1;
+    private static final byte TAG_FIELD = 2;
+    private static final byte GEOMETRY_TYPE_FIELD = 3;
+    private static final byte GEOMETRY_FIELD = 4;
+    /**
+     * The geometry of the feature. Required.
+     */
+    private final List<CommandInteger> geometry = new ArrayList<>();
+
+    /**
+     * The geometry type of the feature. Required.
+     */
+    private final GeometryTypes geometryType;
+    /**
+     * The id of the feature. Optional.
+     */
+    // Technically, uint64
+    private final long id;
+    /**
+     * The tags of the feature. Optional.
+     */
+    private TagMap tags;
+    private Geometry geometryObject;
+
+    /**
+     * Create a new Feature
+     *
+     * @param layer  The layer the feature is part of (required for tags)
+     * @param record The record to create the feature from
+     * @throws IOException - if an IO error occurs
+     */
+    public Feature(Layer layer, ProtobufRecord record) throws IOException {
+        long tId = 0;
+        GeometryTypes geometryTypeTemp = GeometryTypes.UNKNOWN;
+        String key = null;
+        try (ProtobufParser parser = new ProtobufParser(record.getBytes())) {
+            while (parser.hasNext()) {
+                try (ProtobufRecord next = new ProtobufRecord(parser)) {
+                    if (next.getField() == TAG_FIELD) {
+                        if (tags == null) {
+                            tags = new TagMap();
+                        }
+                        // This is packed in v1 and v2
+                        ProtobufPacked packed = new ProtobufPacked(next.getBytes());
+                        for (Number number : packed.getArray()) {
+                            key = parseTagValue(key, layer, number);
+                        }
+                    } else if (next.getField() == GEOMETRY_FIELD) {
+                        // This is packed in v1 and v2
+                        ProtobufPacked packed = new ProtobufPacked(next.getBytes());
+                        CommandInteger currentCommand = null;
+                        for (Number number : packed.getArray()) {
+                            if (currentCommand != null && currentCommand.hasAllExpectedParameters()) {
+                                currentCommand = null;
+                            }
+                            if (currentCommand == null) {
+                                currentCommand = new CommandInteger(number.intValue());
+                                this.geometry.add(currentCommand);
+                            } else {
+                                currentCommand.addParameter(ProtobufParser.decodeZigZag(number));
+                            }
+                        }
+                        // TODO fallback to non-packed
+                    } else if (next.getField() == GEOMETRY_TYPE_FIELD) {
+                        geometryTypeTemp = GeometryTypes.values()[next.asUnsignedVarInt().intValue()];
+                    } else if (next.getField() == ID_FIELD) {
+                        tId = next.asUnsignedVarInt().longValue();
+                    }
+                }
+            }
+        }
+        this.id = tId;
+        this.geometryType = geometryTypeTemp;
+        record.close();
+    }
+
+    /**
+     * Parse a tag value
+     *
+     * @param key    The current key (or {@code null}, if {@code null}, the returned value will be the new key)
+     * @param layer  The layer with key/value information
+     * @param number The number to get the value from
+     * @return The new key (if {@code null}, then a value was parsed and added to tags)
+     */
+    private String parseTagValue(String key, Layer layer, Number number) {
+        if (key == null) {
+            key = layer.getKey(number.intValue());
+        } else {
+            Object value = layer.getValue(number.intValue());
+            if (value instanceof Double || value instanceof Float) {
+                // reset grouping if the instance is a singleton
+                final NumberFormat numberFormat = NumberFormat.getNumberInstance();
+                final boolean grouping = numberFormat.isGroupingUsed();
+                try {
+                    numberFormat.setGroupingUsed(false);
+                    this.tags.put(key, numberFormat.format(value));
+                } finally {
+                    numberFormat.setGroupingUsed(grouping);
+                }
+            } else {
+                this.tags.put(key, Utils.intern(value.toString()));
+            }
+            key = null;
+        }
+        return key;
+    }
+
+    /**
+     * Get the geometry instructions
+     *
+     * @return The geometry
+     */
+    public List<CommandInteger> getGeometry() {
+        return this.geometry;
+    }
+
+    /**
+     * Get the geometry type
+     *
+     * @return The {@link GeometryTypes}
+     */
+    public GeometryTypes getGeometryType() {
+        return this.geometryType;
+    }
+
+    /**
+     * Get the id of the object
+     *
+     * @return The unique id in the layer, or 0.
+     */
+    public long getId() {
+        return this.id;
+    }
+
+    /**
+     * Get the tags
+     *
+     * @return A tag map
+     */
+    public TagMap getTags() {
+        return this.tags;
+    }
+
+    /**
+     * Get the an object with shapes for the geometry
+     * @return An object with usable geometry information
+     */
+    public Geometry getGeometryObject() {
+        if (this.geometryObject == null) {
+            this.geometryObject = new Geometry(this.getGeometryType(), this.getGeometry());
+        }
+        return this.geometryObject;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java	(revision 17862)
@@ -0,0 +1,102 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Shape;
+import java.awt.geom.Area;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Path2D;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A class to generate geometry for a vector tile
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class Geometry {
+    final Collection<Shape> shapes = new ArrayList<>();
+
+    /**
+     * Create a {@link Geometry} for a {@link Feature}
+     * @param geometryType The type of geometry
+     * @param commands The commands used to create the geometry
+     */
+    public Geometry(GeometryTypes geometryType, List<CommandInteger> commands) {
+        if (geometryType == GeometryTypes.POINT) {
+            for (CommandInteger command : commands) {
+                final short[] operations = command.getOperations();
+                // Each MoveTo command is a new point
+                if (command.getType() == Command.MoveTo && operations.length % 2 == 0 && operations.length > 0) {
+                    for (int i = 0; i < operations.length / 2; i++) {
+                        // Just using Ellipse2D since it extends Shape
+                        shapes.add(new Ellipse2D.Float(operations[2 * i],
+                                operations[2 * i + 1], 0, 0));
+                    }
+                } else {
+                    throw new IllegalArgumentException(tr("{0} with {1} arguments is not understood", geometryType, operations.length));
+                }
+            }
+        } else if (geometryType == GeometryTypes.LINESTRING || geometryType == GeometryTypes.POLYGON) {
+            Path2D.Float line = null;
+            Area area = null;
+            // MVT uses delta encoding. Each feature starts at (0, 0).
+            double x = 0;
+            double y = 0;
+            // Area is used to determine the inner/outer of a polygon
+            double areaAreaSq = 0;
+            for (CommandInteger command : commands) {
+                final short[] operations = command.getOperations();
+                // Technically, there is no reason why there can be multiple MoveTo operations in one command, but that is undefined behavior
+                if (command.getType() == Command.MoveTo && operations.length == 2) {
+                    areaAreaSq = 0;
+                    x += operations[0];
+                    y += operations[1];
+                    line = new Path2D.Float();
+                    line.moveTo(x, y);
+                    shapes.add(line);
+                } else if (command.getType() == Command.LineTo && operations.length % 2 == 0 && line != null) {
+                    for (int i = 0; i < operations.length / 2; i++) {
+                        final double lx = x;
+                        final double ly = y;
+                        x += operations[2 * i];
+                        y += operations[2 * i + 1];
+                        areaAreaSq += lx * y - x * ly;
+                        line.lineTo(x, y);
+                    }
+                // ClosePath should only be used with Polygon geometry
+                } else if (geometryType == GeometryTypes.POLYGON && command.getType() == Command.ClosePath && line != null) {
+                    shapes.remove(line);
+                    // new Area() closes the line if it isn't already closed
+                    if (area == null) {
+                        area = new Area();
+                        shapes.add(area);
+                    }
+
+                    Area nArea = new Area(line);
+                    // SonarLint thinks that this is never > 0. It can be.
+                    if (areaAreaSq > 0) {
+                        area.add(nArea);
+                    } else if (areaAreaSq < 0) {
+                        area.exclusiveOr(nArea);
+                    } else {
+                        throw new IllegalArgumentException(tr("{0} cannot have zero area", geometryType));
+                    }
+                } else {
+                    throw new IllegalArgumentException(tr("{0} with {1} arguments is not understood", geometryType, operations.length));
+                }
+            }
+        }
+    }
+
+    /**
+     * Get the shapes to draw this geometry with
+     * @return A collection of shapes
+     */
+    public Collection<Shape> getShapes() {
+        return Collections.unmodifiableCollection(this.shapes);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java	(revision 17862)
@@ -0,0 +1,31 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+/**
+ * Geometry types used by Mapbox Vector Tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public enum GeometryTypes {
+    /** May be ignored */
+    UNKNOWN,
+    /** May be a point or a multipoint geometry. Uses <i>only</i> {@link Command#MoveTo}. Multiple {@link Command#MoveTo}
+     * indicates that it is a multi-point object. */
+    POINT,
+    /** May be a line or a multiline geometry. Each line {@link Command#MoveTo} and one or more {@link Command#LineTo}. */
+    LINESTRING,
+    /** May be a polygon or a multipolygon. Each ring uses a {@link Command#MoveTo}, one or more {@link Command#LineTo},
+     * and one {@link Command#ClosePath} command. See {@link Ring}s. */
+    POLYGON;
+
+    /**
+     * Rings used by {@link GeometryTypes#POLYGON}
+     * @author Taylor Smock
+     */
+    public enum Ring {
+        /** A ring that goes in the clockwise direction */
+        ExteriorRing,
+        /** A ring that goes in the anti-clockwise direction */
+        InteriorRing
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java	(revision 17862)
@@ -0,0 +1,25 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+/**
+ * Thrown when a mapbox vector tile does not match specifications.
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class InvalidMapboxVectorTileException extends RuntimeException {
+    /**
+     * Create a default {@link InvalidMapboxVectorTileException}.
+     */
+    public InvalidMapboxVectorTileException() {
+        super();
+    }
+
+    /**
+     * Create a new {@link InvalidMapboxVectorTileException} exception with a message
+     * @param message The message
+     */
+    public InvalidMapboxVectorTileException(final String message) {
+        super(message);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java	(revision 17862)
@@ -0,0 +1,253 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.openstreetmap.josm.data.protobuf.ProtobufParser;
+import org.openstreetmap.josm.data.protobuf.ProtobufRecord;
+import org.openstreetmap.josm.tools.Destroyable;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A Mapbox Vector Tile Layer
+ * @author Taylor Smock
+ * @since xxx
+ */
+public final class Layer implements Destroyable {
+    private static final class ValueFields<T> {
+        static final ValueFields<String> STRING = new ValueFields<>(1, ProtobufRecord::asString);
+        static final ValueFields<Float> FLOAT = new ValueFields<>(2, ProtobufRecord::asFloat);
+        static final ValueFields<Double> DOUBLE = new ValueFields<>(3, ProtobufRecord::asDouble);
+        static final ValueFields<Number> INT64 = new ValueFields<>(4, ProtobufRecord::asUnsignedVarInt);
+        // This may have issues if there are actual uint_values (i.e., more than {@link Long#MAX_VALUE})
+        static final ValueFields<Number> UINT64 = new ValueFields<>(5, ProtobufRecord::asUnsignedVarInt);
+        static final ValueFields<Number> SINT64 = new ValueFields<>(6, ProtobufRecord::asSignedVarInt);
+        static final ValueFields<Boolean> BOOL = new ValueFields<>(7, r -> r.asUnsignedVarInt().longValue() != 0);
+
+        /**
+         * A collection of methods to map a record to a type
+         */
+        public static final Collection<ValueFields<?>> MAPPERS =
+          Collections.unmodifiableList(Arrays.asList(STRING, FLOAT, DOUBLE, INT64, UINT64, SINT64, BOOL));
+
+        private final byte field;
+        private final Function<ProtobufRecord, T> conversion;
+        private ValueFields(int field, Function<ProtobufRecord, T> conversion) {
+            this.field = (byte) field;
+            this.conversion = conversion;
+        }
+
+        /**
+         * Get the field identifier for the value
+         * @return The identifier
+         */
+        public byte getField() {
+            return this.field;
+        }
+
+        /**
+         * Convert a protobuf record to a value
+         * @param protobufRecord The record to convert
+         * @return the converted value
+         */
+        public T convertValue(ProtobufRecord protobufRecord) {
+            return this.conversion.apply(protobufRecord);
+        }
+    }
+
+    /** The field value for a layer (in {@link ProtobufRecord#getField}) */
+    public static final byte LAYER_FIELD = 3;
+    private static final byte VERSION_FIELD = 15;
+    private static final byte NAME_FIELD = 1;
+    private static final byte FEATURE_FIELD = 2;
+    private static final byte KEY_FIELD = 3;
+    private static final byte VALUE_FIELD = 4;
+    private static final byte EXTENT_FIELD = 5;
+    /** The default extent for a vector tile */
+    static final int DEFAULT_EXTENT = 4096;
+    private static final byte DEFAULT_VERSION = 1;
+    /** This is <i>technically</i> an integer, but there are currently only two major versions (1, 2). Required. */
+    private final byte version;
+    /** A unique name for the layer. This <i>must</i> be unique on a per-tile basis. Required. */
+    private final String name;
+
+    /** The extent of the tile, typically 4096. Required. */
+    private final int extent;
+
+    /** A list of unique keys. Order is important. Optional. */
+    private final List<String> keyList = new ArrayList<>();
+    /** A list of unique values. Order is important. Optional. */
+    private final List<Object> valueList = new ArrayList<>();
+    /** The actual features of this layer in this tile */
+    private final List<Feature> featureCollection;
+
+    /**
+     * Create a layer from a collection of records
+     * @param records The records to convert to a layer
+     * @throws IOException - if an IO error occurs
+     */
+    public Layer(Collection<ProtobufRecord> records) throws IOException {
+        // Do the unique required fields first
+        Map<Integer, List<ProtobufRecord>> sorted = records.stream().collect(Collectors.groupingBy(ProtobufRecord::getField));
+        this.version = sorted.getOrDefault((int) VERSION_FIELD, Collections.emptyList()).parallelStream()
+          .map(ProtobufRecord::asUnsignedVarInt).map(Number::byteValue).findFirst().orElse(DEFAULT_VERSION);
+        // Per spec, we cannot continue past this until we have checked the version number
+        if (this.version != 1 && this.version != 2) {
+            throw new IllegalArgumentException(tr("We do not understand version {0} of the vector tile specification", this.version));
+        }
+        this.name = sorted.getOrDefault((int) NAME_FIELD, Collections.emptyList()).parallelStream().map(ProtobufRecord::asString).findFirst()
+                .orElseThrow(() -> new IllegalArgumentException(tr("Vector tile layers must have a layer name")));
+        this.extent = sorted.getOrDefault((int) EXTENT_FIELD, Collections.emptyList()).parallelStream().map(ProtobufRecord::asUnsignedVarInt)
+                .map(Number::intValue).findAny().orElse(DEFAULT_EXTENT);
+
+        sorted.getOrDefault((int) KEY_FIELD, Collections.emptyList()).parallelStream().map(ProtobufRecord::asString)
+                .forEachOrdered(this.keyList::add);
+        sorted.getOrDefault((int) VALUE_FIELD, Collections.emptyList()).parallelStream().map(ProtobufRecord::getBytes)
+                .map(ProtobufParser::new).map(parser1 -> {
+                    try {
+                        return new ProtobufRecord(parser1);
+                    } catch (IOException e) {
+                        Logging.error(e);
+                        return null;
+                    }
+                })
+                .filter(Objects::nonNull)
+                .map(value -> ValueFields.MAPPERS.parallelStream()
+                        .filter(v -> v.getField() == value.getField())
+                        .map(v -> v.convertValue(value)).findFirst()
+                        .orElseThrow(() -> new IllegalArgumentException(tr("Unknown field in vector tile layer value ({0})", value.getField()))))
+                .forEachOrdered(this.valueList::add);
+        Collection<IOException> exceptions = new HashSet<>(0);
+        this.featureCollection = sorted.getOrDefault((int) FEATURE_FIELD, Collections.emptyList()).parallelStream().map(feature -> {
+            try {
+                return new Feature(this, feature);
+            } catch (IOException e) {
+                exceptions.add(e);
+            }
+            return null;
+        }).collect(Collectors.toList());
+        if (!exceptions.isEmpty()) {
+            throw exceptions.iterator().next();
+        }
+        // Cleanup bytes (for memory)
+        for (ProtobufRecord record : records) {
+            record.close();
+        }
+    }
+
+    /**
+     * Get all the records from a array of bytes
+     * @param bytes The byte information
+     * @return All the protobuf records
+     * @throws IOException If there was an error reading the bytes (unlikely)
+     */
+    private static Collection<ProtobufRecord> getAllRecords(byte[] bytes) throws IOException {
+        try (ProtobufParser parser = new ProtobufParser(bytes)) {
+            return parser.allRecords();
+        }
+    }
+
+    /**
+     * Create a new layer
+     * @param bytes The bytes that the layer comes from
+     * @throws IOException - if an IO error occurs
+     */
+    public Layer(byte[] bytes) throws IOException {
+        this(getAllRecords(bytes));
+    }
+
+    /**
+     * Get the extent of the tile
+     * @return The layer extent
+     */
+    public int getExtent() {
+        return this.extent;
+    }
+
+    /**
+     * Get the feature on this layer
+     * @return the features
+     */
+    public Collection<Feature> getFeatures() {
+        return Collections.unmodifiableCollection(this.featureCollection);
+    }
+
+    /**
+     * Get the geometry for this layer
+     * @return The geometry
+     */
+    public Collection<Geometry> getGeometry() {
+        return getFeatures().stream().map(Feature::getGeometryObject).collect(Collectors.toList());
+    }
+
+    /**
+     * Get a specified key
+     * @param index The index in the key list
+     * @return The actual key
+     */
+    public String getKey(int index) {
+        return this.keyList.get(index);
+    }
+
+    /**
+     * Get the name of the layer
+     * @return The layer name
+     */
+    public String getName() {
+        return this.name;
+    }
+
+    /**
+     * Get a specified value
+     * @param index The index in the value list
+     * @return The actual value. This can be a {@link String}, {@link Boolean}, {@link Integer}, or {@link Float} value.
+     */
+    public Object getValue(int index) {
+        return this.valueList.get(index);
+    }
+
+    /**
+     * Get the Mapbox Vector Tile version specification for this layer
+     * @return The version of the Mapbox Vector Tile specification
+     */
+    public byte getVersion() {
+        return this.version;
+    }
+
+    @Override
+    public void destroy() {
+        this.featureCollection.clear();
+        this.keyList.clear();
+        this.valueList.clear();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other instanceof Layer) {
+            Layer o = (Layer) other;
+            return this.extent == o.extent
+              && this.version == o.version
+              && Objects.equals(this.name, o.name)
+              && Objects.equals(this.featureCollection, o.featureCollection)
+              && Objects.equals(this.keyList, o.keyList)
+              && Objects.equals(this.valueList, o.valueList);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.name, this.version, this.extent, this.featureCollection, this.keyList, this.valueList);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java	(revision 17862)
@@ -0,0 +1,35 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Items that MAY be used to figure out if a file or server response MAY BE a Mapbox Vector Tile
+ * @author Taylor Smock
+ * @since xxx
+ */
+public final class MVTFile {
+    /**
+     * Extensions for Mapbox Vector Tiles.
+     * {@code mvt} is a SHOULD, <i>not</i> a MUST.
+     */
+    public static final List<String> EXTENSION = Collections.unmodifiableList(Arrays.asList("mvt", "pbf"));
+
+    /**
+     * mimetypes for Mapbox Vector Tiles
+     * This {@code application/vnd.mapbox-vector-tile}is a SHOULD, <i>not</i> a MUST.
+     */
+    public static final List<String> MIMETYPE = Collections.unmodifiableList(Arrays.asList("application/vnd.mapbox-vector-tile",
+            "application/x-protobuf"));
+
+    /**
+     * The default projection. This is Web Mercator, per specification.
+     */
+    public static final String DEFAULT_PROJECTION = "EPSG:3857";
+
+    private MVTFile() {
+        // Hide the constructor
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java	(revision 17862)
@@ -0,0 +1,155 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
+import org.openstreetmap.josm.data.IQuadBucketType;
+import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.protobuf.ProtobufParser;
+import org.openstreetmap.josm.data.protobuf.ProtobufRecord;
+import org.openstreetmap.josm.data.vector.VectorDataStore;
+import org.openstreetmap.josm.tools.ListenerList;
+import org.openstreetmap.josm.tools.Logging;
+
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * A class for Mapbox Vector Tiles
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MVTTile extends Tile implements VectorTile, IQuadBucketType {
+    private final ListenerList<TileListener> listenerList = ListenerList.create();
+    private Collection<Layer> layers;
+    private int extent = Layer.DEFAULT_EXTENT;
+    static final BufferedImage CLEAR_LOADED = new BufferedImage(1, 1, BufferedImage.TYPE_4BYTE_ABGR);
+    private BBox bbox;
+    private VectorDataStore vectorDataStore;
+
+    /**
+     * Create a new Tile
+     * @param source The source of the tile
+     * @param xtile The x coordinate for the tile
+     * @param ytile The y coordinate for the tile
+     * @param zoom The zoom for the tile
+     */
+    public MVTTile(TileSource source, int xtile, int ytile, int zoom) {
+        super(source, xtile, ytile, zoom);
+    }
+
+    @Override
+    public void loadImage(final InputStream inputStream) throws IOException {
+        if (this.image == null || this.image == Tile.LOADING_IMAGE || this.image == Tile.ERROR_IMAGE) {
+            this.initLoading();
+            ProtobufParser parser = new ProtobufParser(inputStream);
+            Collection<ProtobufRecord> protobufRecords = parser.allRecords();
+            this.layers = new HashSet<>();
+            this.layers = protobufRecords.stream().map(protoBufRecord -> {
+                Layer mvtLayer = null;
+                if (protoBufRecord.getField() == Layer.LAYER_FIELD) {
+                    try (ProtobufParser tParser = new ProtobufParser(protoBufRecord.getBytes())) {
+                        mvtLayer = new Layer(tParser.allRecords());
+                    } catch (IOException e) {
+                        Logging.error(e);
+                    } finally {
+                        // Cleanup bytes
+                        protoBufRecord.close();
+                    }
+                }
+                return mvtLayer;
+            }).collect(Collectors.toCollection(HashSet::new));
+            this.extent = layers.stream().map(Layer::getExtent).max(Integer::compare).orElse(Layer.DEFAULT_EXTENT);
+            if (this.getData() != null) {
+                this.finishLoading();
+                this.listenerList.fireEvent(event -> event.finishedLoading(this));
+                // Ensure that we don't keep the loading image around
+                this.image = CLEAR_LOADED;
+                // Cleanup as much as possible -- layers will still exist, but only base information (like name, extent) will remain.
+                // Called last just in case the listeners need the layers.
+                this.layers.forEach(Layer::destroy);
+            }
+        }
+    }
+
+    @Override
+    public Collection<Layer> getLayers() {
+        return this.layers;
+    }
+
+    @Override
+    public int getExtent() {
+        return this.extent;
+    }
+
+    /**
+     * Add a tile loader finisher listener
+     *
+     * @param listener The listener to add
+     */
+    public void addTileLoaderFinisher(TileListener listener) {
+        // Add as weak listeners since we don't want to keep unnecessary references.
+        this.listenerList.addWeakListener(listener);
+    }
+
+    @Override
+    public BBox getBBox() {
+        if (this.bbox == null) {
+            final ICoordinate upperLeft = this.getTileSource().tileXYToLatLon(this);
+            final ICoordinate lowerRight = this.getTileSource()
+                    .tileXYToLatLon(this.getXtile() + 1, this.getYtile() + 1, this.getZoom());
+            BBox newBBox = new BBox(upperLeft.getLon(), upperLeft.getLat(), lowerRight.getLon(), lowerRight.getLat());
+            this.bbox = newBBox.toImmutable();
+        }
+        return this.bbox;
+    }
+
+    /**
+     * Get the datastore for this tile
+     * @return The data
+     */
+    public VectorDataStore getData() {
+        if (this.vectorDataStore == null) {
+            VectorDataStore newDataStore = new VectorDataStore();
+            newDataStore.addDataTile(this);
+            this.vectorDataStore = newDataStore;
+        }
+        return this.vectorDataStore;
+    }
+
+    /**
+     * A class that can be notified that a tile has finished loading
+     *
+     * @author Taylor Smock
+     */
+    public interface TileListener {
+        /**
+         * Called when the MVTTile is finished loading
+         *
+         * @param tile The tile that finished loading
+         */
+        void finishedLoading(MVTTile tile);
+    }
+
+    /**
+     * A class used to set the layers that an MVTTile will show.
+     *
+     * @author Taylor Smock
+     */
+    public interface LayerShower {
+        /**
+         * Get a list of layers to show
+         *
+         * @return A list of layer names
+         */
+        List<String> layersToShow();
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoader.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoader.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoader.java	(revision 17862)
@@ -0,0 +1,79 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.CachedTileLoader;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
+import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
+import org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob;
+import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
+import org.openstreetmap.josm.data.imagery.TileJobOptions;
+import org.openstreetmap.josm.data.preferences.IntegerProperty;
+import org.openstreetmap.josm.tools.CheckParameterUtil;
+
+import org.apache.commons.jcs3.access.behavior.ICacheAccess;
+
+/**
+ * A TileLoader class for MVT tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MapboxVectorCachedTileLoader implements TileLoader, CachedTileLoader {
+    protected final ICacheAccess<String, BufferedImageCacheEntry> cache;
+    protected final TileLoaderListener listener;
+    protected final TileJobOptions options;
+    private static final IntegerProperty THREAD_LIMIT =
+            new IntegerProperty("imagery.vector.mvtloader.maxjobs", TMSCachedTileLoader.THREAD_LIMIT.getDefaultValue());
+    private static final ThreadPoolExecutor DEFAULT_DOWNLOAD_JOB_DISPATCHER =
+            TMSCachedTileLoader.getNewThreadPoolExecutor("MVT-downloader-%d", THREAD_LIMIT.get());
+
+    /**
+     * Constructor
+     * @param listener          called when tile loading has finished
+     * @param cache             of the cache
+     * @param options           tile job options
+     */
+    public MapboxVectorCachedTileLoader(TileLoaderListener listener, ICacheAccess<String, BufferedImageCacheEntry> cache,
+                                        TileJobOptions options) {
+        CheckParameterUtil.ensureParameterNotNull(cache, "cache");
+        this.cache = cache;
+        this.options = options;
+        this.listener = listener;
+    }
+
+    @Override
+    public void clearCache(TileSource source) {
+        this.cache.remove(source.getName() + ':');
+    }
+
+    @Override
+    public TileJob createTileLoaderJob(Tile tile) {
+        return new MapboxVectorCachedTileLoaderJob(
+                listener,
+                tile,
+                cache,
+                options,
+                getDownloadExecutor());
+    }
+
+    @Override
+    public void cancelOutstandingTasks() {
+        final ThreadPoolExecutor executor = getDownloadExecutor();
+        executor.getQueue().stream().filter(executor::remove).filter(MapboxVectorCachedTileLoaderJob.class::isInstance)
+                .map(MapboxVectorCachedTileLoaderJob.class::cast).forEach(JCSCachedTileLoaderJob::handleJobCancellation);
+    }
+
+    @Override
+    public boolean hasOutstandingTasks() {
+        return getDownloadExecutor().getTaskCount() > getDownloadExecutor().getCompletedTaskCount();
+    }
+
+    private static ThreadPoolExecutor getDownloadExecutor() {
+        return DEFAULT_DOWNLOAD_JOB_DISPATCHER;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoaderJob.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoaderJob.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoaderJob.java	(revision 17862)
@@ -0,0 +1,26 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
+import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
+import org.openstreetmap.josm.data.imagery.TMSCachedTileLoaderJob;
+import org.openstreetmap.josm.data.imagery.TileJobOptions;
+
+import org.apache.commons.jcs3.access.behavior.ICacheAccess;
+
+/**
+ * Bridge to JCS cache for MVT tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MapboxVectorCachedTileLoaderJob extends TMSCachedTileLoaderJob {
+
+    public MapboxVectorCachedTileLoaderJob(TileLoaderListener listener, Tile tile,
+                                           ICacheAccess<String, BufferedImageCacheEntry> cache, TileJobOptions options,
+                                           ThreadPoolExecutor downloadExecutor) {
+        super(listener, tile, cache, options, downloadExecutor);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java	(revision 17862)
@@ -0,0 +1,95 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import javax.json.Json;
+import javax.json.JsonException;
+import javax.json.JsonObject;
+import javax.json.JsonReader;
+
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.JosmTemplatedTMSTileSource;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.MapboxVectorStyle;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.Source;
+import org.openstreetmap.josm.gui.ExtendedDialog;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.gui.widgets.JosmComboBox;
+import org.openstreetmap.josm.io.CachedFile;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * Tile Source handling for Mapbox Vector Tile sources
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MapboxVectorTileSource extends JosmTemplatedTMSTileSource {
+    private final MapboxVectorStyle styleSource;
+
+    /**
+     * Create a new {@link MapboxVectorTileSource} from an {@link ImageryInfo}
+     * @param info The info to create the source from
+     */
+    public MapboxVectorTileSource(ImageryInfo info) {
+        super(info);
+        MapboxVectorStyle mapBoxVectorStyle = null;
+        try (CachedFile style = new CachedFile(info.getUrl());
+          InputStream inputStream = style.getInputStream();
+          JsonReader reader = Json.createReader(inputStream)) {
+            JsonObject object = reader.readObject();
+            // OK, we may have a stylesheet. "version", "layers", and "sources" are all required.
+            if (object.containsKey("version") && object.containsKey("layers") && object.containsKey("sources")) {
+                mapBoxVectorStyle = MapboxVectorStyle.getMapboxVectorStyle(info.getUrl());
+            }
+        } catch (IOException | JsonException e) {
+            Logging.trace(e);
+        }
+        this.styleSource = mapBoxVectorStyle;
+        if (this.styleSource != null) {
+            final Source source;
+            List<Source> sources = this.styleSource.getSources().keySet().stream().filter(Objects::nonNull)
+              .collect(Collectors.toList());
+            if (sources.size() == 1) {
+                source = sources.get(0);
+            } else if (!sources.isEmpty()) {
+                // Ask user what source they want.
+                source = GuiHelper.runInEDTAndWaitAndReturn(() -> {
+                    ExtendedDialog dialog = new ExtendedDialog(MainApplication.getMainFrame(),
+                      tr("Select Vector Tile Layers"), tr("Add layers"));
+                    JosmComboBox<Source> comboBox = new JosmComboBox<>(sources.toArray(new Source[0]));
+                    comboBox.setSelectedIndex(0);
+                    dialog.setContent(comboBox);
+                    dialog.showDialog();
+                    return (Source) comboBox.getSelectedItem();
+                });
+            } else {
+                // Umm. What happened? We probably have an invalid style source.
+                throw new InvalidMapboxVectorTileException(tr("Cannot understand style source: {0}", info.getUrl()));
+            }
+            if (source != null) {
+                this.name = name + ": " + source.getName();
+                // There can technically be multiple URL's for this field; unfortunately, JOSM can only handle one right now.
+                this.baseUrl = source.getUrls().get(0);
+                this.minZoom = source.getMinZoom();
+                this.maxZoom = source.getMaxZoom();
+                if (source.getAttributionText() != null) {
+                    this.setAttributionText(source.getAttributionText());
+                }
+            }
+        }
+    }
+
+    /**
+     * Get the style source for this Vector Tile source
+     * @return The source to use for styling
+     */
+    public MapboxVectorStyle getStyleSource() {
+        return this.styleSource;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Expression.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Expression.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Expression.java	(revision 17862)
@@ -0,0 +1,99 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import javax.json.JsonArray;
+import javax.json.JsonObject;
+import javax.json.JsonString;
+import javax.json.JsonValue;
+
+/**
+ * A Mapbox vector style expression (immutable)
+ * @author Taylor Smock
+ * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/">https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/</a>
+ * @since xxx
+ */
+public final class Expression {
+    /** An empty expression to use */
+    public static final Expression EMPTY_EXPRESSION = new Expression(JsonValue.NULL);
+    private static final String EMPTY_STRING = "";
+
+    private final String mapcssFilterExpression;
+
+    /**
+     * Create a new filter expression. <i>Please note that this currently only supports basic comparators!</i>
+     * @param value The value to parse
+     */
+    public Expression(JsonValue value) {
+        if (value.getValueType() == JsonValue.ValueType.ARRAY) {
+            final JsonArray array = value.asJsonArray();
+            if (!array.isEmpty() && array.get(0).getValueType() == JsonValue.ValueType.STRING) {
+                if ("==".equals(array.getString(0))) {
+                    // The mapcss equivalent of == is = (for the most part)
+                    this.mapcssFilterExpression = convertToString(array.get(1)) + "=" + convertToString(array.get(2));
+                } else if (Arrays.asList("<=", ">=", ">", "<", "!=").contains(array.getString(0))) {
+                    this.mapcssFilterExpression = convertToString(array.get(1)) + array.getString(0) + convertToString(array.get(2));
+                } else {
+                    this.mapcssFilterExpression = EMPTY_STRING;
+                }
+            } else {
+                this.mapcssFilterExpression = EMPTY_STRING;
+            }
+        } else {
+            this.mapcssFilterExpression = EMPTY_STRING;
+        }
+    }
+
+    /**
+     * Convert a value to a string
+     * @param value The value to convert
+     * @return A string
+     */
+    private static String convertToString(JsonValue value) {
+        switch (value.getValueType()) {
+        case STRING:
+            return ((JsonString) value).getString();
+        case FALSE:
+            return Boolean.FALSE.toString();
+        case TRUE:
+            return Boolean.TRUE.toString();
+        case NUMBER:
+            return value.toString();
+        case ARRAY:
+            return '['
+              + ((JsonArray) value).stream().map(Expression::convertToString).collect(Collectors.joining(","))
+              + ']';
+        case OBJECT:
+            return '{'
+              + ((JsonObject) value).entrySet().stream()
+              .map(entry -> entry.getKey() + ":" + convertToString(entry.getValue())).collect(
+                Collectors.joining(","))
+              + '}';
+        case NULL:
+        default:
+            return EMPTY_STRING;
+        }
+    }
+
+    @Override
+    public String toString() {
+        return !EMPTY_STRING.equals(this.mapcssFilterExpression) ? '[' + this.mapcssFilterExpression + ']' : EMPTY_STRING;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other instanceof Expression) {
+            Expression o = (Expression) other;
+            return Objects.equals(this.mapcssFilterExpression, o.mapcssFilterExpression);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.mapcssFilterExpression);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java	(revision 17862)
@@ -0,0 +1,524 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import org.openstreetmap.josm.gui.mappaint.StyleKeys;
+
+import java.awt.Font;
+import java.awt.GraphicsEnvironment;
+import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.json.JsonArray;
+import javax.json.JsonNumber;
+import javax.json.JsonObject;
+import javax.json.JsonString;
+import javax.json.JsonValue;
+
+/**
+ * Mapbox style layers
+ * @author Taylor Smock
+ * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/">https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/</a>
+ * @since xxx
+ */
+public class Layers {
+    /**
+     * The layer type. This affects the rendering.
+     * @author Taylor Smock
+     * @since xxx
+     */
+    enum Type {
+        /** Filled polygon with an (optional) border */
+        FILL,
+        /** A line */
+        LINE,
+        /** A symbol */
+        SYMBOL,
+        /** A circle */
+        CIRCLE,
+        /** A heatmap */
+        HEATMAP,
+        /** A 3D polygon extrusion */
+        FILL_EXTRUSION,
+        /** Raster */
+        RASTER,
+        /** Hillshade data */
+        HILLSHADE,
+        /** A background color or pattern */
+        BACKGROUND,
+        /** The fallback layer */
+        SKY
+    }
+
+    private static final String EMPTY_STRING = "";
+    private static final char SEMI_COLON = ';';
+    private static final Pattern CURLY_BRACES = Pattern.compile("(\\{(.*?)})");
+    private static final String PAINT = "paint";
+
+    /** A required unique layer name */
+    private final String id;
+    /** The required type */
+    private final Type type;
+    /** An optional expression */
+    private final Expression filter;
+    /** The max zoom for the layer */
+    private final int maxZoom;
+    /** The min zoom for the layer */
+    private final int minZoom;
+
+    /** Default paint properties for this layer */
+    private final String paint;
+
+    /** A source description to be used with this layer. Required for everything <i>but</i> {@link Type#BACKGROUND} */
+    private final String source;
+    /** Layer to use from the vector tile source. Only allowed with {@link SourceType#VECTOR}. */
+    private final String sourceLayer;
+    /** The id for the style -- used for image paths */
+    private final String styleId;
+    /**
+     * Create a layer object
+     * @param layerInfo The info to use to create the layer
+     */
+    public Layers(final JsonObject layerInfo) {
+        this (null, layerInfo);
+    }
+
+    /**
+     * Create a layer object
+     * @param styleId The id for the style (image paths require this)
+     * @param layerInfo The info to use to create the layer
+     */
+    public Layers(final String styleId, final JsonObject layerInfo) {
+        this.id = layerInfo.getString("id");
+        this.styleId = styleId;
+        this.type = Type.valueOf(layerInfo.getString("type").replace("-", "_").toUpperCase(Locale.ROOT));
+        if (layerInfo.containsKey("filter")) {
+            this.filter = new Expression(layerInfo.get("filter"));
+        } else {
+            this.filter = Expression.EMPTY_EXPRESSION;
+        }
+        this.maxZoom = layerInfo.getInt("maxzoom", Integer.MAX_VALUE);
+        this.minZoom = layerInfo.getInt("minzoom", Integer.MIN_VALUE);
+        // There is a metadata field (I don't *think* I need it?)
+        // source is only optional with {@link Type#BACKGROUND}.
+        if (this.type == Type.BACKGROUND) {
+            this.source = layerInfo.getString("source", null);
+        } else {
+            this.source = layerInfo.getString("source");
+        }
+        if (layerInfo.containsKey(PAINT) && layerInfo.get(PAINT).getValueType() == JsonValue.ValueType.OBJECT) {
+            final JsonObject paintObject = layerInfo.getJsonObject(PAINT);
+            final JsonObject layoutObject = layerInfo.getOrDefault("layout", JsonValue.EMPTY_JSON_OBJECT).asJsonObject();
+            // Don't throw exceptions here, since we may just point at the styling
+            if ("visible".equalsIgnoreCase(layoutObject.getString("visibility", "visible"))) {
+                switch (type) {
+                case FILL:
+                    // area
+                    this.paint = parsePaintFill(paintObject);
+                    break;
+                case LINE:
+                    // way
+                    this.paint = parsePaintLine(layoutObject, paintObject);
+                    break;
+                case CIRCLE:
+                    // point
+                    this.paint = parsePaintCircle(paintObject);
+                    break;
+                case SYMBOL:
+                    // point
+                    this.paint = parsePaintSymbol(layoutObject, paintObject);
+                    break;
+                case BACKGROUND:
+                    // canvas only
+                    this.paint = parsePaintBackground(paintObject);
+                    break;
+                default:
+                    this.paint = EMPTY_STRING;
+                }
+            } else {
+                this.paint = EMPTY_STRING;
+            }
+        } else {
+            this.paint = EMPTY_STRING;
+        }
+        this.sourceLayer = layerInfo.getString("source-layer", null);
+    }
+
+    /**
+     * Get the filter for this layer
+     * @return The filter
+     */
+    public Expression getFilter() {
+        return this.filter;
+    }
+
+    /**
+     * Get the unique id for this layer
+     * @return The unique id
+     */
+    public String getId() {
+        return this.id;
+    }
+
+    /**
+     * Get the type of this layer
+     * @return The layer type
+     */
+    public Type getType() {
+        return this.type;
+    }
+
+    private static String parsePaintLine(final JsonObject layoutObject, final JsonObject paintObject) {
+        final StringBuilder sb = new StringBuilder(36);
+        // line-blur, default 0 (px)
+        // line-color, default #000000, disabled by line-pattern
+        final String color = paintObject.getString("line-color", "#000000");
+        sb.append(StyleKeys.COLOR).append(':').append(color).append(SEMI_COLON);
+        // line-opacity, default 1 (0-1)
+        final JsonNumber opacity = paintObject.getJsonNumber("line-opacity");
+        if (opacity != null) {
+            sb.append(StyleKeys.OPACITY).append(':').append(opacity.numberValue().doubleValue()).append(SEMI_COLON);
+        }
+        // line-cap, default butt (butt|round|square)
+        final String cap = layoutObject.getString("line-cap", "butt");
+        sb.append(StyleKeys.LINECAP).append(':');
+        switch (cap) {
+        case "round":
+        case "square":
+            sb.append(cap);
+            break;
+        case "butt":
+        default:
+            sb.append("none");
+        }
+
+        sb.append(SEMI_COLON);
+        // line-dasharray, array of number >= 0, units in line widths, disabled by line-pattern
+        if (paintObject.containsKey("line-dasharray")) {
+            final JsonArray dashArray = paintObject.getJsonArray("line-dasharray");
+            sb.append(StyleKeys.DASHES).append(':');
+            sb.append(dashArray.stream().filter(JsonNumber.class::isInstance).map(JsonNumber.class::cast)
+              .map(JsonNumber::toString).collect(Collectors.joining(",")));
+            sb.append(SEMI_COLON);
+        }
+        // line-gap-width
+        // line-gradient
+        // line-join
+        // line-miter-limit
+        // line-offset
+        // line-pattern TODO this first, since it disables stuff
+        // line-round-limit
+        // line-sort-key
+        // line-translate
+        // line-translate-anchor
+        // line-width
+        final JsonNumber width = paintObject.getJsonNumber("line-width");
+        sb.append(StyleKeys.WIDTH).append(':').append(width == null ? 1 : width.toString()).append(SEMI_COLON);
+        return sb.toString();
+    }
+
+    private static String parsePaintCircle(final JsonObject paintObject) {
+        final StringBuilder sb = new StringBuilder(150).append("symbol-shape:circle;")
+          // circle-blur
+          // circle-color
+          .append("symbol-fill-color:").append(paintObject.getString("circle-color", "#000000")).append(SEMI_COLON);
+        // circle-opacity
+        final JsonNumber fillOpacity = paintObject.getJsonNumber("circle-opacity");
+        sb.append("symbol-fill-opacity:").append(fillOpacity != null ? fillOpacity.numberValue().toString() : "1").append(SEMI_COLON);
+        // circle-pitch-alignment // not 3D
+        // circle-pitch-scale // not 3D
+        // circle-radius
+        final JsonNumber radius = paintObject.getJsonNumber("circle-radius");
+        sb.append("symbol-size:").append(radius != null ? (2 * radius.numberValue().doubleValue()) : "10").append(SEMI_COLON)
+          // circle-sort-key
+          // circle-stroke-color
+          .append("symbol-stroke-color:").append(paintObject.getString("circle-stroke-color", "#000000")).append(SEMI_COLON);
+        // circle-stroke-opacity
+        final JsonNumber strokeOpacity = paintObject.getJsonNumber("circle-stroke-opacity");
+        sb.append("symbol-stroke-opacity:").append(strokeOpacity != null ? strokeOpacity.numberValue().toString() : "1").append(SEMI_COLON);
+        // circle-stroke-width
+        final JsonNumber strokeWidth = paintObject.getJsonNumber("circle-stroke-width");
+        sb.append("symbol-stroke-width:").append(strokeWidth != null ? strokeWidth.numberValue().toString() : "0").append(SEMI_COLON);
+        // circle-translate
+        // circle-translate-anchor
+        return sb.toString();
+    }
+
+    private String parsePaintSymbol(
+      final JsonObject layoutObject,
+      final JsonObject paintObject) {
+        final StringBuilder sb = new StringBuilder();
+        // icon-allow-overlap
+        // icon-anchor
+        // icon-color
+        // icon-halo-blur
+        // icon-halo-color
+        // icon-halo-width
+        // icon-ignore-placement
+        // icon-image
+        boolean iconImage = false;
+        if (layoutObject.containsKey("icon-image")) {
+            sb.append("icon-image:concat(");
+            if (this.styleId != null && !this.styleId.trim().isEmpty()) {
+                sb.append('"').append(this.styleId).append('/').append("\",");
+            }
+            Matcher matcher = CURLY_BRACES.matcher(layoutObject.getString("icon-image"));
+            StringBuffer stringBuffer = new StringBuffer();
+            int previousMatch;
+            if (matcher.lookingAt()) {
+                matcher.appendReplacement(stringBuffer, "tag(\"$2\"),\"");
+                previousMatch = matcher.end();
+            } else {
+                previousMatch = 0;
+                stringBuffer.append('"');
+            }
+            while (matcher.find()) {
+                if (matcher.start() == previousMatch) {
+                    matcher.appendReplacement(stringBuffer, ",tag(\"$2\")");
+                } else {
+                    matcher.appendReplacement(stringBuffer, "\",tag(\"$2\"),\"");
+                }
+                previousMatch = matcher.end();
+            }
+            if (matcher.hitEnd() && stringBuffer.toString().endsWith(",\"")) {
+                stringBuffer.delete(stringBuffer.length() - ",\"".length(), stringBuffer.length());
+            } else if (!matcher.hitEnd()) {
+                stringBuffer.append('"');
+            }
+            StringBuffer tail = new StringBuffer();
+            matcher.appendTail(tail);
+            if (tail.length() > 0) {
+                String current = stringBuffer.toString();
+                if (!"\"".equals(current) && !current.endsWith(",\"")) {
+                    stringBuffer.append(",\"");
+                }
+                stringBuffer.append(tail);
+                stringBuffer.append('"');
+            }
+
+            sb.append(stringBuffer).append(')').append(SEMI_COLON);
+            iconImage = true;
+        }
+        // icon-keep-upright
+        // icon-offset
+        if (iconImage && layoutObject.containsKey("icon-offset")) {
+            // default [0, 0], right,down == positive, left,up == negative
+            final List<JsonNumber> offset = layoutObject.getJsonArray("icon-offset").getValuesAs(JsonNumber.class);
+            // Assume that the offset must be size 2. Probably not necessary, but docs aren't necessary clear.
+            if (offset.size() == 2) {
+                sb.append("icon-offset-x:").append(offset.get(0).doubleValue()).append(SEMI_COLON)
+                  .append("icon-offset-y:").append(offset.get(1).doubleValue()).append(SEMI_COLON);
+            }
+        }
+        // icon-opacity
+        if (iconImage && paintObject.containsKey("icon-opacity")) {
+            final double opacity = paintObject.getJsonNumber("icon-opacity").doubleValue();
+            sb.append("icon-opacity:").append(opacity).append(SEMI_COLON);
+        }
+        // icon-optional
+        // icon-padding
+        // icon-pitch-alignment
+        // icon-rotate
+        if (iconImage && layoutObject.containsKey("icon-rotate")) {
+            final double rotation = layoutObject.getJsonNumber("icon-rotate").doubleValue();
+            sb.append("icon-rotation:").append(rotation).append(SEMI_COLON);
+        }
+        // icon-rotation-alignment
+        // icon-size
+        // icon-text-fit
+        // icon-text-fit-padding
+        // icon-translate
+        // icon-translate-anchor
+        // symbol-avoid-edges
+        // symbol-placement
+        // symbol-sort-key
+        // symbol-spacing
+        // symbol-z-order
+        // text-allow-overlap
+        // text-anchor
+        // text-color
+        if (paintObject.containsKey(StyleKeys.TEXT_COLOR)) {
+            sb.append(StyleKeys.TEXT_COLOR).append(':').append(paintObject.getString(StyleKeys.TEXT_COLOR)).append(SEMI_COLON);
+        }
+        // text-field
+        if (layoutObject.containsKey("text-field")) {
+            sb.append(StyleKeys.TEXT).append(':')
+              .append(layoutObject.getString("text-field").replace("}", EMPTY_STRING).replace("{", EMPTY_STRING))
+              .append(SEMI_COLON);
+        }
+        // text-font
+        if (layoutObject.containsKey("text-font")) {
+            List<String> fonts = layoutObject.getJsonArray("text-font").stream().filter(JsonString.class::isInstance)
+              .map(JsonString.class::cast).map(JsonString::getString).collect(Collectors.toList());
+            Font[] systemFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().getAllFonts();
+            for (String fontString : fonts) {
+                Collection<Font> fontMatches = Stream.of(systemFonts)
+                  .filter(font -> Arrays.asList(font.getName(), font.getFontName(), font.getFamily(), font.getPSName()).contains(fontString))
+                  .collect(Collectors.toList());
+                if (!fontMatches.isEmpty()) {
+                    final Font setFont = fontMatches.stream().filter(font -> font.getName().equals(fontString)).findAny()
+                      .orElseGet(() -> fontMatches.stream().filter(font -> font.getFontName().equals(fontString)).findAny()
+                        .orElseGet(() -> fontMatches.stream().filter(font -> font.getPSName().equals(fontString)).findAny()
+                        .orElseGet(() -> fontMatches.stream().filter(font -> font.getFamily().equals(fontString)).findAny().orElse(null))));
+                    if (setFont != null) {
+                        sb.append(StyleKeys.FONT_FAMILY).append(':').append('"').append(setFont.getFamily()).append('"').append(SEMI_COLON);
+                        sb.append(StyleKeys.FONT_WEIGHT).append(':').append(setFont.isBold() ? "bold" : "normal").append(SEMI_COLON);
+                        sb.append(StyleKeys.FONT_STYLE).append(':').append(setFont.isItalic() ? "italic" : "normal").append(SEMI_COLON);
+                        break;
+                    }
+                }
+            }
+        }
+        // text-halo-blur
+        // text-halo-color
+        if (paintObject.containsKey(StyleKeys.TEXT_HALO_COLOR)) {
+            sb.append(StyleKeys.TEXT_HALO_COLOR).append(':').append(paintObject.getString(StyleKeys.TEXT_HALO_COLOR)).append(SEMI_COLON);
+        }
+        // text-halo-width
+        if (paintObject.containsKey("text-halo-width")) {
+            sb.append(StyleKeys.TEXT_HALO_RADIUS).append(':').append(paintObject.getJsonNumber("text-halo-width").intValue() / 2)
+                    .append(SEMI_COLON);
+        }
+        // text-ignore-placement
+        // text-justify
+        // text-keep-upright
+        // text-letter-spacing
+        // text-line-height
+        // text-max-angle
+        // text-max-width
+        // text-offset
+        // text-opacity
+        if (paintObject.containsKey(StyleKeys.TEXT_OPACITY)) {
+            sb.append(StyleKeys.TEXT_OPACITY).append(':').append(paintObject.getJsonNumber(StyleKeys.TEXT_OPACITY).doubleValue())
+                    .append(SEMI_COLON);
+        }
+        // text-optional
+        // text-padding
+        // text-pitch-alignment
+        // text-radial-offset
+        // text-rotate
+        // text-rotation-alignment
+        // text-size
+        final JsonNumber textSize = layoutObject.getJsonNumber("text-size");
+        sb.append(StyleKeys.FONT_SIZE).append(':').append(textSize != null ? textSize.numberValue().toString() : "16").append(SEMI_COLON);
+        // text-transform
+        // text-translate
+        // text-translate-anchor
+        // text-variable-anchor
+        // text-writing-mode
+        return sb.toString();
+    }
+
+    private static String parsePaintBackground(final JsonObject paintObject) {
+        final StringBuilder sb = new StringBuilder(20);
+        // background-color
+        final String bgColor = paintObject.getString("background-color", null);
+        if (bgColor != null) {
+            sb.append(StyleKeys.FILL_COLOR).append(':').append(bgColor).append(SEMI_COLON);
+        }
+        // background-opacity
+        // background-pattern
+        return sb.toString();
+    }
+
+    private static String parsePaintFill(final JsonObject paintObject) {
+        StringBuilder sb = new StringBuilder(50)
+          // fill-antialias
+          // fill-color
+          .append(StyleKeys.FILL_COLOR).append(':').append(paintObject.getString(StyleKeys.FILL_COLOR, "#000000")).append(SEMI_COLON);
+        // fill-opacity
+        final JsonNumber opacity = paintObject.getJsonNumber(StyleKeys.FILL_OPACITY);
+        sb.append(StyleKeys.FILL_OPACITY).append(':').append(opacity != null ? opacity.numberValue().toString() : "1").append(SEMI_COLON)
+          // fill-outline-color
+          .append(StyleKeys.COLOR).append(':').append(paintObject.getString("fill-outline-color",
+          paintObject.getString("fill-color", "#000000"))).append(SEMI_COLON);
+        // fill-pattern
+        // fill-sort-key
+        // fill-translate
+        // fill-translate-anchor
+        return sb.toString();
+    }
+
+    /**
+     * Converts this layer object to a mapcss entry string (to be parsed later)
+     * @return The mapcss entry (string form)
+     */
+    @Override
+    public String toString() {
+        if (this.filter.toString().isEmpty() && this.paint.isEmpty()) {
+            return EMPTY_STRING;
+        } else if (this.type == Type.BACKGROUND) {
+            // AFAIK, paint has no zoom levels, and doesn't accept a layer
+            return "canvas{" + this.paint + "}";
+        }
+
+        final String zoomSelector;
+        if (this.minZoom == this.maxZoom) {
+            zoomSelector = "|z" + this.minZoom;
+        } else if (this.minZoom > Integer.MIN_VALUE && this.maxZoom == Integer.MAX_VALUE) {
+            zoomSelector = "|z" + this.minZoom + "-";
+        } else if (this.minZoom == Integer.MIN_VALUE && this.maxZoom < Integer.MAX_VALUE) {
+            zoomSelector = "|z-" + this.maxZoom;
+        } else if (this.minZoom > Integer.MIN_VALUE) {
+            zoomSelector = MessageFormat.format("|z{0}-{1}", this.minZoom, this.maxZoom);
+        } else {
+            zoomSelector = EMPTY_STRING;
+        }
+        final String commonData = zoomSelector + this.filter.toString() + "::" + this.id + "{" + this.paint + "}";
+
+        if (this.type == Type.CIRCLE || this.type == Type.SYMBOL) {
+            return "node" + commonData;
+        } else if (this.type == Type.FILL) {
+            return "area" + commonData;
+        } else if (this.type == Type.LINE) {
+            return "way" + commonData;
+        }
+        return super.toString();
+    }
+
+    /**
+     * Get the source that this applies to
+     * @return The source name
+     */
+    public String getSource() {
+        return this.source;
+    }
+
+    /**
+     * Get the layer that this applies to
+     * @return The layer name
+     */
+    public String getSourceLayer() {
+        return this.sourceLayer;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other != null && this.getClass() == other.getClass()) {
+            Layers o = (Layers) other;
+            return this.type == o.type
+              && this.minZoom == o.minZoom
+              && this.maxZoom == o.maxZoom
+              && Objects.equals(this.id, o.id)
+              && Objects.equals(this.styleId, o.styleId)
+              && Objects.equals(this.sourceLayer, o.sourceLayer)
+              && Objects.equals(this.source, o.source)
+              && Objects.equals(this.filter, o.filter)
+              && Objects.equals(this.paint, o.paint);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.type, this.minZoom, this.maxZoom, this.id, this.styleId, this.sourceLayer, this.source,
+          this.filter, this.paint);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyle.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyle.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyle.java	(revision 17862)
@@ -0,0 +1,278 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Image;
+import java.awt.image.BufferedImage;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+import javax.imageio.ImageIO;
+import javax.json.Json;
+import javax.json.JsonArray;
+import javax.json.JsonObject;
+import javax.json.JsonReader;
+import javax.json.JsonStructure;
+import javax.json.JsonValue;
+
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.InvalidMapboxVectorTileException;
+import org.openstreetmap.josm.data.preferences.JosmBaseDirectories;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.mappaint.ElemStyles;
+import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource;
+import org.openstreetmap.josm.io.CachedFile;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * Create a mapping for a Mapbox Vector Style
+ *
+ * @author Taylor Smock
+ * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/">https://docs.mapbox.com/mapbox-gl-js/style-spec/</a>
+ * @since xxx
+ */
+public class MapboxVectorStyle {
+
+    private static final ConcurrentHashMap<String, MapboxVectorStyle> STYLE_MAPPING = new ConcurrentHashMap<>();
+
+    /**
+     * Get a MapboxVector style for a URL
+     * @param url The url to get
+     * @return The Mapbox Vector Style. May be {@code null} if there was an error.
+     */
+    public static MapboxVectorStyle getMapboxVectorStyle(String url) {
+        return STYLE_MAPPING.computeIfAbsent(url, key -> {
+            try (CachedFile style = new CachedFile(url);
+                    BufferedReader reader = style.getContentReader();
+                    JsonReader jsonReader = Json.createReader(reader)) {
+                JsonStructure structure = jsonReader.read();
+                return new MapboxVectorStyle(structure.asJsonObject());
+            } catch (IOException e) {
+                Logging.error(e);
+            }
+            // Documentation indicates that this will <i>not</i> be entered into the map, which means that this will be
+            // retried if something goes wrong.
+            return null;
+        });
+    }
+
+    /** The version for the style specification */
+    private final int version;
+    /** The optional name for the vector style */
+    private final String name;
+    /** The optional URL for sprites. This mush be absolute (so it must contain the scheme, authority, and path). */
+    private final String spriteUrl;
+    /** The optional URL for glyphs. This may have replaceable values in it. */
+    private final String glyphUrl;
+    /** The required collection of sources with a list of layers that are applicable for that source*/
+    private final Map<Source, ElemStyles> sources;
+
+    /**
+     * Create a new MapboxVector style. You should prefer {@link #getMapboxVectorStyle(String)}
+     * for deduplication purposes.
+     *
+     * @param jsonObject The object to create the style from
+     * @see #getMapboxVectorStyle(String)
+     */
+    public MapboxVectorStyle(JsonObject jsonObject) {
+        // There should be a version specifier. We currently only support version 8.
+        // This can throw an NPE when there is no version number.
+        this.version = jsonObject.getInt("version");
+        if (this.version == 8) {
+            this.name = jsonObject.getString("name", null);
+            String id = jsonObject.getString("id", this.name);
+            this.spriteUrl = jsonObject.getString("sprite", null);
+            this.glyphUrl = jsonObject.getString("glyphs", null);
+            final List<Source> sourceList;
+            if (jsonObject.containsKey("sources") && jsonObject.get("sources").getValueType() == JsonValue.ValueType.OBJECT) {
+                final JsonObject sourceObj = jsonObject.getJsonObject("sources");
+                sourceList = sourceObj.entrySet().stream().filter(entry -> entry.getValue().getValueType() == JsonValue.ValueType.OBJECT)
+                  .map(entry -> {
+                      try {
+                          return new Source(entry.getKey(), entry.getValue().asJsonObject());
+                      } catch (InvalidMapboxVectorTileException e) {
+                          Logging.error(e);
+                          // Reraise if not a known exception
+                          if (!"TileJson not yet supported".equals(e.getMessage())) {
+                              throw e;
+                          }
+                      }
+                      return null;
+                  }).filter(Objects::nonNull).collect(Collectors.toList());
+            } else {
+                sourceList = Collections.emptyList();
+            }
+            final List<Layers> layers;
+            if (jsonObject.containsKey("layers") && jsonObject.get("layers").getValueType() == JsonValue.ValueType.ARRAY) {
+                JsonArray lArray = jsonObject.getJsonArray("layers");
+                layers = lArray.stream().filter(JsonObject.class::isInstance).map(JsonObject.class::cast).map(obj -> new Layers(id, obj))
+                  .collect(Collectors.toList());
+            } else {
+                layers = Collections.emptyList();
+            }
+            final Map<Optional<Source>, List<Layers>> sourceLayer = layers.stream().collect(
+              Collectors.groupingBy(layer -> sourceList.stream().filter(source -> source.getName().equals(layer.getSource()))
+                .findFirst(), LinkedHashMap::new, Collectors.toList()));
+            // Abuse HashMap null (null == default)
+            this.sources = new LinkedHashMap<>();
+            for (Map.Entry<Optional<Source>, List<Layers>> entry : sourceLayer.entrySet()) {
+                final Source source = entry.getKey().orElse(null);
+                final String data = entry.getValue().stream().map(Layers::toString).collect(Collectors.joining());
+                final String metaData = "meta{title:" + (source == null ? "Generated Style" :
+                  source.getName()) + ";version:\"autogenerated\";description:\"auto generated style\";}";
+
+                // This is the default canvas
+                final String canvas = "canvas{default-points:false;default-lines:false;}";
+                final MapCSSStyleSource style = new MapCSSStyleSource(metaData + canvas + data);
+                // Save to directory
+                MainApplication.worker.execute(() -> this.save((source == null ? data.hashCode() : source.getName()) + ".mapcss", style));
+                this.sources.put(source, new ElemStyles(Collections.singleton(style)));
+            }
+            if (this.spriteUrl != null && !this.spriteUrl.trim().isEmpty()) {
+                MainApplication.worker.execute(this::fetchSprites);
+            }
+        } else {
+            throw new IllegalArgumentException(tr("Vector Tile Style Version not understood: version {0} (json: {1})",
+              this.version, jsonObject));
+        }
+    }
+
+    /**
+     * Fetch sprites. Please note that this is (literally) only png. Unfortunately.
+     * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/sprite/">https://docs.mapbox.com/mapbox-gl-js/style-spec/sprite/</a>
+     */
+    private void fetchSprites() {
+        // HiDPI images first -- if this succeeds, don't bother with the lower resolution (JOSM has no method to switch)
+        try (CachedFile spriteJson = new CachedFile(this.spriteUrl + "@2x.json");
+          CachedFile spritePng = new CachedFile(this.spriteUrl + "@2x.png")) {
+            if (parseSprites(spriteJson, spritePng)) {
+                return;
+            }
+        }
+        try (CachedFile spriteJson = new CachedFile(this.spriteUrl + ".json");
+        CachedFile spritePng = new CachedFile(this.spriteUrl + ".png")) {
+            parseSprites(spriteJson, spritePng);
+        }
+    }
+
+    private boolean parseSprites(CachedFile spriteJson, CachedFile spritePng) {
+        /* JSON looks like this:
+         * { "image-name": {"width": width, "height": height, "x": x, "y": y, "pixelRatio": 1 }}
+         * width/height are the dimensions of the image
+         * x -- distance right from top left
+         * y -- distance down from top left
+         * pixelRatio -- this <i>appears</i> to be from the "@2x" (default 1)
+         * content -- [left, top corner, right, bottom corner]
+         * stretchX -- [[from, to], [from, to], ...]
+         * stretchY -- [[from, to], [from, to], ...]
+         */
+        final JsonObject spriteObject;
+        final BufferedImage spritePngImage;
+        try (BufferedReader spriteJsonBufferedReader = spriteJson.getContentReader();
+          JsonReader spriteJsonReader = Json.createReader(spriteJsonBufferedReader);
+          InputStream spritePngBufferedReader = spritePng.getInputStream()
+        ) {
+            spriteObject = spriteJsonReader.read().asJsonObject();
+            spritePngImage = ImageIO.read(spritePngBufferedReader);
+        } catch (IOException e) {
+            Logging.error(e);
+            return false;
+        }
+        for (Map.Entry<String, JsonValue> entry : spriteObject.entrySet()) {
+            final JsonObject info = entry.getValue().asJsonObject();
+            int width = info.getInt("width");
+            int height = info.getInt("height");
+            int x = info.getInt("x");
+            int y = info.getInt("y");
+            save(entry.getKey() + ".png", spritePngImage.getSubimage(x, y, width, height));
+        }
+        return true;
+    }
+
+    private void save(String name, Object object) {
+        final File cache;
+        if (object instanceof Image) {
+            // Images have a specific location where they are looked for
+            cache = new File(Config.getDirs().getUserDataDirectory(true), "images");
+        } else {
+            cache = JosmBaseDirectories.getInstance().getCacheDirectory(true);
+        }
+        final File location = new File(cache, this.name != null ? this.name : Integer.toString(this.hashCode()));
+        if ((!location.exists() && !location.mkdirs()) || (location.exists() && !location.isDirectory())) {
+            // Don't try to save if the file exists and is not a directory or we couldn't create it
+            return;
+        }
+        final File toSave = new File(location, name);
+        try (OutputStream fileOutputStream = Files.newOutputStream(toSave.toPath())) {
+            if (object instanceof String) {
+                fileOutputStream.write(((String) object).getBytes(StandardCharsets.UTF_8));
+            } else if (object instanceof MapCSSStyleSource) {
+                MapCSSStyleSource source = (MapCSSStyleSource) object;
+                try (InputStream inputStream = source.getSourceInputStream()) {
+                    int byteVal = inputStream.read();
+                    do {
+                        fileOutputStream.write(byteVal);
+                        byteVal = inputStream.read();
+                    } while (byteVal > -1);
+                    source.url = "file:/" + toSave.getAbsolutePath().replace('\\', '/');
+                    if (source.isLoaded()) {
+                        source.loadStyleSource();
+                    }
+                }
+            } else if (object instanceof BufferedImage) {
+                // This directory is checked first when getting images
+                ImageIO.write((BufferedImage) object, "png", toSave);
+            }
+        } catch (IOException e) {
+            Logging.info(e);
+        }
+    }
+
+    /**
+     * Get the generated layer->style mapping
+     * @return The mapping (use to enable/disable a paint style)
+     */
+    public Map<Source, ElemStyles> getSources() {
+        return this.sources;
+    }
+
+    /**
+     * Get the sprite url for the style
+     * @return The base sprite url
+     */
+    public String getSpriteUrl() {
+        return this.spriteUrl;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other != null && other.getClass() == this.getClass()) {
+            MapboxVectorStyle o = (MapboxVectorStyle) other;
+            return this.version == o.version
+              && Objects.equals(this.name, o.name)
+              && Objects.equals(this.glyphUrl, o.glyphUrl)
+              && Objects.equals(this.spriteUrl, o.spriteUrl)
+              && Objects.equals(this.sources, o.sources);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.name, this.version, this.glyphUrl, this.spriteUrl, this.sources);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Scheme.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Scheme.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Scheme.java	(revision 17862)
@@ -0,0 +1,12 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+/**
+ * The scheme used for tiles
+ */
+public enum Scheme {
+    /** Standard slippy map scheme */
+    XYZ,
+    /** OSGeo specification scheme */
+    TMS
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java	(revision 17862)
@@ -0,0 +1,255 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.function.IntFunction;
+
+import javax.json.JsonArray;
+import javax.json.JsonObject;
+import javax.json.JsonString;
+import javax.json.JsonValue;
+
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.InvalidMapboxVectorTileException;
+
+/**
+ * A source from a Mapbox Vector Style
+ *
+ * @author Taylor Smock
+ * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/">https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/</a>
+ * @since xxx
+ */
+public class Source {
+    /**
+     * A common function for zoom constraints
+     */
+    private static class ZoomBoundFunction implements IntFunction<Integer> {
+        private final int min;
+        private final int max;
+        /**
+         * Create a new bound for zooms
+         * @param min The min zoom
+         * @param max The max zoom
+         */
+        ZoomBoundFunction(int min, int max) {
+            this.min = min;
+            this.max = max;
+        }
+
+        @Override public Integer apply(int value) {
+            return Math.max(min, Math.min(value, max));
+        }
+    }
+
+    /**
+     * WMS servers should contain a "{bbox-epsg-3857}" parameter for the bbox
+     */
+    private static final String WMS_BBOX = "bbox-epsg-3857";
+
+    private static final String[] NO_URLS = new String[0];
+
+    /**
+     * Constrain the min/max zooms to be between 0 and 30, as per tilejson spec
+     */
+    private static final IntFunction<Integer> ZOOM_BOUND_FUNCTION = new ZoomBoundFunction(0, 30);
+
+    /* Common items */
+    /**
+     * The name of the source
+     */
+    private final String name;
+    /**
+     * The type of the source
+     */
+    private final SourceType sourceType;
+
+    /* Common tiled data */
+    /**
+     * The minimum zoom supported
+     */
+    private final int minZoom;
+    /**
+     * The maximum zoom supported
+     */
+    private final int maxZoom;
+    /**
+     * The tile urls. These usually have replaceable fields.
+     */
+    private final String[] tileUrls;
+
+    /* Vector and raster data */
+    /**
+     * The attribution to display for the user
+     */
+    private final String attribution;
+    /**
+     * The bounds of the data. We should not request data outside of the bounds
+     */
+    private final Bounds bounds;
+    /**
+     * The property to use as a feature id. Can be parameterized
+     */
+    private final String promoteId;
+    /**
+     * The tile scheme
+     */
+    private final Scheme scheme;
+    /**
+     * {@code true} if the tiles should not be cached
+     */
+    private final boolean volatileCache;
+
+    /* Raster data */
+    /**
+     * The tile size
+     */
+    private final int tileSize;
+
+    /**
+     * Create a new Source object
+     *
+     * @param name The name of the source object
+     * @param data The data to set the source information with
+     */
+    public Source(final String name, final JsonObject data) {
+        Objects.requireNonNull(name, "Name cannot be null");
+        Objects.requireNonNull(data, "Data cannot be null");
+        this.name = name;
+        // "type" is required (so throw an NPE if it doesn't exist)
+        final String type = data.getString("type");
+        this.sourceType = SourceType.valueOf(type.replace("-", "_").toUpperCase(Locale.ROOT));
+        // This can also contain SourceType.RASTER_DEM (only needs encoding)
+        if (SourceType.VECTOR == this.sourceType || SourceType.RASTER == this.sourceType) {
+            if (data.containsKey("url")) {
+                // TODO implement https://github.com/mapbox/tilejson-spec
+                throw new InvalidMapboxVectorTileException("TileJson not yet supported");
+            } else {
+                this.minZoom = ZOOM_BOUND_FUNCTION.apply(data.getInt("minzoom", 0));
+                this.maxZoom = ZOOM_BOUND_FUNCTION.apply(data.getInt("maxzoom", 22));
+                this.attribution = data.getString("attribution", null);
+                if (data.containsKey("bounds") && data.get("bounds").getValueType() == JsonValue.ValueType.ARRAY) {
+                    final JsonArray bJsonArray = data.getJsonArray("bounds");
+                    if (bJsonArray.size() != 4) {
+                        throw new IllegalArgumentException(MessageFormat.format("bounds must have four values, but has {0}", bJsonArray.size()));
+                    }
+                    final double[] bArray = new double[bJsonArray.size()];
+                    for (int i = 0; i < bJsonArray.size(); i++) {
+                        bArray[i] = bJsonArray.getJsonNumber(i).doubleValue();
+                    }
+                    // The order in the response is
+                    // [south-west longitude, south-west latitude, north-east longitude, north-east latitude]
+                    this.bounds = new Bounds(bArray[1], bArray[0], bArray[3], bArray[2]);
+                } else {
+                    // Don't use a static instance for bounds, as it is not a immutable class
+                    this.bounds = new Bounds(-85.051129, -180, 85.051129, 180);
+                }
+                this.promoteId = data.getString("promoteId", null);
+                this.scheme = Scheme.valueOf(data.getString("scheme", "xyz").toUpperCase(Locale.ROOT));
+                if (data.containsKey("tiles") && data.get("tiles").getValueType() == JsonValue.ValueType.ARRAY) {
+                    this.tileUrls = data.getJsonArray("tiles").stream().filter(JsonString.class::isInstance)
+                      .map(JsonString.class::cast).map(JsonString::getString)
+                      // Replace bbox-epsg-3857 with bbox (already encased with {})
+                      .map(url -> url.replace(WMS_BBOX, "bbox")).toArray(String[]::new);
+                } else {
+                    this.tileUrls = NO_URLS;
+                }
+                this.volatileCache = data.getBoolean("volatile", false);
+                this.tileSize = data.getInt("tileSize", 512);
+            }
+        } else {
+            throw new UnsupportedOperationException();
+        }
+    }
+
+    /**
+     * Get the bounds for this source
+     * @return The bounds where this source can be used
+     */
+    public Bounds getBounds() {
+        return this.bounds;
+    }
+
+    /**
+     * Get the source name
+     * @return the name
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Get the URLs that can be used to get vector data
+     *
+     * @return The urls
+     */
+    public List<String> getUrls() {
+        return Collections.unmodifiableList(Arrays.asList(this.tileUrls));
+    }
+
+    /**
+     * Get the minimum zoom
+     *
+     * @return The min zoom (default {@code 0})
+     */
+    public int getMinZoom() {
+        return this.minZoom;
+    }
+
+    /**
+     * Get the max zoom
+     *
+     * @return The max zoom (default {@code 22})
+     */
+    public int getMaxZoom() {
+        return this.maxZoom;
+    }
+
+    /**
+     * Get the attribution for this source
+     *
+     * @return The attribution text. May be {@code null}.
+     */
+    public String getAttributionText() {
+        return this.attribution;
+    }
+
+    @Override
+    public String toString() {
+        Collection<String> parts = new ArrayList<>(1 + this.getUrls().size());
+        parts.add(this.getName());
+        parts.addAll(this.getUrls());
+        return String.join(" ", parts);
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other != null && this.getClass() == other.getClass()) {
+            Source o = (Source) other;
+            return Objects.equals(this.name, o.name)
+              && this.sourceType == o.sourceType
+              && this.minZoom == o.minZoom
+              && this.maxZoom == o.maxZoom
+              && Objects.equals(this.attribution, o.attribution)
+              && Objects.equals(this.promoteId, o.promoteId)
+              && this.scheme == o.scheme
+              && this.volatileCache == o.volatileCache
+              && this.tileSize == o.tileSize
+              && Objects.equals(this.bounds, o.bounds)
+              && Objects.deepEquals(this.tileUrls, o.tileUrls);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.name, this.sourceType, this.minZoom, this.maxZoom, this.attribution, this.promoteId,
+          this.scheme, this.volatileCache, this.tileSize, this.bounds, Arrays.hashCode(this.tileUrls));
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceType.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceType.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceType.java	(revision 17862)
@@ -0,0 +1,17 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+/**
+ * The "source type" for the data (Mapbox Vector Style specification)
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public enum SourceType {
+    VECTOR,
+    RASTER,
+    RASTER_DEM,
+    GEOJSON,
+    IMAGE,
+    VIDEO
+}
Index: trunk/src/org/openstreetmap/josm/data/osm/AbstractPrimitive.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/AbstractPrimitive.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/osm/AbstractPrimitive.java	(revision 17862)
@@ -32,5 +32,5 @@
  * @since 4099
  */
-public abstract class AbstractPrimitive implements IPrimitive {
+public abstract class AbstractPrimitive implements IPrimitive, IFilterablePrimitive {
 
     /**
@@ -353,4 +353,16 @@
     }
 
+    /**
+     * Update flags
+     * @param flag The flag to update
+     * @param value The value to set
+     * @return {@code true} if the flags have changed
+     */
+    protected boolean updateFlagsChanged(short flag, boolean value) {
+        int oldFlags = flags;
+        updateFlags(flag, value);
+        return oldFlags != flags;
+    }
+
     @Override
     public void setModified(boolean modified) {
@@ -408,4 +420,40 @@
     public boolean isIncomplete() {
         return (flags & FLAG_INCOMPLETE) != 0;
+    }
+
+    @Override
+    public boolean getHiddenType() {
+        return (flags & FLAG_HIDDEN_TYPE) != 0;
+    }
+
+    @Override
+    public boolean getDisabledType() {
+        return (flags & FLAG_DISABLED_TYPE) != 0;
+    }
+
+    @Override
+    public boolean setDisabledState(boolean hidden) {
+        // Store as variables to avoid short circuit boolean return
+        final boolean flagDisabled = updateFlagsChanged(FLAG_DISABLED, true);
+        final boolean flagHideIfDisabled = updateFlagsChanged(FLAG_HIDE_IF_DISABLED, hidden);
+        return flagDisabled || flagHideIfDisabled;
+    }
+
+    @Override
+    public boolean unsetDisabledState() {
+        // Store as variables to avoid short circuit boolean return
+        final boolean flagDisabled = updateFlagsChanged(FLAG_DISABLED, false);
+        final boolean flagHideIfDisabled = updateFlagsChanged(FLAG_HIDE_IF_DISABLED, false);
+        return flagDisabled || flagHideIfDisabled;
+    }
+
+    @Override
+    public void setDisabledType(boolean isExplicit) {
+        updateFlags(FLAG_DISABLED_TYPE, isExplicit);
+    }
+
+    @Override
+    public void setHiddenType(boolean isExplicit) {
+        updateFlags(FLAG_HIDDEN_TYPE, isExplicit);
     }
 
Index: trunk/src/org/openstreetmap/josm/data/osm/BBox.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/BBox.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/osm/BBox.java	(revision 17862)
@@ -175,4 +175,14 @@
      */
     public void addPrimitive(OsmPrimitive primitive, double extraSpace) {
+        this.addPrimitive((IPrimitive) primitive, extraSpace);
+    }
+
+    /**
+     * Extends this bbox to include the bbox of the primitive extended by extraSpace.
+     * @param primitive an primitive
+     * @param extraSpace the value to extend the primitives bbox. Unit is in LatLon degrees.
+     * @since xxx
+     */
+    public void addPrimitive(IPrimitive primitive, double extraSpace) {
         IBounds primBbox = primitive.getBBox();
         add(primBbox.getMinLon() - extraSpace, primBbox.getMinLat() - extraSpace);
@@ -456,6 +466,7 @@
      * Returns an immutable version of this bbox, i.e., modifying calls throw an {@link UnsupportedOperationException}.
      * @return an immutable version of this bbox
-     */
-    BBox toImmutable() {
+     * @since xxx (interface)
+     */
+    public BBox toImmutable() {
         return new Immutable(this);
     }
@@ -473,5 +484,5 @@
 
         @Override
-        BBox toImmutable() {
+        public BBox toImmutable() {
             return this;
         }
Index: trunk/src/org/openstreetmap/josm/data/osm/FilterMatcher.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/FilterMatcher.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/osm/FilterMatcher.java	(revision 17862)
@@ -162,5 +162,5 @@
      * when hidden is false, returns whether the primitive is disabled or hidden
      */
-    private static boolean isFiltered(OsmPrimitive primitive, boolean hidden) {
+    private static boolean isFiltered(IPrimitive primitive, boolean hidden) {
         return hidden ? primitive.isDisabledAndHidden() : primitive.isDisabled();
     }
@@ -169,9 +169,10 @@
      * Check if primitive is hidden explicitly.
      * Only used for ways and relations.
+     * @param <T> The primitive type
      * @param primitive the primitive to check
      * @param hidden the level where the check is performed
      * @return true, if at least one non-inverted filter applies to the primitive
      */
-    private static boolean isFilterExplicit(OsmPrimitive primitive, boolean hidden) {
+    private static <T extends IFilterablePrimitive> boolean isFilterExplicit(T primitive, boolean hidden) {
         return hidden ? primitive.getHiddenType() : primitive.getDisabledType();
     }
@@ -179,4 +180,5 @@
     /**
      * Check if all parent ways are filtered.
+     * @param <T> The primitive type
      * @param primitive the primitive to check
      * @param hidden parameter that indicates the minimum level of filtering:
@@ -188,12 +190,12 @@
      * (c) at least one of the parent ways is explicitly filtered
      */
-    private static boolean allParentWaysFiltered(OsmPrimitive primitive, boolean hidden) {
-        List<OsmPrimitive> refs = primitive.getReferrers();
+    private static <T extends IPrimitive & IFilterablePrimitive> boolean allParentWaysFiltered(T primitive, boolean hidden) {
+        List<? extends IPrimitive> refs = primitive.getReferrers();
         boolean isExplicit = false;
-        for (OsmPrimitive p: refs) {
-            if (p instanceof Way) {
+        for (IPrimitive p: refs) {
+            if (p instanceof IWay && p instanceof IFilterablePrimitive) {
                 if (!isFiltered(p, hidden))
                     return false;
-                isExplicit |= isFilterExplicit(p, hidden);
+                isExplicit |= isFilterExplicit((IFilterablePrimitive) p, hidden);
             }
         }
@@ -201,13 +203,13 @@
     }
 
-    private static boolean oneParentWayNotFiltered(OsmPrimitive primitive, boolean hidden) {
-        return primitive.referrers(Way.class)
+    private static boolean oneParentWayNotFiltered(IPrimitive primitive, boolean hidden) {
+        return primitive.getReferrers().stream().filter(IWay.class::isInstance).map(IWay.class::cast)
                 .anyMatch(p -> !isFiltered(p, hidden));
     }
 
-    private static boolean allParentMultipolygonsFiltered(OsmPrimitive primitive, boolean hidden) {
+    private static boolean allParentMultipolygonsFiltered(IPrimitive primitive, boolean hidden) {
         boolean isExplicit = false;
-        for (Relation r : new SubclassFilteredCollection<OsmPrimitive, Relation>(
-                primitive.getReferrers(), OsmPrimitive::isMultipolygon)) {
+        for (Relation r : new SubclassFilteredCollection<IPrimitive, Relation>(
+                primitive.getReferrers(), IPrimitive::isMultipolygon)) {
             if (!isFiltered(r, hidden))
                 return false;
@@ -217,10 +219,10 @@
     }
 
-    private static boolean oneParentMultipolygonNotFiltered(OsmPrimitive primitive, boolean hidden) {
-        return new SubclassFilteredCollection<OsmPrimitive, Relation>(primitive.getReferrers(), OsmPrimitive::isMultipolygon).stream()
+    private static boolean oneParentMultipolygonNotFiltered(IPrimitive primitive, boolean hidden) {
+        return new SubclassFilteredCollection<IPrimitive, IRelation>(primitive.getReferrers(), IPrimitive::isMultipolygon).stream()
                 .anyMatch(r -> !isFiltered(r, hidden));
     }
 
-    private static FilterType test(List<FilterInfo> filters, OsmPrimitive primitive, boolean hidden) {
+    private static <T extends IPrimitive & IFilterablePrimitive> FilterType test(List<FilterInfo> filters, T primitive, boolean hidden) {
         if (primitive.isIncomplete() || primitive.isPreserved())
             return FilterType.NOT_FILTERED;
@@ -246,5 +248,5 @@
         }
 
-        if (primitive instanceof Node) {
+        if (primitive instanceof INode) {
             if (filtered) {
                 // If there is a parent way, that is not hidden, we  show the
@@ -267,5 +269,5 @@
                     return FilterType.NOT_FILTERED;
             }
-        } else if (primitive instanceof Way) {
+        } else if (primitive instanceof IWay) {
             if (filtered) {
                 if (explicitlyFiltered)
@@ -296,4 +298,5 @@
      * The filter flags for all parent objects must be set correctly, when
      * calling this method.
+     * @param <T> The primitive type
      * @param primitive the primitive
      * @return FilterType.NOT_FILTERED when primitive is not hidden;
@@ -303,5 +306,5 @@
      * are inverted
      */
-    public FilterType isHidden(OsmPrimitive primitive) {
+    public <T extends IPrimitive & IFilterablePrimitive> FilterType isHidden(T primitive) {
         return test(hiddenFilters, primitive, true);
     }
@@ -311,4 +314,5 @@
      * The filter flags for all parent objects must be set correctly, when
      * calling this method.
+     * @param <T> The primitive type
      * @param primitive the primitive
      * @return FilterType.NOT_FILTERED when primitive is not disabled;
@@ -318,5 +322,5 @@
      * are inverted
      */
-    public FilterType isDisabled(OsmPrimitive primitive) {
+    public <T extends IPrimitive & IFilterablePrimitive> FilterType isDisabled(T primitive) {
         return test(disabledFilters, primitive, false);
     }
Index: trunk/src/org/openstreetmap/josm/data/osm/FilterWorker.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/FilterWorker.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/osm/FilterWorker.java	(revision 17862)
@@ -10,5 +10,5 @@
 
 /**
- * Class for applying {@link Filter}s to {@link OsmPrimitive}s.
+ * Class for applying {@link Filter}s to {@link IPrimitive}s.
  *
  * Provides a bridge between Filter GUI and the data.
@@ -25,11 +25,13 @@
      * Apply the filters to the primitives of the data set.
      *
+     * @param <T> The primitive type
      * @param all the collection of primitives for that the filter state should be updated
      * @param filters the filters
      * @return true, if the filter state (normal / disabled / hidden) of any primitive has changed in the process
      * @throws SearchParseError if the search expression in a filter cannot be parsed
-     * @since 12383
+     * @since 12383, xxx (generics)
      */
-    public static boolean executeFilters(Collection<OsmPrimitive> all, Filter... filters) throws SearchParseError {
+    public static <T extends IPrimitive & IFilterablePrimitive> boolean executeFilters(Collection<T> all, Filter... filters)
+            throws SearchParseError {
         return executeFilters(all, FilterMatcher.of(filters));
     }
@@ -38,22 +40,24 @@
      * Apply the filters to the primitives of the data set.
      *
+     * @param <T> The primitive type
      * @param all the collection of primitives for that the filter state should be updated
      * @param filterMatcher the FilterMatcher
      * @return true, if the filter state (normal / disabled / hidden) of any primitive has changed in the process
+     * @since xxx (generics)
      */
-    public static boolean executeFilters(Collection<OsmPrimitive> all, FilterMatcher filterMatcher) {
+    public static <T extends IPrimitive & IFilterablePrimitive> boolean executeFilters(Collection<T> all, FilterMatcher filterMatcher) {
         boolean changed;
         // first relations, then ways and nodes last; this is required to resolve dependencies
-        changed = doExecuteFilters(SubclassFilteredCollection.filter(all, Relation.class::isInstance), filterMatcher);
-        changed |= doExecuteFilters(SubclassFilteredCollection.filter(all, Way.class::isInstance), filterMatcher);
-        changed |= doExecuteFilters(SubclassFilteredCollection.filter(all, Node.class::isInstance), filterMatcher);
+        changed = doExecuteFilters(SubclassFilteredCollection.filter(all, IRelation.class::isInstance), filterMatcher);
+        changed |= doExecuteFilters(SubclassFilteredCollection.filter(all, IWay.class::isInstance), filterMatcher);
+        changed |= doExecuteFilters(SubclassFilteredCollection.filter(all, INode.class::isInstance), filterMatcher);
         return changed;
     }
 
-    private static boolean doExecuteFilters(Collection<OsmPrimitive> all, FilterMatcher filterMatcher) {
+    private static <T extends IPrimitive & IFilterablePrimitive> boolean doExecuteFilters(Collection<T> all, FilterMatcher filterMatcher) {
 
         boolean changed = false;
 
-        for (OsmPrimitive primitive: all) {
+        for (T primitive : all) {
             FilterType hiddenType = filterMatcher.isHidden(primitive);
             if (hiddenType != FilterType.NOT_FILTERED) {
@@ -76,10 +80,12 @@
      * Apply the filters to a single primitive.
      *
+     * @param <T> the primitive type
      * @param primitive the primitive
      * @param filterMatcher the FilterMatcher
      * @return true, if the filter state (normal / disabled / hidden)
      * of the primitive has changed in the process
+     * @since xxx (generics)
      */
-    public static boolean executeFilters(OsmPrimitive primitive, FilterMatcher filterMatcher) {
+    public static <T extends IPrimitive & IFilterablePrimitive> boolean executeFilters(T primitive, FilterMatcher filterMatcher) {
         return doExecuteFilters(Collections.singleton(primitive), filterMatcher);
     }
@@ -87,11 +93,12 @@
     /**
      * Clear all filter flags, i.e.&nbsp;turn off filters.
+     * @param <T> the primitive type
      * @param prims the primitives
      * @return true, if the filter state (normal / disabled / hidden) of any primitive has changed in the process
      * @since 12388 (signature)
      */
-    public static boolean clearFilterFlags(Collection<OsmPrimitive> prims) {
+    public static <T extends IPrimitive & IFilterablePrimitive> boolean clearFilterFlags(Collection<T> prims) {
         boolean changed = false;
-        for (OsmPrimitive osm : prims) {
+        for (T osm : prims) {
             changed |= osm.unsetDisabledState();
         }
Index: trunk/src/org/openstreetmap/josm/data/osm/IFilterablePrimitive.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/IFilterablePrimitive.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/osm/IFilterablePrimitive.java	(revision 17862)
@@ -0,0 +1,51 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm;
+
+/**
+ * An interface used to indicate that a primitive is filterable
+ * @author Taylor Smock
+ * @since xxx
+ */
+public interface IFilterablePrimitive {
+    /**
+     * Get binary property used internally by the filter mechanism.
+     * @return {@code true} if this object has the "hidden type" flag enabled
+     */
+    boolean getHiddenType();
+
+    /**
+     * Get binary property used internally by the filter mechanism.
+     * @return {@code true} if this object has the "disabled type" flag enabled
+     */
+    boolean getDisabledType();
+
+    /**
+     * Make the primitive disabled (e.g.&nbsp;if a filter applies).
+     *
+     * To enable the primitive again, use unsetDisabledState.
+     * @param hidden if the primitive should be completely hidden from view or
+     *             just shown in gray color.
+     * @return true, any flag has changed; false if you try to set the disabled
+     * state to the value that is already preset
+     */
+    boolean setDisabledState(boolean hidden);
+
+    /**
+     * Remove the disabled flag from the primitive.
+     * Afterwards, the primitive is displayed normally and can be selected again.
+     * @return {@code true} if a change occurred
+     */
+    boolean unsetDisabledState();
+
+    /**
+     * Set binary property used internally by the filter mechanism.
+     * @param isExplicit new "disabled type" flag value
+     */
+    void setDisabledType(boolean isExplicit);
+
+    /**
+     * Set binary property used internally by the filter mechanism.
+     * @param isExplicit new "hidden type" flag value
+     */
+    void setHiddenType(boolean isExplicit);
+}
Index: trunk/src/org/openstreetmap/josm/data/osm/IPrimitive.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/IPrimitive.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/osm/IPrimitive.java	(revision 17862)
@@ -393,4 +393,13 @@
 
     /**
+     * Get an object to synchronize the style cache on. This <i>should</i> be a field that does not change during paint.
+     * By default, it returns the current object, but should be overriden to avoid some performance issues.
+     * @return A non-{@code null} object to synchronize on when painting
+     */
+    default Object getStyleCacheSyncObject() {
+        return this;
+    }
+
+    /**
      * Replies the display name of a primitive formatted by <code>formatter</code>
      * @param formatter formatter to use
Index: trunk/src/org/openstreetmap/josm/data/osm/IRelationMember.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/IRelationMember.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/osm/IRelationMember.java	(revision 17862)
@@ -67,3 +67,12 @@
      */
     P getMember();
+
+    /**
+     * Returns the relation member as a way.
+     * @return Member as a way
+     * @since xxx
+     */
+    default IWay<?> getWay() {
+        return (IWay<?>) getMember();
+    }
 }
Index: trunk/src/org/openstreetmap/josm/data/osm/IWaySegment.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/IWaySegment.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/osm/IWaySegment.java	(revision 17862)
@@ -0,0 +1,177 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm;
+
+import java.awt.geom.Line2D;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A segment consisting of 2 consecutive nodes out of a way.
+ * @author Taylor Smock
+ * @param <N> The node type
+ * @param <W> The way type
+ * @since xxx
+ */
+public class IWaySegment<N extends INode, W extends IWay<N>> implements Comparable<IWaySegment<N, W>> {
+
+    /**
+     * The way.
+     */
+    public final W way;
+
+    /**
+     * The index of one of the 2 nodes in the way.  The other node has the
+     * index <code>lowerIndex + 1</code>.
+     */
+    public final int lowerIndex;
+
+    /**
+     * Constructs a new {@code IWaySegment}.
+     * @param w The way
+     * @param i The node lower index
+     * @throws IllegalArgumentException in case of invalid index
+     */
+    public IWaySegment(W w, int i) {
+        way = w;
+        lowerIndex = i;
+        if (i < 0 || i >= w.getNodesCount() - 1) {
+            throw new IllegalArgumentException(toString());
+        }
+    }
+
+    /**
+     * Returns the first node of the way segment.
+     * @return the first node
+     */
+    public N getFirstNode() {
+        return way.getNode(lowerIndex);
+    }
+
+    /**
+     * Returns the second (last) node of the way segment.
+     * @return the second node
+     */
+    public N getSecondNode() {
+        return way.getNode(lowerIndex + 1);
+    }
+
+    /**
+     * Determines and returns the way segment for the given way and node pair.
+     * @param way way
+     * @param first first node
+     * @param second second node
+     * @return way segment
+     * @throws IllegalArgumentException if the node pair is not part of way
+     */
+    public static <N extends INode, W extends IWay<N>> IWaySegment<N, W> forNodePair(W way, N first, N second) {
+        int endIndex = way.getNodesCount() - 1;
+        while (endIndex > 0) {
+            final int indexOfFirst = way.getNodes().subList(0, endIndex).lastIndexOf(first);
+            if (second.equals(way.getNode(indexOfFirst + 1))) {
+                return new IWaySegment<>(way, indexOfFirst);
+            }
+            endIndex--;
+        }
+        throw new IllegalArgumentException("Node pair is not part of way!");
+    }
+
+    /**
+     * Returns this way segment as complete way.
+     * @return the way segment as {@code Way}
+     * @throws IllegalAccessException See {@link Constructor#newInstance}
+     * @throws IllegalArgumentException See {@link Constructor#newInstance}
+     * @throws InstantiationException See {@link Constructor#newInstance}
+     * @throws InvocationTargetException See {@link Constructor#newInstance}
+     * @throws NoSuchMethodException See {@link Class#getConstructor}
+     */
+    public W toWay()
+      throws IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
+        // If the number of nodes is 2, then don't bother creating a new way
+        if (this.way.getNodes().size() == 2) {
+            return this.way;
+        }
+        // Since the way determines the generic class, this.way.getClass() is always Class<W>, assuming
+        // that way remains the defining element for the type, and remains final.
+        @SuppressWarnings("unchecked")
+        Class<W> clazz = (Class<W>) this.way.getClass();
+        Constructor<W> constructor;
+        W w;
+        try {
+            // Check for clone constructor
+            constructor = clazz.getConstructor(clazz);
+            w = constructor.newInstance(this.way);
+        } catch (NoSuchMethodException e) {
+            Logging.trace(e);
+            constructor = clazz.getConstructor();
+            w = constructor.newInstance();
+        }
+
+        w.setNodes(Arrays.asList(getFirstNode(), getSecondNode()));
+        return w;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        IWaySegment<?, ?> that = (IWaySegment<?, ?>) o;
+        return lowerIndex == that.lowerIndex &&
+          Objects.equals(way, that.way);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(way, lowerIndex);
+    }
+
+    @Override
+    public int compareTo(IWaySegment o) {
+        final W thisWay;
+        final IWay<?> otherWay;
+        try {
+            thisWay = toWay();
+            otherWay = o == null ? null : o.toWay();
+        } catch (ReflectiveOperationException e) {
+            Logging.error(e);
+            return -1;
+        }
+        return o == null ? -1 : (equals(o) ? 0 : thisWay.compareTo(otherWay));
+    }
+
+    /**
+     * Checks whether this segment crosses other segment
+     *
+     * @param s2 The other segment
+     * @return true if both segments crosses
+     */
+    public boolean intersects(IWaySegment<?, ?> s2) {
+        if (getFirstNode().equals(s2.getFirstNode()) || getSecondNode().equals(s2.getSecondNode()) ||
+          getFirstNode().equals(s2.getSecondNode()) || getSecondNode().equals(s2.getFirstNode()))
+            return false;
+
+        return Line2D.linesIntersect(
+          getFirstNode().getEastNorth().east(), getFirstNode().getEastNorth().north(),
+          getSecondNode().getEastNorth().east(), getSecondNode().getEastNorth().north(),
+          s2.getFirstNode().getEastNorth().east(), s2.getFirstNode().getEastNorth().north(),
+          s2.getSecondNode().getEastNorth().east(), s2.getSecondNode().getEastNorth().north());
+    }
+
+    /**
+     * Checks whether this segment and another way segment share the same points
+     * @param s2 The other segment
+     * @return true if other way segment is the same or reverse
+     */
+    public boolean isSimilar(IWaySegment<?, ?> s2) {
+        return (getFirstNode().equals(s2.getFirstNode()) && getSecondNode().equals(s2.getSecondNode()))
+          || (getFirstNode().equals(s2.getSecondNode()) && getSecondNode().equals(s2.getFirstNode()));
+    }
+
+    @Override
+    public String toString() {
+        return "IWaySegment [way=" + way.getUniqueId() + ", lowerIndex=" + lowerIndex + ']';
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/osm/OsmData.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/OsmData.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/osm/OsmData.java	(revision 17862)
@@ -351,5 +351,5 @@
      */
     default Collection<N> getSelectedNodes() {
-        return new SubclassFilteredCollection<>(getSelected(), Node.class::isInstance);
+        return new SubclassFilteredCollection<>(getSelected(), INode.class::isInstance);
     }
 
@@ -359,5 +359,5 @@
      */
     default Collection<W> getSelectedWays() {
-        return new SubclassFilteredCollection<>(getSelected(), Way.class::isInstance);
+        return new SubclassFilteredCollection<>(getSelected(), IWay.class::isInstance);
     }
 
@@ -367,5 +367,5 @@
      */
     default Collection<R> getSelectedRelations() {
-        return new SubclassFilteredCollection<>(getSelected(), Relation.class::isInstance);
+        return new SubclassFilteredCollection<>(getSelected(), IRelation.class::isInstance);
     }
 
Index: trunk/src/org/openstreetmap/josm/data/osm/OsmPrimitive.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/OsmPrimitive.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/osm/OsmPrimitive.java	(revision 17862)
@@ -330,20 +330,9 @@
     }
 
-    /**
-     * Make the primitive disabled (e.g.&nbsp;if a filter applies).
-     *
-     * To enable the primitive again, use unsetDisabledState.
-     * @param hidden if the primitive should be completely hidden from view or
-     *             just shown in gray color.
-     * @return true, any flag has changed; false if you try to set the disabled
-     * state to the value that is already preset
-     */
+    @Override
     public boolean setDisabledState(boolean hidden) {
         boolean locked = writeLock();
         try {
-            int oldFlags = flags;
-            updateFlagsNoLock(FLAG_DISABLED, true);
-            updateFlagsNoLock(FLAG_HIDE_IF_DISABLED, hidden);
-            return oldFlags != flags;
+            return super.setDisabledState(hidden);
         } finally {
             writeUnlock(locked);
@@ -356,30 +345,12 @@
      * @return {@code true} if a change occurred
      */
+    @Override
     public boolean unsetDisabledState() {
         boolean locked = writeLock();
         try {
-            int oldFlags = flags;
-            updateFlagsNoLock(FLAG_DISABLED, false);
-            updateFlagsNoLock(FLAG_HIDE_IF_DISABLED, false);
-            return oldFlags != flags;
-        } finally {
-            writeUnlock(locked);
-        }
-    }
-
-    /**
-     * Set binary property used internally by the filter mechanism.
-     * @param isExplicit new "disabled type" flag value
-     */
-    public void setDisabledType(boolean isExplicit) {
-        updateFlags(FLAG_DISABLED_TYPE, isExplicit);
-    }
-
-    /**
-     * Set binary property used internally by the filter mechanism.
-     * @param isExplicit new "hidden type" flag value
-     */
-    public void setHiddenType(boolean isExplicit) {
-        updateFlags(FLAG_HIDDEN_TYPE, isExplicit);
+            return super.unsetDisabledState();
+        } finally {
+            writeUnlock(locked);
+        }
     }
 
@@ -401,20 +372,4 @@
     public boolean isDisabledAndHidden() {
         return ((flags & FLAG_DISABLED) != 0) && ((flags & FLAG_HIDE_IF_DISABLED) != 0);
-    }
-
-    /**
-     * Get binary property used internally by the filter mechanism.
-     * @return {@code true} if this object has the "hidden type" flag enabled
-     */
-    public boolean getHiddenType() {
-        return (flags & FLAG_HIDDEN_TYPE) != 0;
-    }
-
-    /**
-     * Get binary property used internally by the filter mechanism.
-     * @return {@code true} if this object has the "disabled type" flag enabled
-     */
-    public boolean getDisabledType() {
-        return (flags & FLAG_DISABLED_TYPE) != 0;
     }
 
Index: trunk/src/org/openstreetmap/josm/data/osm/RelationMember.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/RelationMember.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/osm/RelationMember.java	(revision 17862)
@@ -58,4 +58,5 @@
      * @since 1937
      */
+    @Override
     public Way getWay() {
         return (Way) member;
Index: trunk/src/org/openstreetmap/josm/data/osm/WaySegment.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/WaySegment.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/osm/WaySegment.java	(revision 17862)
@@ -1,58 +1,27 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.osm;
-
-import java.awt.geom.Line2D;
-import java.util.Objects;
 
 /**
  * A segment consisting of 2 consecutive nodes out of a way.
  */
-public final class WaySegment implements Comparable<WaySegment> {
+public final class WaySegment extends IWaySegment<Node, Way> {
 
     /**
-     * The way.
-     */
-    public final Way way;
-
-    /**
-     * The index of one of the 2 nodes in the way.  The other node has the
-     * index <code>lowerIndex + 1</code>.
-     */
-    public final int lowerIndex;
-
-    /**
-     * Constructs a new {@code WaySegment}.
-     * @param w The way
-     * @param i The node lower index
+     * Constructs a new {@code IWaySegment}.
+     *
+     * @param way The way
+     * @param i   The node lower index
      * @throws IllegalArgumentException in case of invalid index
      */
-    public WaySegment(Way w, int i) {
-        way = w;
-        lowerIndex = i;
-        if (i < 0 || i >= w.getNodesCount() - 1) {
-            throw new IllegalArgumentException(toString());
-        }
+    public WaySegment(Way way, int i) {
+        super(way, i);
     }
 
     /**
-     * Returns the first node of the way segment.
-     * @return the first node
-     */
-    public Node getFirstNode() {
-        return way.getNode(lowerIndex);
-    }
-
-    /**
-     * Returns the second (last) node of the way segment.
-     * @return the second node
-     */
-    public Node getSecondNode() {
-        return way.getNode(lowerIndex + 1);
-    }
-
-    /**
-     * Determines and returns the way segment for the given way and node pair.
-     * @param way way
-     * @param first first node
+     * Determines and returns the way segment for the given way and node pair. You should prefer
+     * {@link IWaySegment#forNodePair(IWay, INode, INode)} whenever possible.
+     *
+     * @param way    way
+     * @param first  first node
      * @param second second node
      * @return way segment
@@ -71,8 +40,22 @@
     }
 
+    @Override
+    public Node getFirstNode() {
+        // This is kept for binary compatibility
+        return super.getFirstNode();
+    }
+
+    @Override
+    public Node getSecondNode() {
+        // This is kept for binary compatibility
+        return super.getSecondNode();
+    }
+
+
     /**
      * Returns this way segment as complete way.
      * @return the way segment as {@code Way}
      */
+    @Override
     public Way toWay() {
         Way w = new Way();
@@ -84,19 +67,12 @@
     @Override
     public boolean equals(Object o) {
-        if (this == o) return true;
-        if (o == null || getClass() != o.getClass()) return false;
-        WaySegment that = (WaySegment) o;
-        return lowerIndex == that.lowerIndex &&
-                Objects.equals(way, that.way);
+        // This is kept for binary compatibility
+        return super.equals(o);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(way, lowerIndex);
-    }
-
-    @Override
-    public int compareTo(WaySegment o) {
-        return o == null ? -1 : (equals(o) ? 0 : toWay().compareTo(o.toWay()));
+        // This is kept for binary compatibility
+        return super.hashCode();
     }
 
@@ -108,13 +84,6 @@
      */
     public boolean intersects(WaySegment s2) {
-        if (getFirstNode().equals(s2.getFirstNode()) || getSecondNode().equals(s2.getSecondNode()) ||
-                getFirstNode().equals(s2.getSecondNode()) || getSecondNode().equals(s2.getFirstNode()))
-            return false;
-
-        return Line2D.linesIntersect(
-                getFirstNode().getEastNorth().east(), getFirstNode().getEastNorth().north(),
-                getSecondNode().getEastNorth().east(), getSecondNode().getEastNorth().north(),
-                s2.getFirstNode().getEastNorth().east(), s2.getFirstNode().getEastNorth().north(),
-                s2.getSecondNode().getEastNorth().east(), s2.getSecondNode().getEastNorth().north());
+        // This is kept for binary compatibility
+        return super.intersects(s2);
     }
 
@@ -125,6 +94,6 @@
      */
     public boolean isSimilar(WaySegment s2) {
-        return (getFirstNode().equals(s2.getFirstNode()) && getSecondNode().equals(s2.getSecondNode()))
-            || (getFirstNode().equals(s2.getSecondNode()) && getSecondNode().equals(s2.getFirstNode()));
+        // This is kept for binary compatibility
+        return super.isSimilar(s2);
     }
 
Index: trunk/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java	(revision 17862)
@@ -0,0 +1,35 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm.event;
+
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmData;
+
+/**
+ * This interface indicates that the class can fire {@link IDataSelectionListener}.
+ * @author Taylor Smock, Michael Zangl (original code)
+ * @param <O> the base type of OSM primitives
+ * @param <N> type representing OSM nodes
+ * @param <W> type representing OSM ways
+ * @param <R> type representing OSM relations
+ * @param <D> The dataset type
+ * @since xxx
+ */
+public interface IDataSelectionEventSource<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>,
+       D extends OsmData<O, N, W, R>> {
+    /**
+     * Add a listener
+     * @param listener The listener to add
+     * @return {@code true} if the listener was added
+     */
+    boolean addSelectionListener(IDataSelectionListener<O, N, W, R, D> listener);
+
+    /**
+     * Remove a listener
+     * @param listener The listener to remove
+     * @return {@code true} if the listener was removed
+     */
+    boolean removeSelectionListener(IDataSelectionListener<O, N, W, R, D> listener);
+}
Index: trunk/src/org/openstreetmap/josm/data/osm/event/IDataSelectionListener.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/event/IDataSelectionListener.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/osm/event/IDataSelectionListener.java	(revision 17862)
@@ -0,0 +1,368 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm.event;
+
+import org.openstreetmap.josm.data.osm.DataSelectionListener;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmData;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.tools.CheckParameterUtil;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * This interface is the same as {@link DataSelectionListener}, except it isn't {@link OsmPrimitive} specific.
+ * @author Taylor Smock, Michael Zangl (original code)
+ * @param <O> the base type of OSM primitives
+ * @param <N> type representing OSM nodes
+ * @param <W> type representing OSM ways
+ * @param <R> type representing OSM relations
+ * @param <D> The dataset type
+ * @since xxx
+ */
+@FunctionalInterface
+public interface IDataSelectionListener<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>,
+       D extends OsmData<O, N, W, R>> {
+    /**
+     * Called whenever the selection is changed.
+     *
+     * You get notified about the new selection, the elements that were added and removed and the layer that triggered the event.
+     * @param event The selection change event.
+     * @see SelectionChangeEvent
+     */
+    void selectionChanged(SelectionChangeEvent<O, N, W, R, D> event);
+
+    /**
+     * The event that is fired when the selection changed.
+     * @author Michael Zangl
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     * @since xxx generics
+     */
+    interface SelectionChangeEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>,
+              D extends OsmData<O, N, W, R>> {
+        /**
+         * Gets the previous selection
+         * <p>
+         * This collection cannot be modified and will not change.
+         * @return The old selection
+         */
+        Set<O> getOldSelection();
+
+        /**
+         * Gets the new selection. New elements are added to the end of the collection.
+         * <p>
+         * This collection cannot be modified and will not change.
+         * @return The new selection
+         */
+        Set<O> getSelection();
+
+        /**
+         * Gets the primitives that have been removed from the selection.
+         * <p>
+         * Those are the primitives contained in {@link #getOldSelection()} but not in {@link #getSelection()}
+         * <p>
+         * This collection cannot be modified and will not change.
+         * @return The primitives that were removed
+         */
+        Set<O> getRemoved();
+
+        /**
+         * Gets the primitives that have been added to the selection.
+         * <p>
+         * Those are the primitives contained in {@link #getSelection()} but not in {@link #getOldSelection()}
+         * <p>
+         * This collection cannot be modified and will not change.
+         * @return The primitives that were added
+         */
+        Set<O> getAdded();
+
+        /**
+         * Gets the data set that triggered this selection event.
+         * @return The data set.
+         */
+        D getSource();
+
+        /**
+         * Test if this event did not change anything.
+         * <p>
+         * This will return <code>false</code> for all events that are sent to listeners, so you don't need to test it.
+         * @return <code>true</code> if this did not change the selection.
+         */
+        default boolean isNop() {
+            return getAdded().isEmpty() && getRemoved().isEmpty();
+        }
+    }
+
+    /**
+     * The base class for selection events
+     * @author Michael Zangl
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     * @since 12048, xxx (generics)
+     */
+    abstract class AbstractSelectionEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>,
+             D extends OsmData<O, N, W, R>> implements SelectionChangeEvent<O, N, W, R, D> {
+        private final D source;
+        private final Set<O> old;
+
+        protected AbstractSelectionEvent(D source, Set<O> old) {
+            CheckParameterUtil.ensureParameterNotNull(source, "source");
+            CheckParameterUtil.ensureParameterNotNull(old, "old");
+            this.source = source;
+            this.old = Collections.unmodifiableSet(old);
+        }
+
+        @Override
+        public Set<O> getOldSelection() {
+            return old;
+        }
+
+        @Override
+        public D getSource() {
+            return source;
+        }
+    }
+
+    /**
+     * The selection is replaced by a new selection
+     * @author Michael Zangl
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     * @since xxx (generics)
+     */
+    class SelectionReplaceEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>>
+        extends AbstractSelectionEvent<O, N, W, R, D> {
+        private final Set<O> current;
+        private Set<O> removed;
+        private Set<O> added;
+
+        /**
+         * Create a {@link SelectionReplaceEvent}
+         * @param source The source dataset
+         * @param old The old primitives that were previously selected. The caller needs to ensure that this set is not modified.
+         * @param newSelection The primitives of the new selection.
+         */
+        public SelectionReplaceEvent(D source, Set<O> old, Stream<O> newSelection) {
+            super(source, old);
+            this.current = newSelection.collect(Collectors.toCollection(LinkedHashSet::new));
+        }
+
+        @Override
+        public Set<O> getSelection() {
+            return current;
+        }
+
+        @Override
+        public synchronized Set<O> getRemoved() {
+            if (removed == null) {
+                removed = getOldSelection().stream()
+                        .filter(p -> !current.contains(p))
+                        .collect(Collectors.toCollection(LinkedHashSet::new));
+            }
+            return removed;
+        }
+
+        @Override
+        public synchronized Set<O> getAdded() {
+            if (added == null) {
+                added = current.stream()
+                        .filter(p -> !getOldSelection().contains(p)).collect(Collectors.toCollection(LinkedHashSet::new));
+            }
+            return added;
+        }
+
+        @Override
+        public String toString() {
+            return "SelectionReplaceEvent [current=" + current + ", removed=" + removed + ", added=" + added + ']';
+        }
+    }
+
+    /**
+     * Primitives are added to the selection
+     * @author Michael Zangl
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     * @since xxx (generics)
+     */
+    class SelectionAddEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>>
+        extends AbstractSelectionEvent<O, N, W, R, D> {
+        private final Set<O> add;
+        private final Set<O> current;
+
+        /**
+         * Create a {@link SelectionAddEvent}
+         * @param source The source dataset
+         * @param old The old primitives that were previously selected. The caller needs to ensure that this set is not modified.
+         * @param toAdd The primitives to add.
+         */
+        public SelectionAddEvent(D source, Set<O> old, Stream<O> toAdd) {
+            super(source, old);
+            this.add = toAdd
+                    .filter(p -> !old.contains(p))
+                    .collect(Collectors.toCollection(LinkedHashSet::new));
+            if (this.add.isEmpty()) {
+                this.current = this.getOldSelection();
+            } else {
+                this.current = new LinkedHashSet<>(old);
+                this.current.addAll(add);
+            }
+        }
+
+        @Override
+        public Set<O> getSelection() {
+            return Collections.unmodifiableSet(current);
+        }
+
+        @Override
+        public Set<O> getRemoved() {
+            return Collections.emptySet();
+        }
+
+        @Override
+        public Set<O> getAdded() {
+            return Collections.unmodifiableSet(add);
+        }
+
+        @Override
+        public String toString() {
+            return "SelectionAddEvent [add=" + add + ", current=" + current + ']';
+        }
+    }
+
+    /**
+     * Primitives are removed from the selection
+     * @author Michael Zangl
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     * @since 12048, xxx (generics)
+     */
+    class SelectionRemoveEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>>
+        extends AbstractSelectionEvent<O, N, W, R, D> {
+        private final Set<O> remove;
+        private final Set<O> current;
+
+        /**
+         * Create a {@link DataSelectionListener.SelectionRemoveEvent}
+         * @param source The source dataset
+         * @param old The old primitives that were previously selected. The caller needs to ensure that this set is not modified.
+         * @param toRemove The primitives to remove.
+         */
+        public SelectionRemoveEvent(D source, Set<O> old, Stream<O> toRemove) {
+            super(source, old);
+            this.remove = toRemove
+                    .filter(old::contains)
+                    .collect(Collectors.toCollection(LinkedHashSet::new));
+            if (this.remove.isEmpty()) {
+                this.current = this.getOldSelection();
+            } else {
+                HashSet<O> currentSet = new LinkedHashSet<>(old);
+                currentSet.removeAll(remove);
+                current = currentSet;
+            }
+        }
+
+        @Override
+        public Set<O> getSelection() {
+            return Collections.unmodifiableSet(current);
+        }
+
+        @Override
+        public Set<O> getRemoved() {
+            return Collections.unmodifiableSet(remove);
+        }
+
+        @Override
+        public Set<O> getAdded() {
+            return Collections.emptySet();
+        }
+
+        @Override
+        public String toString() {
+            return "SelectionRemoveEvent [remove=" + remove + ", current=" + current + ']';
+        }
+    }
+
+    /**
+     * Toggle the selected state of a primitive
+     * @author Michael Zangl
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     * @since xxx (generics)
+     */
+    class SelectionToggleEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>>
+        extends AbstractSelectionEvent<O, N, W, R, D> {
+        private final Set<O> current;
+        private final Set<O> remove;
+        private final Set<O> add;
+
+        /**
+         * Create a {@link SelectionToggleEvent}
+         * @param source The source dataset
+         * @param old The old primitives that were previously selected. The caller needs to ensure that this set is not modified.
+         * @param toToggle The primitives to toggle.
+         */
+        public SelectionToggleEvent(D source, Set<O> old, Stream<O> toToggle) {
+            super(source, old);
+            HashSet<O> currentSet = new LinkedHashSet<>(old);
+            HashSet<O> removeSet = new LinkedHashSet<>();
+            HashSet<O> addSet = new LinkedHashSet<>();
+            toToggle.forEach(p -> {
+                if (currentSet.remove(p)) {
+                    removeSet.add(p);
+                } else {
+                    addSet.add(p);
+                    currentSet.add(p);
+                }
+            });
+            this.current = Collections.unmodifiableSet(currentSet);
+            this.remove = Collections.unmodifiableSet(removeSet);
+            this.add = Collections.unmodifiableSet(addSet);
+        }
+
+        @Override
+        public Set<O> getSelection() {
+            return current;
+        }
+
+        @Override
+        public Set<O> getRemoved() {
+            return remove;
+        }
+
+        @Override
+        public Set<O> getAdded() {
+            return add;
+        }
+
+        @Override
+        public String toString() {
+            return "SelectionToggleEvent [current=" + current + ", remove=" + remove + ", add=" + add + ']';
+        }
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledMapRenderer.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledMapRenderer.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledMapRenderer.java	(revision 17862)
@@ -37,4 +37,5 @@
 import java.util.concurrent.ForkJoinPool;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
 import java.util.function.BiConsumer;
 import java.util.function.Consumer;
@@ -1638,11 +1639,11 @@
         BBox bbox = bounds.toBBox();
         getSettings(renderVirtualNodes);
-
         try {
-            if (data.getReadLock().tryLock(1, TimeUnit.SECONDS)) {
+            Lock readLock = data.getReadLock();
+            if (readLock.tryLock(1, TimeUnit.SECONDS)) {
                 try {
                     paintWithLock(data, renderVirtualNodes, benchmark, bbox);
                 } finally {
-                    data.getReadLock().unlock();
+                    readLock.unlock();
                 }
             } else {
Index: trunk/src/org/openstreetmap/josm/data/protobuf/ProtobufPacked.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/protobuf/ProtobufPacked.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/protobuf/ProtobufPacked.java	(revision 17862)
@@ -0,0 +1,62 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Parse packed values (only numerical values)
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class ProtobufPacked {
+    private final byte[] bytes;
+    private final Number[] numbers;
+    private int location;
+
+    /**
+     * Create a new ProtobufPacked object
+     *
+     * @param bytes The packed bytes
+     */
+    public ProtobufPacked(byte[] bytes) {
+        this.location = 0;
+        this.bytes = bytes;
+        List<Number> numbersT = new ArrayList<>();
+        while (this.location < bytes.length) {
+            numbersT.add(ProtobufParser.convertByteArray(this.nextVarInt(), ProtobufParser.VAR_INT_BYTE_SIZE));
+        }
+
+        this.numbers = new Number[numbersT.size()];
+        for (int i = 0; i < numbersT.size(); i++) {
+            this.numbers[i] = numbersT.get(i);
+        }
+    }
+
+    /**
+     * Get the parsed number array
+     *
+     * @return The number array
+     */
+    public Number[] getArray() {
+        return this.numbers;
+    }
+
+    private byte[] nextVarInt() {
+        List<Byte> byteList = new ArrayList<>();
+        while ((this.bytes[this.location] & ProtobufParser.MOST_SIGNIFICANT_BYTE)
+          == ProtobufParser.MOST_SIGNIFICANT_BYTE) {
+            // Get rid of the leading bit (shift left 1, then shift right 1 unsigned)
+            byteList.add((byte) (this.bytes[this.location++] ^ ProtobufParser.MOST_SIGNIFICANT_BYTE));
+        }
+        // The last byte doesn't drop the most significant bit
+        byteList.add(this.bytes[this.location++]);
+        byte[] byteArray = new byte[byteList.size()];
+        for (int i = 0; i < byteList.size(); i++) {
+            byteArray[i] = byteList.get(i);
+        }
+
+        return byteArray;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/protobuf/ProtobufParser.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/protobuf/ProtobufParser.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/protobuf/ProtobufParser.java	(revision 17862)
@@ -0,0 +1,245 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A basic Protobuf parser
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class ProtobufParser implements AutoCloseable {
+    /**
+     * The default byte size (see {@link #VAR_INT_BYTE_SIZE} for var ints)
+     */
+    public static final byte BYTE_SIZE = 8;
+    /**
+     * The byte size for var ints (since the first byte is just an indicator for if the var int is done)
+     */
+    public static final byte VAR_INT_BYTE_SIZE = BYTE_SIZE - 1;
+    /**
+     * Used to get the most significant byte
+     */
+    static final byte MOST_SIGNIFICANT_BYTE = (byte) (1 << 7);
+    /**
+     * Convert a byte array to a number (little endian)
+     *
+     * @param bytes    The bytes to convert
+     * @param byteSize The size of the byte. For var ints, this is 7, for other ints, this is 8.
+     * @return An appropriate {@link Number} class.
+     */
+    public static Number convertByteArray(byte[] bytes, byte byteSize) {
+        long number = 0;
+        for (int i = 0; i < bytes.length; i++) {
+            // Need to convert to uint64 in order to avoid bit operation from filling in 1's and overflow issues
+            number += Byte.toUnsignedLong(bytes[i]) << (byteSize * i);
+        }
+        return convertLong(number);
+    }
+
+    /**
+     * Convert a long to an appropriate {@link Number} class
+     *
+     * @param number The long to convert
+     * @return A {@link Number}
+     */
+    public static Number convertLong(long number) {
+        // TODO deal with booleans
+        if (number <= Byte.MAX_VALUE && number >= Byte.MIN_VALUE) {
+            return (byte) number;
+        } else if (number <= Short.MAX_VALUE && number >= Short.MIN_VALUE) {
+            return (short) number;
+        } else if (number <= Integer.MAX_VALUE && number >= Integer.MIN_VALUE) {
+            return (int) number;
+        }
+        return number;
+    }
+
+    /**
+     * Decode a zig-zag encoded value
+     *
+     * @param signed The value to decode
+     * @return The decoded value
+     */
+    public static Number decodeZigZag(Number signed) {
+        final long value = signed.longValue();
+        return convertLong((value >> 1) ^ -(value & 1));
+    }
+
+    /**
+     * Encode a number to a zig-zag encode value
+     *
+     * @param signed The number to encode
+     * @return The encoded value
+     */
+    public static Number encodeZigZag(Number signed) {
+        final long value = signed.longValue();
+        // This boundary condition could be >= or <= or both. Tests indicate that it doesn't actually matter.
+        // The only difference would be the number type returned, except it is always converted to the most basic type.
+        final int shift = (value > Integer.MAX_VALUE || value < Integer.MIN_VALUE ? Long.BYTES : Integer.BYTES) * 8 - 1;
+        return convertLong((value << 1) ^ (value >> shift));
+    }
+
+    private final InputStream inputStream;
+
+    /**
+     * Create a new parser
+     *
+     * @param bytes The bytes to parse
+     */
+    public ProtobufParser(byte[] bytes) {
+        this(new ByteArrayInputStream(bytes));
+    }
+
+    /**
+     * Create a new parser
+     *
+     * @param inputStream The InputStream (will be fully read at this time)
+     */
+    public ProtobufParser(InputStream inputStream) {
+        if (inputStream.markSupported()) {
+            this.inputStream = inputStream;
+        } else {
+            this.inputStream = new BufferedInputStream(inputStream);
+        }
+    }
+
+    /**
+     * Read all records
+     *
+     * @return A collection of all records
+     * @throws IOException - if an IO error occurs
+     */
+    public Collection<ProtobufRecord> allRecords() throws IOException {
+        Collection<ProtobufRecord> records = new ArrayList<>();
+        while (this.hasNext()) {
+            records.add(new ProtobufRecord(this));
+        }
+        return records;
+    }
+
+    @Override
+    public void close() {
+        try {
+            this.inputStream.close();
+        } catch (IOException e) {
+            Logging.error(e);
+        }
+    }
+
+    /**
+     * Check if there is more data to read
+     *
+     * @return {@code true} if there is more data to read
+     * @throws IOException - if an IO error occurs
+     */
+    public boolean hasNext() throws IOException {
+        return this.inputStream.available() > 0;
+    }
+
+    /**
+     * Get the "next" WireType
+     *
+     * @return {@link WireType} expected
+     * @throws IOException - if an IO error occurs
+     */
+    public WireType next() throws IOException {
+        this.inputStream.mark(16);
+        try {
+            return WireType.values()[this.inputStream.read() << 3];
+        } finally {
+            this.inputStream.reset();
+        }
+    }
+
+    /**
+     * Get the next byte
+     *
+     * @return The next byte
+     * @throws IOException - if an IO error occurs
+     */
+    public int nextByte() throws IOException {
+        return this.inputStream.read();
+    }
+
+    /**
+     * Get the next 32 bits ({@link WireType#THIRTY_TWO_BIT})
+     *
+     * @return a byte array of the next 32 bits (4 bytes)
+     * @throws IOException - if an IO error occurs
+     */
+    public byte[] nextFixed32() throws IOException {
+        // 4 bytes == 32 bits
+        return readNextBytes(4);
+    }
+
+    /**
+     * Get the next 64 bits ({@link WireType#SIXTY_FOUR_BIT})
+     *
+     * @return a byte array of the next 64 bits (8 bytes)
+     * @throws IOException - if an IO error occurs
+     */
+    public byte[] nextFixed64() throws IOException {
+        // 8 bytes == 64 bits
+        return readNextBytes(8);
+    }
+
+    /**
+     * Get the next delimited message ({@link WireType#LENGTH_DELIMITED})
+     *
+     * @return The next length delimited message
+     * @throws IOException - if an IO error occurs
+     */
+    public byte[] nextLengthDelimited() throws IOException {
+        int length = convertByteArray(this.nextVarInt(), VAR_INT_BYTE_SIZE).intValue();
+        return readNextBytes(length);
+    }
+
+    /**
+     * Get the next var int ({@code WireType#VARINT})
+     *
+     * @return The next var int ({@code int32}, {@code int64}, {@code uint32}, {@code uint64}, {@code bool}, {@code enum})
+     * @throws IOException - if an IO error occurs
+     */
+    public byte[] nextVarInt() throws IOException {
+        List<Byte> byteList = new ArrayList<>();
+        int currentByte = this.nextByte();
+        while ((byte) (currentByte & MOST_SIGNIFICANT_BYTE) == MOST_SIGNIFICANT_BYTE && currentByte > 0) {
+            // Get rid of the leading bit (shift left 1, then shift right 1 unsigned)
+            byteList.add((byte) (currentByte ^ MOST_SIGNIFICANT_BYTE));
+            currentByte = this.nextByte();
+        }
+        // The last byte doesn't drop the most significant bit
+        byteList.add((byte) currentByte);
+        byte[] byteArray = new byte[byteList.size()];
+        for (int i = 0; i < byteList.size(); i++) {
+            byteArray[i] = byteList.get(i);
+        }
+
+        return byteArray;
+    }
+
+    /**
+     * Read an arbitrary number of bytes
+     *
+     * @param size The number of bytes to read
+     * @return a byte array of the specified size, filled with bytes read (unsigned)
+     * @throws IOException - if an IO error occurs
+     */
+    private byte[] readNextBytes(int size) throws IOException {
+        byte[] bytesRead = new byte[size];
+        for (int i = 0; i < bytesRead.length; i++) {
+            bytesRead[i] = (byte) this.nextByte();
+        }
+        return bytesRead;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/protobuf/ProtobufRecord.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/protobuf/ProtobufRecord.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/protobuf/ProtobufRecord.java	(revision 17862)
@@ -0,0 +1,152 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.stream.Stream;
+
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * A protobuf record, storing the {@link WireType}, the parsed field number, and the bytes for it.
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class ProtobufRecord implements AutoCloseable {
+    private static final byte[] EMPTY_BYTES = {};
+    private final WireType type;
+    private final int field;
+    private byte[] bytes;
+
+    /**
+     * Create a new Protobuf record
+     *
+     * @param parser The parser to use to create the record
+     * @throws IOException - if an IO error occurs
+     */
+    public ProtobufRecord(ProtobufParser parser) throws IOException {
+        Number number = ProtobufParser.convertByteArray(parser.nextVarInt(), ProtobufParser.VAR_INT_BYTE_SIZE);
+        // I don't foresee having field numbers > {@code Integer#MAX_VALUE >> 3}
+        this.field = (int) number.longValue() >> 3;
+        // 7 is 111 (so last three bits)
+        byte wireType = (byte) (number.longValue() & 7);
+        this.type = Stream.of(WireType.values()).filter(wType -> wType.getTypeRepresentation() == wireType).findFirst()
+          .orElse(WireType.UNKNOWN);
+
+        if (this.type == WireType.VARINT) {
+            this.bytes = parser.nextVarInt();
+        } else if (this.type == WireType.SIXTY_FOUR_BIT) {
+            this.bytes = parser.nextFixed64();
+        } else if (this.type == WireType.THIRTY_TWO_BIT) {
+            this.bytes = parser.nextFixed32();
+        } else if (this.type == WireType.LENGTH_DELIMITED) {
+            this.bytes = parser.nextLengthDelimited();
+        } else {
+            this.bytes = EMPTY_BYTES;
+        }
+    }
+
+    /**
+     * Get as a double ({@link WireType#SIXTY_FOUR_BIT})
+     *
+     * @return the double
+     */
+    public double asDouble() {
+        long doubleNumber = ProtobufParser.convertByteArray(asFixed64(), ProtobufParser.BYTE_SIZE).longValue();
+        return Double.longBitsToDouble(doubleNumber);
+    }
+
+    /**
+     * Get as 32 bits ({@link WireType#THIRTY_TWO_BIT})
+     *
+     * @return a byte array of the 32 bits (4 bytes)
+     */
+    public byte[] asFixed32() {
+        // TODO verify, or just assume?
+        // 4 bytes == 32 bits
+        return this.bytes;
+    }
+
+    /**
+     * Get as 64 bits ({@link WireType#SIXTY_FOUR_BIT})
+     *
+     * @return a byte array of the 64 bits (8 bytes)
+     */
+    public byte[] asFixed64() {
+        // TODO verify, or just assume?
+        // 8 bytes == 64 bits
+        return this.bytes;
+    }
+
+    /**
+     * Get as a float ({@link WireType#THIRTY_TWO_BIT})
+     *
+     * @return the float
+     */
+    public float asFloat() {
+        int floatNumber = ProtobufParser.convertByteArray(asFixed32(), ProtobufParser.BYTE_SIZE).intValue();
+        return Float.intBitsToFloat(floatNumber);
+    }
+
+    /**
+     * Get the signed var int ({@code WireType#VARINT}).
+     * These are specially encoded so that they take up less space.
+     *
+     * @return The signed var int ({@code sint32} or {@code sint64})
+     */
+    public Number asSignedVarInt() {
+        final Number signed = this.asUnsignedVarInt();
+        return ProtobufParser.decodeZigZag(signed);
+    }
+
+    /**
+     * Get as a string ({@link WireType#LENGTH_DELIMITED})
+     *
+     * @return The string (encoded as {@link StandardCharsets#UTF_8})
+     */
+    public String asString() {
+        return Utils.intern(new String(this.bytes, StandardCharsets.UTF_8));
+    }
+
+    /**
+     * Get the var int ({@code WireType#VARINT})
+     *
+     * @return The var int ({@code int32}, {@code int64}, {@code uint32}, {@code uint64}, {@code bool}, {@code enum})
+     */
+    public Number asUnsignedVarInt() {
+        return ProtobufParser.convertByteArray(this.bytes, ProtobufParser.VAR_INT_BYTE_SIZE);
+    }
+
+    @Override
+    public void close() {
+        this.bytes = null;
+    }
+
+    /**
+     * Get the raw bytes for this record
+     *
+     * @return The bytes
+     */
+    public byte[] getBytes() {
+        return this.bytes;
+    }
+
+    /**
+     * Get the field value
+     *
+     * @return The field value
+     */
+    public int getField() {
+        return this.field;
+    }
+
+    /**
+     * Get the WireType of the data
+     *
+     * @return The {@link WireType} of the data
+     */
+    public WireType getType() {
+        return this.type;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/protobuf/WireType.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/protobuf/WireType.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/protobuf/WireType.java	(revision 17862)
@@ -0,0 +1,61 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+/**
+ * The WireTypes
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public enum WireType {
+    /**
+     * int32, int64, uint32, uint64, sing32, sint64, bool, enum
+     */
+    VARINT(0),
+    /**
+     * fixed64, sfixed64, double
+     */
+    SIXTY_FOUR_BIT(1),
+    /**
+     * string, bytes, embedded messages, packed repeated fields
+     */
+    LENGTH_DELIMITED(2),
+    /**
+     * start groups
+     *
+     * @deprecated Unknown reason. Deprecated since at least 2012.
+     */
+    @Deprecated
+    START_GROUP(3),
+    /**
+     * end groups
+     *
+     * @deprecated Unknown reason. Deprecated since at least 2012.
+     */
+    @Deprecated
+    END_GROUP(4),
+    /**
+     * fixed32, sfixed32, float
+     */
+    THIRTY_TWO_BIT(5),
+
+    /**
+     * For unknown WireTypes
+     */
+    UNKNOWN(Byte.MAX_VALUE);
+
+    private final byte type;
+
+    WireType(int value) {
+        this.type = (byte) value;
+    }
+
+    /**
+     * Get the type representation (byte form)
+     *
+     * @return The wire type byte representation
+     */
+    public byte getTypeRepresentation() {
+        return this.type;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/vector/DataLayer.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/vector/DataLayer.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/vector/DataLayer.java	(revision 17862)
@@ -0,0 +1,23 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+/**
+ * An interface for objects that are part of a data layer
+ * @param <T> The type used to identify a layer, typically a string
+ */
+public interface DataLayer<T> {
+    /**
+     * Get the layer
+     * @return The layer
+     */
+    T getLayer();
+
+    /**
+     * Set the layer
+     * @param layer The layer to set
+     * @return {@code true} if the layer was set -- some objects may never change layers.
+     */
+    default boolean setLayer(T layer) {
+        return layer != null && layer.equals(getLayer());
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/vector/DataStore.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/vector/DataStore.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/vector/DataStore.java	(revision 17862)
@@ -0,0 +1,127 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.josm.data.DataSource;
+import org.openstreetmap.josm.data.osm.DataSet;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.PrimitiveId;
+import org.openstreetmap.josm.data.osm.QuadBucketPrimitiveStore;
+import org.openstreetmap.josm.data.osm.Storage;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A class that stores data (essentially a simple {@link DataSet})
+ * @author Taylor Smock
+ * @since xxx
+ */
+class DataStore<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>> {
+    /**
+     * This literally only exists to make {@link QuadBucketPrimitiveStore#removePrimitive} public
+     *
+     * @param <N> The node type
+     * @param <W> The way type
+     * @param <R> The relation type
+     */
+    static class LocalQuadBucketPrimitiveStore<N extends INode, W extends IWay<N>, R extends IRelation<?>>
+      extends QuadBucketPrimitiveStore<N, W, R> {
+        // Allow us to remove primitives (protected in {@link QuadBucketPrimitiveStore})
+        @Override
+        public void removePrimitive(IPrimitive primitive) {
+            super.removePrimitive(primitive);
+        }
+    }
+
+    protected final LocalQuadBucketPrimitiveStore<N, W, R> store = new LocalQuadBucketPrimitiveStore<>();
+    protected final Storage<O> allPrimitives = new Storage<>(new Storage.PrimitiveIdHash(), true);
+    // TODO what happens when I use hashCode?
+    protected final Set<Tile> addedTiles = Collections.synchronizedSet(new HashSet<>());
+    protected final Map<PrimitiveId, O> primitivesMap = Collections.synchronizedMap(allPrimitives
+      .foreignKey(new Storage.PrimitiveIdHash()));
+    protected final Collection<DataSource> dataSources = new LinkedList<>();
+    private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
+
+    public QuadBucketPrimitiveStore<N, W, R> getStore() {
+        return this.store;
+    }
+
+    public Storage<O> getAllPrimitives() {
+        return this.allPrimitives;
+    }
+
+    /**
+     * Get the primitives map.
+     * @implNote The returned map is a {@link Collections#synchronizedMap}. Please synchronize on it.
+     * @return The Primitives map.
+     */
+    public Map<PrimitiveId, O> getPrimitivesMap() {
+        return this.primitivesMap;
+    }
+
+    public Collection<DataSource> getDataSources() {
+        return Collections.unmodifiableCollection(dataSources);
+    }
+
+    /**
+     * Add a datasource to this data set
+     * @param dataSource The data soure to add
+     */
+    public void addDataSource(DataSource dataSource) {
+        this.dataSources.add(dataSource);
+    }
+
+    /**
+     * Add a primitive to this dataset
+     * @param primitive The primitive to remove
+     */
+    @SuppressWarnings("squid:S2445")
+    protected void removePrimitive(O primitive) {
+        if (primitive == null) {
+            return;
+        }
+        try {
+            this.readWriteLock.writeLock().lockInterruptibly();
+            if (this.allPrimitives.contains(primitive)) {
+                this.store.removePrimitive(primitive);
+                this.allPrimitives.remove(primitive);
+                this.primitivesMap.remove(primitive.getPrimitiveId());
+            }
+        } catch (InterruptedException e) {
+            Logging.error(e);
+            Thread.currentThread().interrupt();
+        } finally {
+            if (this.readWriteLock.isWriteLockedByCurrentThread()) {
+                this.readWriteLock.writeLock().unlock();
+            }
+        }
+    }
+
+    /**
+     * Add a primitive to this dataset
+     * @param primitive The primitive to add
+     */
+    protected void addPrimitive(O primitive) {
+        this.store.addPrimitive(primitive);
+        this.allPrimitives.add(primitive);
+        this.primitivesMap.put(primitive.getPrimitiveId(), primitive);
+    }
+
+    /**
+     * Get the read/write lock for this dataset
+     * @return The read/write lock
+     */
+    protected ReentrantReadWriteLock getReadWriteLock() {
+        return this.readWriteLock;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/vector/VectorDataSet.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/vector/VectorDataSet.java	(revision 17862)
@@ -0,0 +1,659 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import org.openstreetmap.josm.data.DataSource;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.DataSelectionListener;
+import org.openstreetmap.josm.data.osm.DownloadPolicy;
+import org.openstreetmap.josm.data.osm.HighlightUpdateListener;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.OsmData;
+import org.openstreetmap.josm.data.osm.PrimitiveId;
+import org.openstreetmap.josm.data.osm.Storage;
+import org.openstreetmap.josm.data.osm.UploadPolicy;
+import org.openstreetmap.josm.data.osm.WaySegment;
+import org.openstreetmap.josm.data.osm.event.IDataSelectionEventSource;
+import org.openstreetmap.josm.data.osm.event.IDataSelectionListener;
+import org.openstreetmap.josm.gui.mappaint.ElemStyles;
+import org.openstreetmap.josm.tools.ListenerList;
+import org.openstreetmap.josm.tools.Logging;
+import org.openstreetmap.josm.tools.SubclassFilteredCollection;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * A data class for Vector Data
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, VectorWay, VectorRelation>,
+       IDataSelectionEventSource<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> {
+    // Note: In Java 8, computeIfAbsent is blocking for both pre-existing and new values. In Java 9, it is only blocking
+    // for new values (perf increase). See JDK-8161372 for more info.
+    private final Map<Integer, Storage<MVTTile>> dataStoreMap = new ConcurrentHashMap<>();
+    // This is for "custom" data
+    private final VectorDataStore customDataStore = new VectorDataStore();
+    // Both of these listener lists are useless, since they expect OsmPrimitives at this time
+    private final ListenerList<HighlightUpdateListener> highlightUpdateListenerListenerList = ListenerList.create();
+    private final ListenerList<DataSelectionListener> dataSelectionListenerListenerList = ListenerList.create();
+    private boolean lock = true;
+    private String name;
+    private short mappaintCacheIdx = 1;
+
+    private final Object selectionLock = new Object();
+    /**
+     * The current selected primitives. This is always a unmodifiable set.
+     *
+     * The set should be ordered in the order in which the primitives have been added to the selection.
+     */
+    private Set<PrimitiveId> currentSelectedPrimitives = Collections.emptySet();
+
+    private final ListenerList<IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet>> listeners =
+            ListenerList.create();
+
+    private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
+
+    /**
+     * The distance to consider nodes duplicates -- mostly a memory saving measure.
+     * 0.000_000_1 ~1.2 cm (+- 5.57 mm)
+     * Descriptions from <a href="https://xkcd.com/2170/">https://xkcd.com/2170/</a>
+     * Notes on <a href="https://wiki.openstreetmap.org/wiki/Node">https://wiki.openstreetmap.org/wiki/Node</a> indicate
+     * that IEEE 32-bit floats should not be used at high longitude (0.000_01 precision)
+     */
+    protected static final float DUPE_NODE_DISTANCE = 0.000_000_1f;
+
+    /**
+     * The current zoom we are getting/adding to
+     */
+    private int zoom;
+    /**
+     * Default to normal download policy
+     */
+    private DownloadPolicy downloadPolicy = DownloadPolicy.NORMAL;
+    /**
+     * Default to a blocked upload policy
+     */
+    private UploadPolicy uploadPolicy = UploadPolicy.BLOCKED;
+    /**
+     * The paint style for this layer
+     */
+    private ElemStyles styles;
+    private final Collection<PrimitiveId> highlighted = new HashSet<>();
+
+    @Override
+    public Collection<DataSource> getDataSources() {
+        // TODO
+        return Collections.emptyList();
+    }
+
+    @Override
+    public void lock() {
+        this.lock = true;
+    }
+
+    @Override
+    public void unlock() {
+        this.lock = false;
+    }
+
+    @Override
+    public boolean isLocked() {
+        return this.lock;
+    }
+
+    @Override
+    public String getVersion() {
+        return "8"; // TODO get this dynamically. Not critical, as this is currently the _only_ version.
+    }
+
+    @Override
+    public String getName() {
+        return this.name;
+    }
+
+    @Override
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * Add a primitive to the custom data store
+     * @param primitive the primitive to add
+     */
+    @Override
+    public void addPrimitive(VectorPrimitive primitive) {
+        tryWrite(this.readWriteLock, () -> {
+            this.customDataStore.addPrimitive(primitive);
+            primitive.setDataSet(this);
+        });
+    }
+
+    /**
+     * Remove a primitive from the custom data store
+     * @param primitive The primitive to add to the custom data store
+     */
+    public void removePrimitive(VectorPrimitive primitive) {
+        this.customDataStore.removePrimitive(primitive);
+        primitive.setDataSet(null);
+    }
+
+    @Override
+    public void clear() {
+        synchronized (this.dataStoreMap) {
+            this.dataStoreMap.clear();
+        }
+    }
+
+    @Override
+    public List<VectorNode> searchNodes(BBox bbox) {
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .flatMap(store -> store.searchNodes(bbox).stream()).collect(Collectors.toList());
+        }).orElseGet(Collections::emptyList);
+    }
+
+    @Override
+    public boolean containsNode(VectorNode vectorNode) {
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .anyMatch(store -> store.containsNode(vectorNode));
+        }).orElse(Boolean.FALSE);
+    }
+
+    @Override
+    public List<VectorWay> searchWays(BBox bbox) {
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .flatMap(store -> store.searchWays(bbox).stream()).collect(Collectors.toList());
+        }).orElseGet(Collections::emptyList);
+    }
+
+    @Override
+    public boolean containsWay(VectorWay vectorWay) {
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .anyMatch(store -> store.containsWay(vectorWay));
+        }).orElse(Boolean.FALSE);
+    }
+
+    @Override
+    public List<VectorRelation> searchRelations(BBox bbox) {
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .flatMap(store -> store.searchRelations(bbox).stream()).collect(Collectors.toList());
+        }).orElseGet(Collections::emptyList);
+    }
+
+    @Override
+    public boolean containsRelation(VectorRelation vectorRelation) {
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .anyMatch(store -> store.containsRelation(vectorRelation));
+        }).orElse(Boolean.FALSE);
+    }
+
+    /**
+     * Get a primitive for an id
+     * @param primitiveId type and uniqueId of the primitive. Might be &lt; 0 for newly created primitives
+     * @return The primitive for the id. Please note that since this is vector data, there may be more primitives with this id.
+     * Please use {@link #getPrimitivesById(PrimitiveId...)} to get all primitives for that {@link PrimitiveId}.
+     */
+    @Override
+    public VectorPrimitive getPrimitiveById(PrimitiveId primitiveId) {
+        return this.getPrimitivesById(primitiveId).findFirst().orElse(null);
+    }
+
+    /**
+     * Get all primitives for ids
+     * @param primitiveIds The ids to search for
+     * @return The primitives for the ids (note: as this is vector data, a {@link PrimitiveId} may have multiple associated primitives)
+     */
+    public Stream<VectorPrimitive> getPrimitivesById(PrimitiveId... primitiveIds) {
+        final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+        final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+        return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getPrimitivesMap)
+                .flatMap(m -> Stream.of(primitiveIds).map(m::get)).filter(Objects::nonNull);
+    }
+
+    @Override
+    public <T extends VectorPrimitive> Collection<T> getPrimitives(Predicate<? super VectorPrimitive> predicate) {
+        Collection<VectorPrimitive> primitives = tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore))
+                    .map(VectorDataStore::getAllPrimitives).flatMap(Collection::stream).distinct().collect(Collectors.toList());
+
+        }).orElseGet(Collections::emptyList);
+        return new SubclassFilteredCollection<>(primitives, predicate);
+    }
+
+    @Override
+    public Collection<VectorNode> getNodes() {
+        return this.getPrimitives(VectorNode.class::isInstance);
+    }
+
+    @Override
+    public Collection<VectorWay> getWays() {
+        return this.getPrimitives(VectorWay.class::isInstance);
+    }
+
+    @Override
+    public Collection<VectorRelation> getRelations() {
+        return this.getPrimitives(VectorRelation.class::isInstance);
+    }
+
+    @Override
+    public DownloadPolicy getDownloadPolicy() {
+        return this.downloadPolicy;
+    }
+
+    @Override
+    public void setDownloadPolicy(DownloadPolicy downloadPolicy) {
+        this.downloadPolicy = downloadPolicy;
+    }
+
+    @Override
+    public UploadPolicy getUploadPolicy() {
+        return this.uploadPolicy;
+    }
+
+    @Override
+    public void setUploadPolicy(UploadPolicy uploadPolicy) {
+        this.uploadPolicy = uploadPolicy;
+    }
+
+    /**
+     * Get the current Read/Write lock
+     * @implNote This changes based off of zoom level. Please do not use this in a finally block
+     * @return The current read/write lock
+     */
+    @Override
+    public Lock getReadLock() {
+        return this.readWriteLock.readLock();
+    }
+
+    @Override
+    public Collection<WaySegment> getHighlightedVirtualNodes() {
+        // TODO? This requires a change to WaySegment so that it isn't Way/Node specific
+        return Collections.emptyList();
+    }
+
+    @Override
+    public void setHighlightedVirtualNodes(Collection<WaySegment> waySegments) {
+        // TODO? This requires a change to WaySegment so that it isn't Way/Node specific
+    }
+
+    @Override
+    public Collection<WaySegment> getHighlightedWaySegments() {
+        // TODO? This requires a change to WaySegment so that it isn't Way/Node specific
+        return Collections.emptyList();
+    }
+
+    @Override
+    public void setHighlightedWaySegments(Collection<WaySegment> waySegments) {
+        // TODO? This requires a change to WaySegment so that it isn't Way/Node specific
+    }
+
+    /**
+     * Mark some primitives as highlighted
+     * @param primitives The primitives to highlight
+     * @apiNote This is *highly likely* to change, as the inherited methods are modified to accept primitives other than OSM primitives.
+     */
+    public void setHighlighted(Collection<PrimitiveId> primitives) {
+        this.highlighted.clear();
+        this.highlighted.addAll(primitives);
+        // The highlight event updates are very OSM specific, and require a DataSet.
+        this.highlightUpdateListenerListenerList.fireEvent(event -> event.highlightUpdated(null));
+    }
+
+    /**
+     * Get the highlighted objects
+     * @return The highlighted objects
+     */
+    public Collection<PrimitiveId> getHighlighted() {
+        return Collections.unmodifiableCollection(this.highlighted);
+    }
+
+    @Override
+    public void addHighlightUpdateListener(HighlightUpdateListener listener) {
+        this.highlightUpdateListenerListenerList.addListener(listener);
+    }
+
+    @Override
+    public void removeHighlightUpdateListener(HighlightUpdateListener listener) {
+        this.highlightUpdateListenerListenerList.removeListener(listener);
+    }
+
+    @Override
+    public Collection<VectorPrimitive> getAllSelected() {
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+                return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getPrimitivesMap)
+                  .flatMap(dataMap -> {
+                    // Synchronize on dataMap to avoid concurrent modification errors
+                    synchronized (dataMap) {
+                        return this.currentSelectedPrimitives.stream().map(dataMap::get).filter(Objects::nonNull);
+                    }
+                }).collect(Collectors.toList());
+        }).orElseGet(Collections::emptyList);
+    }
+
+    /**
+     * Get the best zoom datastore
+     * @return A datastore with data, or {@code null} if no good datastore exists.
+     */
+    private Optional<Storage<MVTTile>> getBestZoomDataStore() {
+        final int currentZoom = this.zoom;
+        if (this.dataStoreMap.containsKey(currentZoom)) {
+            return Optional.of(this.dataStoreMap.get(currentZoom));
+        }
+        // Check up to two zooms higher (may cause perf hit)
+        for (int tZoom = currentZoom + 1; tZoom < currentZoom + 3; tZoom++) {
+            if (this.dataStoreMap.containsKey(tZoom)) {
+                return Optional.of(this.dataStoreMap.get(tZoom));
+            }
+        }
+        // Return *any* lower zoom data (shouldn't cause a perf hit...)
+        for (int tZoom = currentZoom - 1; tZoom >= 0; tZoom--) {
+            if (this.dataStoreMap.containsKey(tZoom)) {
+                return Optional.of(this.dataStoreMap.get(tZoom));
+            }
+        }
+        // Check higher level zooms. May cause perf issues if selected datastore has a lot of data.
+        for (int tZoom = currentZoom + 3; tZoom < 34; tZoom++) {
+            if (this.dataStoreMap.containsKey(tZoom)) {
+                return Optional.of(this.dataStoreMap.get(tZoom));
+            }
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public boolean selectionEmpty() {
+        return this.currentSelectedPrimitives.isEmpty();
+    }
+
+    @Override
+    public boolean isSelected(VectorPrimitive osm) {
+        return this.currentSelectedPrimitives.contains(osm.getPrimitiveId());
+    }
+
+    @Override
+    public void toggleSelected(Collection<? extends PrimitiveId> osm) {
+        this.toggleSelectedImpl(osm.stream());
+    }
+
+    @Override
+    public void toggleSelected(PrimitiveId... osm) {
+        this.toggleSelectedImpl(Stream.of(osm));
+    }
+
+    private void toggleSelectedImpl(Stream<? extends PrimitiveId> osm) {
+        this.doSelectionChange(old -> new IDataSelectionListener.SelectionToggleEvent<>(this, old,
+                osm.flatMap(this::getPrimitivesById).filter(Objects::nonNull)));
+    }
+
+    @Override
+    public void setSelected(Collection<? extends PrimitiveId> selection) {
+        this.setSelectedImpl(selection.stream());
+    }
+
+    @Override
+    public void setSelected(PrimitiveId... osm) {
+        this.setSelectedImpl(Stream.of(osm));
+    }
+
+    private void setSelectedImpl(Stream<? extends PrimitiveId> osm) {
+        this.doSelectionChange(old -> new IDataSelectionListener.SelectionReplaceEvent<>(this, old,
+                osm.filter(Objects::nonNull).flatMap(this::getPrimitivesById).filter(Objects::nonNull)));
+    }
+
+    @Override
+    public void addSelected(Collection<? extends PrimitiveId> selection) {
+        this.addSelectedImpl(selection.stream());
+    }
+
+    @Override
+    public void addSelected(PrimitiveId... osm) {
+        this.addSelectedImpl(Stream.of(osm));
+    }
+
+    private void addSelectedImpl(Stream<? extends PrimitiveId> osm) {
+        this.doSelectionChange(old -> new IDataSelectionListener.SelectionAddEvent<>(this, old,
+                osm.flatMap(this::getPrimitivesById).filter(Objects::nonNull)));
+    }
+
+    @Override
+    public void clearSelection(PrimitiveId... osm) {
+        this.clearSelectionImpl(Stream.of(osm));
+    }
+
+    @Override
+    public void clearSelection(Collection<? extends PrimitiveId> list) {
+        this.clearSelectionImpl(list.stream());
+    }
+
+    @Override
+    public void clearSelection() {
+        this.clearSelectionImpl(new ArrayList<>(this.currentSelectedPrimitives).stream());
+    }
+
+    private void clearSelectionImpl(Stream<? extends PrimitiveId> osm) {
+        this.doSelectionChange(old -> new IDataSelectionListener.SelectionRemoveEvent<>(this, old,
+                osm.flatMap(this::getPrimitivesById).filter(Objects::nonNull)));
+    }
+
+    /**
+     * Do a selection change.
+     * <p>
+     * This is the only method that changes the current selection state.
+     * @param command A generator that generates the {@link DataSelectionListener.SelectionChangeEvent}
+     *                for the given base set of currently selected primitives.
+     * @return true iff the command did change the selection.
+     */
+    private boolean doSelectionChange(final Function<Set<VectorPrimitive>,
+            IDataSelectionListener.SelectionChangeEvent<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet>> command) {
+        synchronized (this.selectionLock) {
+            IDataSelectionListener.SelectionChangeEvent<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> event =
+                    command.apply(currentSelectedPrimitives.stream().map(this::getPrimitiveById).collect(Collectors.toSet()));
+            if (event.isNop()) {
+                return false;
+            }
+            this.currentSelectedPrimitives = event.getSelection().stream().map(IPrimitive::getPrimitiveId)
+                    .collect(Collectors.toCollection(LinkedHashSet::new));
+            this.listeners.fireEvent(l -> l.selectionChanged(event));
+            return true;
+        }
+    }
+
+    @Override
+    public void addSelectionListener(DataSelectionListener listener) {
+        this.dataSelectionListenerListenerList.addListener(listener);
+    }
+
+    @Override
+    public void removeSelectionListener(DataSelectionListener listener) {
+        this.dataSelectionListenerListenerList.removeListener(listener);
+    }
+
+    public short getMappaintCacheIndex() {
+        return this.mappaintCacheIdx;
+    }
+
+    @Override
+    public void clearMappaintCache() {
+        this.mappaintCacheIdx++;
+    }
+
+    public void setZoom(int zoom) {
+        if (zoom == this.zoom) {
+            return; // Do nothing -- zoom isn't actually changing
+        }
+        this.zoom = zoom;
+        this.clearMappaintCache();
+        final int[] nearestZoom = {-1, -1, -1, -1};
+        nearestZoom[0] = zoom;
+        // Create a new list to avoid concurrent modification issues
+        synchronized (this.dataStoreMap) {
+            final int[] keys = new ArrayList<>(this.dataStoreMap.keySet()).stream().filter(Objects::nonNull)
+              .mapToInt(Integer::intValue).sorted().toArray();
+            final int index;
+            if (this.dataStoreMap.containsKey(zoom)) {
+                index = Arrays.binarySearch(keys, zoom);
+            } else {
+                // (-(insertion point) - 1) = return -> insertion point = -(return + 1)
+                index = -(Arrays.binarySearch(keys, zoom) + 1);
+            }
+            if (index > 0) {
+                nearestZoom[1] = keys[index - 1];
+            }
+            if (index < keys.length - 2) {
+                nearestZoom[2] = keys[index + 1];
+            }
+
+            // TODO cleanup zooms for memory
+        }
+    }
+
+    public int getZoom() {
+        return this.zoom;
+    }
+
+    /**
+     * Add tile data to this dataset
+     * @param tile The tile to add
+     */
+    public void addTileData(MVTTile tile) {
+        tryWrite(this.readWriteLock, () -> {
+            final int currentZoom = tile.getZoom();
+            // computeIfAbsent should be thread safe (ConcurrentHashMap indicates it is, anyway)
+            final Storage<MVTTile> dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, tZoom -> new Storage<>());
+            tile.getData().getAllPrimitives().forEach(primitive -> primitive.setDataSet(this));
+            dataStore.add(tile);
+        });
+    }
+
+    /**
+     * Try to read something (here to avoid boilerplate)
+     *
+     * @param supplier The reading function
+     * @param <T>      The return type
+     * @return The optional return
+     */
+    private static <T> Optional<T> tryRead(ReentrantReadWriteLock lock, Supplier<T> supplier) {
+        try {
+            lock.readLock().lockInterruptibly();
+            return Optional.ofNullable(supplier.get());
+        } catch (InterruptedException e) {
+            Logging.error(e);
+            Thread.currentThread().interrupt();
+        } finally {
+            lock.readLock().unlock();
+        }
+        return Optional.empty();
+    }
+
+    /**
+     * Try to write something (here to avoid boilerplate)
+     *
+     * @param runnable The writing function
+     */
+    private static void tryWrite(ReentrantReadWriteLock lock, Runnable runnable) {
+        try {
+            lock.writeLock().lockInterruptibly();
+            runnable.run();
+        } catch (InterruptedException e) {
+            Logging.error(e);
+            Thread.currentThread().interrupt();
+        } finally {
+            if (lock.isWriteLockedByCurrentThread()) {
+                lock.writeLock().unlock();
+            }
+        }
+    }
+
+    /**
+     * Get the styles for this layer
+     *
+     * @return The styles
+     */
+    public ElemStyles getStyles() {
+        return this.styles;
+    }
+
+    /**
+     * Set the styles for this layer
+     * @param styles The styles to set for this layer
+     */
+    public void setStyles(Collection<ElemStyles> styles) {
+        if (styles.size() == 1) {
+            this.styles = styles.iterator().next();
+        } else if (!styles.isEmpty()) {
+            this.styles = new ElemStyles(styles.stream().flatMap(style -> style.getStyleSources().stream()).collect(Collectors.toList()));
+        } else {
+            this.styles = null;
+        }
+    }
+
+    /**
+     * Mark some layers as invisible
+     * @param invisibleLayers The layer to not show
+     */
+    public void setInvisibleLayers(Collection<String> invisibleLayers) {
+        String[] currentInvisibleLayers = invisibleLayers.stream().filter(Objects::nonNull).toArray(String[]::new);
+        List<String> temporaryList = Arrays.asList(currentInvisibleLayers);
+        this.dataStoreMap.values().stream().flatMap(Collection::stream).map(MVTTile::getData)
+          .forEach(dataStore -> dataStore.getAllPrimitives().parallelStream()
+            .forEach(primitive -> primitive.setVisible(!temporaryList.contains(primitive.getLayer()))));
+    }
+
+    @Override
+    public boolean addSelectionListener(IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> listener) {
+        if (!this.listeners.containsListener(listener)) {
+            this.listeners.addListener(listener);
+        }
+        return this.listeners.containsListener(listener);
+    }
+
+    @Override
+    public boolean removeSelectionListener(
+            IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> listener) {
+        if (this.listeners.containsListener(listener)) {
+            this.listeners.removeListener(listener);
+        }
+        return this.listeners.containsListener(listener);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/vector/VectorDataStore.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/vector/VectorDataStore.java	(revision 17862)
@@ -0,0 +1,358 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import org.openstreetmap.gui.jmapviewer.Coordinate;
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.josm.data.IQuadBucketType;
+import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.SimplePrimitiveId;
+import org.openstreetmap.josm.data.osm.UniqueIdGenerator;
+import org.openstreetmap.josm.gui.dialogs.relation.sort.RelationSorter;
+import org.openstreetmap.josm.tools.Destroyable;
+import org.openstreetmap.josm.tools.Geometry;
+import org.openstreetmap.josm.tools.JosmRuntimeException;
+import org.openstreetmap.josm.tools.Logging;
+
+import java.awt.geom.Area;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Path2D;
+import java.awt.geom.PathIterator;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * A data store for Vector Data sets
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay, VectorRelation> implements Destroyable {
+    private static final String JOSM_MERGE_TYPE_KEY = "josm_merge_type";
+    private static final String ORIGINAL_ID = "original_id";
+    private static final String MULTIPOLYGON_TYPE = "multipolygon";
+    private static final String RELATION_TYPE = "type";
+
+    @Override
+    protected void addPrimitive(VectorPrimitive primitive) {
+        // The field is uint64, so we can use negative numbers to indicate that it is a "generated" object (e.g., nodes for ways)
+        if (primitive.getUniqueId() == 0) {
+            final UniqueIdGenerator generator = primitive.getIdGenerator();
+            long id;
+            do {
+                id = generator.generateUniqueId();
+            } while (this.primitivesMap.containsKey(new SimplePrimitiveId(id, primitive.getType())));
+            primitive.setId(primitive.getIdGenerator().generateUniqueId());
+        }
+        if (primitive instanceof VectorRelation && !primitive.isMultipolygon()) {
+            primitive = mergeWays((VectorRelation) primitive);
+        }
+        final VectorPrimitive alreadyAdded = this.primitivesMap.get(primitive.getPrimitiveId());
+        final VectorRelation mergedRelation = (VectorRelation) this.primitivesMap
+          .get(new SimplePrimitiveId(primitive.getPrimitiveId().getUniqueId(),
+            OsmPrimitiveType.RELATION));
+        if (alreadyAdded == null || alreadyAdded.equals(primitive)) {
+            super.addPrimitive(primitive);
+        } else if (mergedRelation != null && mergedRelation.get(JOSM_MERGE_TYPE_KEY) != null) {
+            mergedRelation.addRelationMember(new VectorRelationMember("", primitive));
+            super.addPrimitive(primitive);
+            // Check that all primitives can be merged
+            if (mergedRelation.getMemberPrimitivesList().stream().allMatch(IWay.class::isInstance)) {
+                // This pretty much does the "right" thing
+                this.mergeWays(mergedRelation);
+            } else if (!(primitive instanceof IWay)) {
+                // Can't merge, ever (one of the childs is a node/relation)
+                mergedRelation.remove(JOSM_MERGE_TYPE_KEY);
+            }
+        } else if (mergedRelation != null && primitive instanceof IRelation) {
+            // Just add to the relation
+            ((VectorRelation) primitive).getMembers().forEach(mergedRelation::addRelationMember);
+        } else if (alreadyAdded instanceof VectorWay && primitive instanceof VectorWay) {
+            final VectorRelation temporaryRelation =
+              mergedRelation == null ? new VectorRelation(primitive.getLayer()) : mergedRelation;
+            if (mergedRelation == null) {
+                temporaryRelation.put(JOSM_MERGE_TYPE_KEY, "merge");
+                temporaryRelation.addRelationMember(new VectorRelationMember("", alreadyAdded));
+            }
+            temporaryRelation.addRelationMember(new VectorRelationMember("", primitive));
+            super.addPrimitive(primitive);
+            super.addPrimitive(temporaryRelation);
+        }
+    }
+
+    private VectorPrimitive mergeWays(VectorRelation relation) {
+        List<VectorRelationMember> members = RelationSorter.sortMembersByConnectivity(relation.getMembers());
+        Collection<VectorWay> relationWayList = members.stream().map(VectorRelationMember::getMember)
+          .filter(VectorWay.class::isInstance)
+          .map(VectorWay.class::cast).collect(Collectors.toCollection(ArrayList::new));
+        // Only support way-only relations
+        if (relationWayList.size() != relation.getMemberPrimitivesList().size()) {
+            return relation;
+        }
+        List<VectorWay> wayList = new ArrayList<>(relation.getMembersCount());
+        // Assume that the order may not be correct, worst case O(n), best case O(n/2)
+        // Assume that the ways were drawn in order
+        final int maxIteration = relationWayList.size();
+        int iteration = 0;
+        while (iteration < maxIteration && wayList.size() < relationWayList.size()) {
+            for (VectorWay way : relationWayList) {
+                if (wayList.isEmpty()) {
+                    wayList.add(way);
+                    continue;
+                }
+                // Check first/last ways (last first, since the list *should* be sorted)
+                if (canMergeWays(wayList.get(wayList.size() - 1), way, false)) {
+                    wayList.add(way);
+                } else if (canMergeWays(wayList.get(0), way, false)) {
+                    wayList.add(0, way);
+                }
+            }
+            iteration++;
+            relationWayList.removeIf(wayList::contains);
+        }
+        return relation;
+    }
+
+    private static <N extends INode, W extends IWay<N>> boolean canMergeWays(W old, W toAdd, boolean allowReverse) {
+        final List<N> nodes = new ArrayList<>(old.getNodes());
+        boolean added = true;
+        if (allowReverse && old.firstNode().equals(toAdd.firstNode())) {
+            // old <-|-> new becomes old ->|-> new
+            Collections.reverse(nodes);
+            nodes.addAll(toAdd.getNodes());
+        } else if (old.firstNode().equals(toAdd.lastNode())) {
+            // old <-|<- new, so we prepend the new nodes in order
+            nodes.addAll(0, toAdd.getNodes());
+        } else if (old.lastNode().equals(toAdd.firstNode())) {
+            // old ->|-> new, we just add it
+            nodes.addAll(toAdd.getNodes());
+        } else if (allowReverse && old.lastNode().equals(toAdd.lastNode())) {
+            // old ->|<- new, we need to reverse new
+            final List<N> toAddNodes = new ArrayList<>(toAdd.getNodes());
+            Collections.reverse(toAddNodes);
+            nodes.addAll(toAddNodes);
+        } else {
+            added = false;
+        }
+        if (added) {
+            // This is (technically) always correct
+            old.setNodes(nodes);
+        }
+        return added;
+    }
+
+    private synchronized <T extends Tile & VectorTile> VectorNode pointToNode(T tile, Layer layer,
+      Collection<VectorPrimitive> featureObjects, int x, int y) {
+        final BBox tileBbox;
+        if (tile instanceof IQuadBucketType) {
+            tileBbox = ((IQuadBucketType) tile).getBBox();
+        } else {
+            final ICoordinate upperLeft = tile.getTileSource().tileXYToLatLon(tile);
+            final ICoordinate lowerRight = tile.getTileSource()
+                    .tileXYToLatLon(tile.getXtile() + 1, tile.getYtile() + 1, tile.getZoom());
+
+            tileBbox = new BBox(upperLeft.getLon(), upperLeft.getLat(), lowerRight.getLon(), lowerRight.getLat());
+        }
+        final int layerExtent = layer.getExtent();
+        final ICoordinate coords = new Coordinate(
+                tileBbox.getMaxLat() - (tileBbox.getMaxLat() - tileBbox.getMinLat()) * y / layerExtent,
+                tileBbox.getMinLon() + (tileBbox.getMaxLon() - tileBbox.getMinLon()) * x / layerExtent
+        );
+        final Collection<VectorNode> nodes = this.store
+          .searchNodes(new BBox(coords.getLon(), coords.getLat(), VectorDataSet.DUPE_NODE_DISTANCE));
+        final VectorNode node;
+        if (!nodes.isEmpty()) {
+            final VectorNode first = nodes.iterator().next();
+            if (first.isDisabled() || !first.isVisible()) {
+                // Only replace nodes that are not visible
+                node = new VectorNode(layer.getName());
+                node.setCoor(node.getCoor());
+                first.getReferrers(true).forEach(primitive -> {
+                    if (primitive instanceof VectorWay) {
+                        List<VectorNode> nodeList = new ArrayList<>(((VectorWay) primitive).getNodes());
+                        nodeList.replaceAll(vnode -> vnode.equals(first) ? node : vnode);
+                        ((VectorWay) primitive).setNodes(nodeList);
+                    } else if (primitive instanceof VectorRelation) {
+                        List<VectorRelationMember> members = new ArrayList<>(((VectorRelation) primitive).getMembers());
+                        members.replaceAll(member ->
+                          member.getMember().equals(first) ? new VectorRelationMember(member.getRole(), node) : member);
+                        ((VectorRelation) primitive).setMembers(members);
+                    }
+                });
+                this.removePrimitive(first);
+            } else {
+                node = first;
+            }
+        } else {
+            node = new VectorNode(layer.getName());
+        }
+        node.setCoor(coords);
+        featureObjects.add(node);
+        return node;
+    }
+
+    private <T extends Tile & VectorTile> List<VectorWay> pathToWay(T tile, Layer layer,
+      Collection<VectorPrimitive> featureObjects, Path2D shape) {
+        final PathIterator pathIterator = shape.getPathIterator(null);
+        final List<VectorWay> ways = pathIteratorToObjects(tile, layer, featureObjects, pathIterator).stream()
+          .filter(VectorWay.class::isInstance).map(VectorWay.class::cast).collect(
+            Collectors.toList());
+        // These nodes technically do not exist, so we shouldn't show them
+        ways.stream().flatMap(way -> way.getNodes().stream())
+          .filter(prim -> !prim.isTagged() && prim.getReferrers(true).size() == 1 && prim.getId() <= 0)
+          .forEach(prim -> {
+              prim.setDisabled(true);
+              prim.setVisible(false);
+          });
+        return ways;
+    }
+
+    private <T extends Tile & VectorTile> List<VectorPrimitive> pathIteratorToObjects(T tile, Layer layer,
+      Collection<VectorPrimitive> featureObjects, PathIterator pathIterator) {
+        final List<VectorNode> nodes = new ArrayList<>();
+        final double[] coords = new double[6];
+        final List<VectorPrimitive> ways = new ArrayList<>();
+        do {
+            final int type = pathIterator.currentSegment(coords);
+            pathIterator.next();
+            if ((PathIterator.SEG_MOVETO == type || PathIterator.SEG_CLOSE == type) && !nodes.isEmpty()) {
+                if (PathIterator.SEG_CLOSE == type) {
+                    nodes.add(nodes.get(0));
+                }
+                // New line
+                if (!nodes.isEmpty()) {
+                    final VectorWay way = new VectorWay(layer.getName());
+                    way.setNodes(nodes);
+                    featureObjects.add(way);
+                    ways.add(way);
+                }
+                nodes.clear();
+            }
+            if (PathIterator.SEG_MOVETO == type || PathIterator.SEG_LINETO == type) {
+                final VectorNode node = pointToNode(tile, layer, featureObjects, (int) coords[0], (int) coords[1]);
+                nodes.add(node);
+            } else if (PathIterator.SEG_CLOSE != type) {
+                // Vector Tiles only have MoveTo, LineTo, and ClosePath. Anything else is not supported at this time.
+                throw new UnsupportedOperationException();
+            }
+        } while (!pathIterator.isDone());
+        if (!nodes.isEmpty()) {
+            final VectorWay way = new VectorWay(layer.getName());
+            way.setNodes(nodes);
+            featureObjects.add(way);
+            ways.add(way);
+        }
+        return ways;
+    }
+
+    private <T extends Tile & VectorTile> VectorRelation areaToRelation(T tile, Layer layer,
+      Collection<VectorPrimitive> featureObjects, Area area) {
+        final PathIterator pathIterator = area.getPathIterator(null);
+        final List<VectorPrimitive> members = pathIteratorToObjects(tile, layer, featureObjects, pathIterator);
+        VectorRelation vectorRelation = new VectorRelation(layer.getName());
+        for (VectorPrimitive member : members) {
+            final String role;
+            if (member instanceof VectorWay && ((VectorWay) member).isClosed()) {
+                role = Geometry.isClockwise(((VectorWay) member).getNodes()) ? "outer" : "inner";
+            } else {
+                role = "";
+            }
+            vectorRelation.addRelationMember(new VectorRelationMember(role, member));
+        }
+        return vectorRelation;
+    }
+
+    /**
+     * Add the information from a tile to this object
+     * @param tile The tile to add
+     * @param <T> The tile type
+     */
+    public <T extends Tile & VectorTile> void addDataTile(T tile) {
+        for (Layer layer : tile.getLayers()) {
+            layer.getFeatures().forEach(feature -> {
+                org.openstreetmap.josm.data.imagery.vectortile.mapbox.Geometry geometry = feature
+                  .getGeometryObject();
+                List<VectorPrimitive> featureObjects = new ArrayList<>();
+                List<VectorPrimitive> primaryFeatureObjects = new ArrayList<>();
+                geometry.getShapes().forEach(shape -> {
+                    final VectorPrimitive primitive;
+                    if (shape instanceof Ellipse2D) {
+                        primitive = pointToNode(tile, layer, featureObjects,
+                          (int) ((Ellipse2D) shape).getCenterX(), (int) ((Ellipse2D) shape).getCenterY());
+                    } else if (shape instanceof Path2D) {
+                        primitive = pathToWay(tile, layer, featureObjects, (Path2D) shape).stream().findFirst()
+                          .orElse(null);
+                    } else if (shape instanceof Area) {
+                        primitive = areaToRelation(tile, layer, featureObjects, (Area) shape);
+                        primitive.put(RELATION_TYPE, MULTIPOLYGON_TYPE);
+                    } else {
+                        // We shouldn't hit this, but just in case
+                        throw new UnsupportedOperationException();
+                    }
+                    primaryFeatureObjects.add(primitive);
+                });
+                final VectorPrimitive primitive;
+                if (primaryFeatureObjects.size() == 1) {
+                    primitive = primaryFeatureObjects.get(0);
+                    if (primitive instanceof IRelation && !primitive.isMultipolygon()) {
+                        primitive.put(JOSM_MERGE_TYPE_KEY, "merge");
+                    }
+                } else if (!primaryFeatureObjects.isEmpty()) {
+                    VectorRelation relation = new VectorRelation(layer.getName());
+                    primaryFeatureObjects.stream().map(prim -> new VectorRelationMember("", prim))
+                      .forEach(relation::addRelationMember);
+                    primitive = relation;
+                } else {
+                    return;
+                }
+                primitive.setId(feature.getId());
+                // Version 1 <i>and</i> 2 <i>do not guarantee</i> that non-zero ids are unique
+                // We depend upon unique ids in the data store
+                if (feature.getId() != 0 && this.primitivesMap.containsKey(primitive.getPrimitiveId())) {
+                    // This, unfortunately, makes a new string
+                    primitive.put(ORIGINAL_ID, Long.toString(feature.getId()));
+                    primitive.setId(primitive.getIdGenerator().generateUniqueId());
+                }
+                if (feature.getTags() != null) {
+                    feature.getTags().forEach(primitive::put);
+                }
+                featureObjects.forEach(this::addPrimitive);
+                primaryFeatureObjects.forEach(this::addPrimitive);
+                try {
+                    this.addPrimitive(primitive);
+                } catch (JosmRuntimeException e) {
+                    Logging.error("{0}/{1}/{2}: {3}", tile.getZoom(), tile.getXtile(), tile.getYtile(), primitive.get("key"));
+                    throw e;
+                }
+            });
+        }
+        // Replace original_ids with the same object (reduce memory usage)
+        // Strings aren't interned automatically in some GC implementations
+        Collection<IPrimitive> primitives = this.getAllPrimitives().stream().filter(p -> p.hasKey(ORIGINAL_ID))
+                .collect(Collectors.toList());
+        List<String> toReplace = primitives.stream().map(p -> p.get(ORIGINAL_ID)).filter(Objects::nonNull).collect(Collectors.toList());
+        primitives.stream().filter(p -> toReplace.contains(p.get(ORIGINAL_ID)))
+                .forEach(p -> p.put(ORIGINAL_ID, toReplace.stream().filter(shared -> shared.equals(p.get(ORIGINAL_ID)))
+                        .findAny().orElse(null)));
+    }
+
+    @Override
+    public void destroy() {
+        this.addedTiles.forEach(tile -> tile.setLoaded(false));
+        this.addedTiles.forEach(tile -> tile.setImage(null));
+        this.addedTiles.clear();
+        this.store.clear();
+        this.allPrimitives.clear();
+        this.primitivesMap.clear();
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/vector/VectorNode.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/vector/VectorNode.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/vector/VectorNode.java	(revision 17862)
@@ -0,0 +1,113 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.List;
+
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.UniqueIdGenerator;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+
+/**
+ * The "Node" type of a vector layer
+ *
+ * @since xxx
+ */
+public class VectorNode extends VectorPrimitive implements INode {
+    private static final UniqueIdGenerator ID_GENERATOR = new UniqueIdGenerator();
+    private double lon = Double.NaN;
+    private double lat = Double.NaN;
+
+    /**
+     * Create a new vector node
+     * @param layer The layer for the vector node
+     */
+    public VectorNode(String layer) {
+        super(layer);
+    }
+
+    @Override
+    public double lon() {
+        return this.lon;
+    }
+
+    @Override
+    public double lat() {
+        return this.lat;
+    }
+
+    @Override
+    public UniqueIdGenerator getIdGenerator() {
+        return ID_GENERATOR;
+    }
+
+    @Override
+    public LatLon getCoor() {
+        return new LatLon(this.lat, this.lon);
+    }
+
+    @Override
+    public void setCoor(LatLon coordinates) {
+        this.lat = coordinates.lat();
+        this.lon = coordinates.lon();
+    }
+
+    /**
+     * Set the coordinates of this node
+     *
+     * @param coordinates The coordinates to set
+     * @see #setCoor(LatLon)
+     */
+    public void setCoor(ICoordinate coordinates) {
+        this.lat = coordinates.getLat();
+        this.lon = coordinates.getLon();
+    }
+
+    @Override
+    public void setEastNorth(EastNorth eastNorth) {
+        final LatLon ll = ProjectionRegistry.getProjection().eastNorth2latlon(eastNorth);
+        this.lat = ll.lat();
+        this.lon = ll.lon();
+    }
+
+    @Override
+    public boolean isReferredByWays(int n) {
+        // Count only referrers that are members of the same dataset (primitive can have some fake references, for example
+        // when way is cloned
+        List<? extends IPrimitive> referrers = super.getReferrers();
+        if (referrers == null || referrers.isEmpty())
+            return false;
+        if (referrers instanceof IPrimitive)
+            return n <= 1 && referrers instanceof IWay && ((IPrimitive) referrers).getDataSet() == getDataSet();
+        else {
+            int counter = 0;
+            for (IPrimitive o : referrers) {
+                if (getDataSet() == o.getDataSet() && o instanceof IWay && ++counter >= n)
+                    return true;
+            }
+            return false;
+        }
+    }
+
+    @Override
+    public void accept(PrimitiveVisitor visitor) {
+        visitor.visit(this);
+    }
+
+    @Override
+    public BBox getBBox() {
+        return new BBox(this.lon, this.lat).toImmutable();
+    }
+
+    @Override
+    public OsmPrimitiveType getType() {
+        return OsmPrimitiveType.NODE;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java	(revision 17862)
@@ -0,0 +1,260 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.openstreetmap.josm.data.osm.AbstractPrimitive;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+import org.openstreetmap.josm.gui.mappaint.StyleCache;
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * The base class for Vector primitives
+ * @author Taylor Smock
+ * @since xxx
+ */
+public abstract class VectorPrimitive extends AbstractPrimitive implements DataLayer<String> {
+    private VectorDataSet dataSet;
+    private boolean highlighted;
+    private StyleCache mappaintStyle;
+    private final String layer;
+
+    /**
+     * Create a primitive for a specific vector layer
+     * @param layer The layer for the primitive
+     */
+    protected VectorPrimitive(String layer) {
+        this.layer = layer;
+        this.id = getIdGenerator().generateUniqueId();
+    }
+
+    @Override
+    protected void keysChangedImpl(Map<String, String> originalKeys) {
+        clearCachedStyle();
+        if (dataSet != null) {
+            for (IPrimitive ref : getReferrers()) {
+                ref.clearCachedStyle();
+            }
+        }
+    }
+
+    @Override
+    public boolean isHighlighted() {
+        return this.highlighted;
+    }
+
+    @Override
+    public void setHighlighted(boolean highlighted) {
+        this.highlighted = highlighted;
+    }
+
+    @Override
+    public boolean isTagged() {
+        return !this.getInterestingTags().isEmpty();
+    }
+
+    @Override
+    public boolean isAnnotated() {
+        return this.getInterestingTags().size() - this.getKeys().size() > 0;
+    }
+
+    @Override
+    public VectorDataSet getDataSet() {
+        return dataSet;
+    }
+
+    protected void setDataSet(VectorDataSet newDataSet) {
+        dataSet = newDataSet;
+    }
+
+    /*----------
+     * MAPPAINT
+     *--------*/
+
+    @Override
+    public final StyleCache getCachedStyle() {
+        return mappaintStyle;
+    }
+
+    @Override
+    public final void setCachedStyle(StyleCache mappaintStyle) {
+        this.mappaintStyle = mappaintStyle;
+    }
+
+    @Override
+    public final boolean isCachedStyleUpToDate() {
+        return mappaintStyle != null && mappaintCacheIdx == dataSet.getMappaintCacheIndex();
+    }
+
+    @Override
+    public final void declareCachedStyleUpToDate() {
+        this.mappaintCacheIdx = dataSet.getMappaintCacheIndex();
+    }
+
+    @Override
+    public boolean hasDirectionKeys() {
+        return false;
+    }
+
+    @Override
+    public boolean reversedDirection() {
+        return false;
+    }
+
+    /*------------
+     * Referrers
+     ------------*/
+    // Largely the same as OsmPrimitive, OsmPrimitive not modified at this time to avoid breaking binary compatibility
+
+    private Object referrers;
+
+    @Override
+    public final List<VectorPrimitive> getReferrers(boolean allowWithoutDataset) {
+        return referrers(allowWithoutDataset, VectorPrimitive.class)
+          .collect(Collectors.toList());
+    }
+
+    /**
+     * Add new referrer. If referrer is already included then no action is taken
+     * @param referrer The referrer to add
+     */
+    protected void addReferrer(IPrimitive referrer) {
+        if (referrers == null) {
+            referrers = referrer;
+        } else if (referrers instanceof IPrimitive) {
+            if (referrers != referrer) {
+                referrers = new IPrimitive[] {(IPrimitive) referrers, referrer};
+            }
+        } else {
+            for (IPrimitive primitive:(IPrimitive[]) referrers) {
+                if (primitive == referrer)
+                    return;
+            }
+            referrers = Utils.addInArrayCopy((IPrimitive[]) referrers, referrer);
+        }
+    }
+
+    /**
+     * Remove referrer. No action is taken if referrer is not registered
+     * @param referrer The referrer to remove
+     */
+    protected void removeReferrer(IPrimitive referrer) {
+        if (referrers instanceof IPrimitive) {
+            if (referrers == referrer) {
+                referrers = null;
+            }
+        } else if (referrers instanceof IPrimitive[]) {
+            IPrimitive[] orig = (IPrimitive[]) referrers;
+            int idx = IntStream.range(0, orig.length)
+              .filter(i -> orig[i] == referrer)
+              .findFirst().orElse(-1);
+            if (idx == -1)
+                return;
+
+            if (orig.length == 2) {
+                referrers = orig[1-idx]; // idx is either 0 or 1, take the other
+            } else { // downsize the array
+                IPrimitive[] smaller = new IPrimitive[orig.length-1];
+                System.arraycopy(orig, 0, smaller, 0, idx);
+                System.arraycopy(orig, idx+1, smaller, idx, smaller.length-idx);
+                referrers = smaller;
+            }
+        }
+    }
+
+    private <T extends IPrimitive> Stream<T> referrers(boolean allowWithoutDataset, Class<T> filter) {
+        // Returns only referrers that are members of the same dataset (primitive can have some fake references, for example
+        // when way is cloned
+
+        if (dataSet == null && !allowWithoutDataset) {
+            return Stream.empty();
+        }
+        if (referrers == null) {
+            return Stream.empty();
+        }
+        final Stream<IPrimitive> stream = referrers instanceof IPrimitive // NOPMD
+          ? Stream.of((IPrimitive) referrers)
+          : Arrays.stream((IPrimitive[]) referrers);
+        return stream
+          .filter(p -> p.getDataSet() == dataSet)
+          .filter(filter::isInstance)
+          .map(filter::cast);
+    }
+
+    /**
+     * Gets all primitives in the current dataset that reference this primitive.
+     * @param filter restrict primitives to subclasses
+     * @param <T> type of primitives
+     * @return the referrers as Stream
+     */
+    public final <T extends IPrimitive> Stream<T> referrers(Class<T> filter) {
+        return referrers(false, filter);
+    }
+
+    @Override
+    public void visitReferrers(PrimitiveVisitor visitor) {
+        if (visitor != null)
+            doVisitReferrers(o -> o.accept(visitor));
+    }
+
+    private void doVisitReferrers(Consumer<IPrimitive> visitor) {
+        if (this.referrers instanceof IPrimitive) {
+            IPrimitive ref = (IPrimitive) this.referrers;
+            if (ref.getDataSet() == dataSet) {
+                visitor.accept(ref);
+            }
+        } else if (this.referrers instanceof IPrimitive[]) {
+            IPrimitive[] refs = (IPrimitive[]) this.referrers;
+            for (IPrimitive ref: refs) {
+                if (ref.getDataSet() == dataSet) {
+                    visitor.accept(ref);
+                }
+            }
+        }
+    }
+
+    /**
+     * Set the id of the object
+     * @param id The id
+     */
+    protected void setId(long id) {
+        this.id = id;
+    }
+
+    /**
+     * Make this object disabled
+     * @param disabled {@code true} to disable the object
+     */
+    public void setDisabled(boolean disabled) {
+        this.updateFlags(FLAG_DISABLED, disabled);
+    }
+
+    /**
+     * Make this object visible
+     * @param visible {@code true} to make this object visible (default)
+     */
+    @Override
+    public void setVisible(boolean visible) {
+        this.updateFlags(FLAG_VISIBLE, visible);
+    }
+
+    /**************************
+     * Data layer information *
+     **************************/
+    @Override
+    public String getLayer() {
+        return this.layer;
+    }
+
+    @Override
+    public boolean isDrawable() {
+        return super.isDrawable() && this.isVisible();
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/vector/VectorRelation.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/vector/VectorRelation.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/vector/VectorRelation.java	(revision 17862)
@@ -0,0 +1,115 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.UniqueIdGenerator;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+
+/**
+ * The "Relation" type for vectors
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class VectorRelation extends VectorPrimitive implements IRelation<VectorRelationMember> {
+    private static final UniqueIdGenerator RELATION_ID_GENERATOR = new UniqueIdGenerator();
+    private final List<VectorRelationMember> members = new ArrayList<>();
+    private BBox cachedBBox;
+
+    /**
+     * Create a new relation for a layer
+     * @param layer The layer the relation will belong to
+     */
+    public VectorRelation(String layer) {
+        super(layer);
+    }
+
+    @Override
+    public UniqueIdGenerator getIdGenerator() {
+        return RELATION_ID_GENERATOR;
+    }
+
+    @Override
+    public void accept(PrimitiveVisitor visitor) {
+        visitor.visit(this);
+    }
+
+    @Override
+    public BBox getBBox() {
+        if (this.cachedBBox == null) {
+            BBox tBBox = new BBox();
+            for (IPrimitive member : this.getMemberPrimitivesList()) {
+                tBBox.add(member.getBBox());
+            }
+            this.cachedBBox = tBBox.toImmutable();
+        }
+        return this.cachedBBox;
+    }
+
+    protected void addRelationMember(VectorRelationMember member) {
+        this.members.add(member);
+        member.getMember().addReferrer(this);
+        cachedBBox = null;
+    }
+
+    /**
+     * Remove the first instance of a member from the relation
+     *
+     * @param member The member to remove
+     */
+    protected void removeRelationMember(VectorRelationMember member) {
+        this.members.remove(member);
+        if (!this.members.contains(member)) {
+            member.getMember().removeReferrer(this);
+        }
+    }
+
+    @Override
+    public int getMembersCount() {
+        return this.members.size();
+    }
+
+    @Override
+    public VectorRelationMember getMember(int index) {
+        return this.members.get(index);
+    }
+
+    @Override
+    public List<VectorRelationMember> getMembers() {
+        return Collections.unmodifiableList(this.members);
+    }
+
+    @Override
+    public void setMembers(List<VectorRelationMember> members) {
+        this.members.clear();
+        this.members.addAll(members);
+    }
+
+    @Override
+    public long getMemberId(int idx) {
+        return this.getMember(idx).getMember().getId();
+    }
+
+    @Override
+    public String getRole(int idx) {
+        return this.getMember(idx).getRole();
+    }
+
+    @Override
+    public OsmPrimitiveType getMemberType(int idx) {
+        return this.getMember(idx).getType();
+    }
+
+    @Override
+    public OsmPrimitiveType getType() {
+        return this.getMembers().stream().map(VectorRelationMember::getType)
+          .allMatch(OsmPrimitiveType.CLOSEDWAY::equals) ? OsmPrimitiveType.MULTIPOLYGON : OsmPrimitiveType.RELATION;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/vector/VectorRelationMember.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/vector/VectorRelationMember.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/vector/VectorRelationMember.java	(revision 17862)
@@ -0,0 +1,70 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.Optional;
+
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IRelationMember;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.tools.CheckParameterUtil;
+
+/**
+ * Relation members for a Vector Relation
+ */
+public class VectorRelationMember implements IRelationMember<VectorPrimitive> {
+    private final String role;
+    private final VectorPrimitive member;
+
+    /**
+     * Create a new relation member
+     * @param role The role of the member
+     * @param member The member primitive
+     */
+    public VectorRelationMember(String role, VectorPrimitive member) {
+        CheckParameterUtil.ensureParameterNotNull(member, "member");
+        this.role = Optional.ofNullable(role).orElse("").intern();
+        this.member = member;
+    }
+
+    @Override
+    public String getRole() {
+        return this.role;
+    }
+
+    @Override
+    public boolean isNode() {
+        return this.member instanceof INode;
+    }
+
+    @Override
+    public boolean isWay() {
+        return this.member instanceof IWay;
+    }
+
+    @Override
+    public boolean isRelation() {
+        return this.member instanceof IRelation;
+    }
+
+    @Override
+    public VectorPrimitive getMember() {
+        return this.member;
+    }
+
+    @Override
+    public long getUniqueId() {
+        return this.member.getId();
+    }
+
+    @Override
+    public OsmPrimitiveType getType() {
+        return this.member.getType();
+    }
+
+    @Override
+    public boolean isNew() {
+        return this.member.isNew();
+    }
+}
Index: trunk/src/org/openstreetmap/josm/data/vector/VectorWay.java
===================================================================
--- trunk/src/org/openstreetmap/josm/data/vector/VectorWay.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/data/vector/VectorWay.java	(revision 17862)
@@ -0,0 +1,133 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.UniqueIdGenerator;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+
+/**
+ * The "Way" type for a Vector layer
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class VectorWay extends VectorPrimitive implements IWay<VectorNode> {
+    private static final UniqueIdGenerator WAY_GENERATOR = new UniqueIdGenerator();
+    private final List<VectorNode> nodes = new ArrayList<>();
+    private BBox cachedBBox;
+
+    /**
+     * Create a new way for a layer
+     * @param layer The layer for the way
+     */
+    public VectorWay(String layer) {
+        super(layer);
+    }
+
+    @Override
+    public UniqueIdGenerator getIdGenerator() {
+        return WAY_GENERATOR;
+    }
+
+    @Override
+    public void accept(PrimitiveVisitor visitor) {
+        visitor.visit(this);
+    }
+
+    @Override
+    public BBox getBBox() {
+        if (this.cachedBBox == null) {
+            BBox tBBox = new BBox();
+            for (INode node : this.getNodes()) {
+                tBBox.add(node.getBBox());
+            }
+            this.cachedBBox = tBBox.toImmutable();
+        }
+        return this.cachedBBox;
+    }
+
+    @Override
+    public int getNodesCount() {
+        return this.getNodes().size();
+    }
+
+    @Override
+    public VectorNode getNode(int index) {
+        return this.getNodes().get(index);
+    }
+
+    @Override
+    public List<VectorNode> getNodes() {
+        return Collections.unmodifiableList(this.nodes);
+    }
+
+    @Override
+    public void setNodes(List<VectorNode> nodes) {
+        this.nodes.forEach(node -> node.removeReferrer(this));
+        this.nodes.clear();
+        nodes.forEach(node -> node.addReferrer(this));
+        this.nodes.addAll(nodes);
+        this.cachedBBox = null;
+    }
+
+    @Override
+    public List<Long> getNodeIds() {
+        return this.getNodes().stream().map(VectorNode::getId).collect(Collectors.toList());
+    }
+
+    @Override
+    public long getNodeId(int idx) {
+        return this.getNodes().get(idx).getId();
+    }
+
+    @Override
+    public boolean isClosed() {
+        return this.firstNode() != null && this.firstNode().equals(this.lastNode());
+    }
+
+    @Override
+    public VectorNode firstNode() {
+        if (this.nodes.isEmpty()) {
+            return null;
+        }
+        return this.getNode(0);
+    }
+
+    @Override
+    public VectorNode lastNode() {
+        if (this.nodes.isEmpty()) {
+            return null;
+        }
+        return this.getNode(this.getNodesCount() - 1);
+    }
+
+    @Override
+    public boolean isFirstLastNode(INode n) {
+        if (this.nodes.isEmpty()) {
+            return false;
+        }
+        return this.firstNode().equals(n) || this.lastNode().equals(n);
+    }
+
+    @Override
+    public boolean isInnerNode(INode n) {
+        if (this.nodes.isEmpty()) {
+            return false;
+        }
+        return !this.firstNode().equals(n) && !this.lastNode().equals(n) && this.nodes.stream()
+          .anyMatch(vectorNode -> vectorNode.equals(n));
+    }
+
+    @Override
+    public OsmPrimitiveType getType() {
+        return this.isClosed() ? OsmPrimitiveType.CLOSEDWAY : OsmPrimitiveType.WAY;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationNodeMap.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationNodeMap.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationNodeMap.java	(revision 17862)
@@ -11,7 +11,8 @@
 import java.util.TreeSet;
 
-import org.openstreetmap.josm.data.osm.Node;
-import org.openstreetmap.josm.data.osm.RelationMember;
-import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelationMember;
+import org.openstreetmap.josm.data.osm.IWay;
 
 /**
@@ -27,13 +28,14 @@
  *
  * @author Christiaan Welvaart &lt;cjw@time4t.net&gt;
- * @since 1785
+ * @param <T> The type of {@link IRelationMember}
+ * @since 1785, xxx (generics)
  */
-public class RelationNodeMap {
+public class RelationNodeMap<T extends IRelationMember<? extends IPrimitive>> {
 
     private static final String ROLE_BACKWARD = "backward";
 
     private static class NodesWays {
-        public final Map<Node, Set<Integer>> nodes = new TreeMap<>();
-        public final Map<Integer, Set<Node>> ways = new TreeMap<>();
+        public final Map<INode, Set<Integer>> nodes = new TreeMap<>();
+        public final Map<Integer, Set<INode>> ways = new TreeMap<>();
         public final boolean oneWay;
 
@@ -57,5 +59,5 @@
      */
     private final Set<Integer> remaining = new TreeSet<>();
-    private final Map<Integer, Set<Node>> remainingOneway = new TreeMap<>();
+    private final Map<Integer, Set<INode>> remainingOneway = new TreeMap<>();
 
     /**
@@ -68,6 +70,7 @@
      * @param m The relation member.
      * @return <code>null</code> if the member is no way, the node otherwise.
-     */
-    public static Node firstOnewayNode(RelationMember m) {
+     * @since xxx (generics)
+     */
+    public static INode firstOnewayNode(IRelationMember<?> m) {
         if (!m.isWay()) return null;
         if (ROLE_BACKWARD.equals(m.getRole())) {
@@ -82,5 +85,5 @@
      * @return <code>null</code> if the member is no way, the node otherwise.
      */
-    public static Node lastOnewayNode(RelationMember m) {
+    public static INode lastOnewayNode(IRelationMember<?> m) {
         if (!m.isWay()) return null;
         if (ROLE_BACKWARD.equals(m.getRole())) {
@@ -90,7 +93,7 @@
     }
 
-    RelationNodeMap(List<RelationMember> members) {
+    RelationNodeMap(List<T> members) {
         for (int i = 0; i < members.size(); ++i) {
-            RelationMember m = members.get(i);
+            T m = members.get(i);
             if (m.getMember().isIncomplete() || !m.isWay() || m.getWay().getNodesCount() < 2) {
                 notSortable.add(i);
@@ -98,7 +101,7 @@
             }
 
-            Way w = m.getWay();
+            IWay<?> w = m.getWay();
             if (RelationSortUtils.roundaboutType(w) != NONE) {
-                for (Node nd : w.getNodes()) {
+                for (INode nd : w.getNodes()) {
                     addPair(nd, i);
                 }
@@ -119,32 +122,32 @@
     }
 
-    private void addPair(Node n, int i) {
+    private void addPair(INode n, int i) {
         map.nodes.computeIfAbsent(n, k -> new TreeSet<>()).add(i);
         map.ways.computeIfAbsent(i, k -> new TreeSet<>()).add(n);
     }
 
-    private void addNodeWayMap(Node n, int i) {
+    private void addNodeWayMap(INode n, int i) {
         onewayMap.nodes.computeIfAbsent(n, k -> new TreeSet<>()).add(i);
     }
 
-    private void addWayNodeMap(Node n, int i) {
+    private void addWayNodeMap(INode n, int i) {
         onewayMap.ways.computeIfAbsent(i, k -> new TreeSet<>()).add(n);
     }
 
-    private void addNodeWayMapReverse(Node n, int i) {
+    private void addNodeWayMapReverse(INode n, int i) {
         onewayReverseMap.nodes.computeIfAbsent(n, k -> new TreeSet<>()).add(i);
     }
 
-    private void addWayNodeMapReverse(Node n, int i) {
+    private void addWayNodeMapReverse(INode n, int i) {
         onewayReverseMap.ways.computeIfAbsent(i, k -> new TreeSet<>()).add(n);
     }
 
-    private void addRemainingForward(Node n, int i) {
+    private void addRemainingForward(INode n, int i) {
         remainingOneway.computeIfAbsent(i, k -> new TreeSet<>()).add(n);
     }
 
     private Integer firstOneway;
-    private Node lastOnewayNode;
-    private Node firstCircular;
+    private INode lastOnewayNode;
+    private INode firstCircular;
 
     /**
@@ -159,5 +162,5 @@
 
         if (map.ways.containsKey(way)) {
-            for (Node n : map.ways.get(way)) {
+            for (INode n : map.ways.get(way)) {
                 Integer i = deleteAndGetAdjacentNode(map, n);
                 if (i != null) return i;
@@ -177,5 +180,5 @@
     private Integer popForwardOnewayPart(Integer way) {
         if (onewayMap.ways.containsKey(way)) {
-            Node exitNode = onewayMap.ways.get(way).iterator().next();
+            INode exitNode = onewayMap.ways.get(way).iterator().next();
 
             if (checkIfEndOfLoopReached(exitNode)) {
@@ -202,5 +205,5 @@
     // an outgoing bidirectional or multiple outgoing oneways, or we
     // looped back to our first circular node)
-    private boolean checkIfEndOfLoopReached(Node n) {
+    private boolean checkIfEndOfLoopReached(INode n) {
         return map.nodes.containsKey(n)
                 || (onewayMap.nodes.containsKey(n) && (onewayMap.nodes.get(n).size() > 1))
@@ -210,5 +213,5 @@
     private Integer popBackwardOnewayPart(int way) {
         if (lastOnewayNode != null) {
-            Set<Node> nodes = new TreeSet<>();
+            Set<INode> nodes = new TreeSet<>();
             if (onewayReverseMap.ways.containsKey(way)) {
                 nodes.addAll(onewayReverseMap.ways.get(way));
@@ -217,5 +220,5 @@
                 nodes.addAll(map.ways.get(way));
             }
-            for (Node n : nodes) {
+            for (INode n : nodes) {
                 if (n == lastOnewayNode) { //if oneway part ends
                     firstOneway = null;
@@ -248,5 +251,5 @@
      * @return node next to n
      */
-    private Integer deleteAndGetAdjacentNode(NodesWays nw, Node n) {
+    private Integer deleteAndGetAdjacentNode(NodesWays nw, INode n) {
         Integer j = findAdjacentWay(nw, n);
         if (j == null) return null;
@@ -255,5 +258,5 @@
     }
 
-    private static Integer findAdjacentWay(NodesWays nw, Node n) {
+    private static Integer findAdjacentWay(NodesWays nw, INode n) {
         Set<Integer> adj = nw.nodes.get(n);
         if (adj == null || adj.isEmpty()) return null;
@@ -261,5 +264,5 @@
     }
 
-    private void deleteWayNode(NodesWays nw, Integer way, Node n) {
+    private void deleteWayNode(NodesWays nw, Integer way, INode n) {
         if (nw.oneWay) {
             doneOneway(way);
@@ -286,5 +289,5 @@
         if (remainingOneway.isEmpty()) return null;
         for (Integer i : remainingOneway.keySet()) { //find oneway, which is connected to more than one way (is between two oneway loops)
-            for (Node n : onewayReverseMap.ways.get(i)) {
+            for (INode n : onewayReverseMap.ways.get(i)) {
                 if (onewayReverseMap.nodes.containsKey(n) && onewayReverseMap.nodes.get(n).size() > 1) {
                     doneOneway(i);
@@ -306,6 +309,6 @@
      */
     private void doneOneway(Integer i) {
-        Set<Node> nodesForward = remainingOneway.get(i);
-        for (Node n : nodesForward) {
+        Set<INode> nodesForward = remainingOneway.get(i);
+        for (INode n : nodesForward) {
             if (onewayMap.nodes.containsKey(n)) {
                 onewayMap.nodes.get(n).remove(i);
@@ -320,6 +323,6 @@
     private void done(Integer i) {
         remaining.remove(i);
-        Set<Node> nodes = map.ways.get(i);
-        for (Node n : nodes) {
+        Set<INode> nodes = map.ways.get(i);
+        for (INode n : nodes) {
             boolean result = map.nodes.get(n).remove(i);
             if (!result) throw new AssertionError();
Index: trunk/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSortUtils.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSortUtils.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSortUtils.java	(revision 17862)
@@ -7,7 +7,7 @@
 
 import org.openstreetmap.josm.data.coor.EastNorth;
-import org.openstreetmap.josm.data.osm.Node;
-import org.openstreetmap.josm.data.osm.RelationMember;
-import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IRelationMember;
+import org.openstreetmap.josm.data.osm.IWay;
 import org.openstreetmap.josm.gui.dialogs.relation.sort.WayConnectionType.Direction;
 
@@ -25,17 +25,25 @@
      * @param member relation member
      * @return roundabout type
+     * @since xxx (generics)
      */
-    static Direction roundaboutType(RelationMember member) {
+    static Direction roundaboutType(IRelationMember<?> member) {
         if (member == null || !member.isWay()) return NONE;
-        return roundaboutType(member.getWay());
+        return roundaboutType((IWay<?>) member.getWay());
     }
 
-    static Direction roundaboutType(Way w) {
+    /**
+     * Check if a way is a roundabout type
+     * @param w The way to check
+     * @param <W> The way type
+     * @return The roundabout type
+     * @since xxx (generics)
+     */
+    static <W extends IWay<?>> Direction roundaboutType(W w) {
         if (w != null && w.hasTag("junction", "circular", "roundabout")) {
             int nodesCount = w.getNodesCount();
             if (nodesCount > 2 && nodesCount < 200) {
-                Node n1 = w.getNode(0);
-                Node n2 = w.getNode(1);
-                Node n3 = w.getNode(2);
+                INode n1 = w.getNode(0);
+                INode n2 = w.getNode(1);
+                INode n3 = w.getNode(2);
                 if (n1 != null && n2 != null && n3 != null && w.isClosed()) {
                     /** do some simple determinant / cross product test on the first 3 nodes
@@ -55,13 +63,13 @@
     }
 
-    static boolean isBackward(final RelationMember member) {
+    static boolean isBackward(final IRelationMember<?> member) {
         return "backward".equals(member.getRole());
     }
 
-    static boolean isForward(final RelationMember member) {
+    static boolean isForward(final IRelationMember<?> member) {
         return "forward".equals(member.getRole());
     }
 
-    static boolean isOneway(final RelationMember member) {
+    static boolean isOneway(final IRelationMember<?> member) {
         return isForward(member) || isBackward(member);
     }
Index: trunk/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSorter.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSorter.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSorter.java	(revision 17862)
@@ -16,4 +16,6 @@
 
 import org.openstreetmap.josm.data.osm.DefaultNameFormatter;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelationMember;
 import org.openstreetmap.josm.data.osm.OsmPrimitive;
 import org.openstreetmap.josm.data.osm.Relation;
@@ -195,10 +197,10 @@
      * @param defaultMembers The members to sort
      * @return A sorted list of the same members
-     */
-    public static List<RelationMember> sortMembersByConnectivity(List<RelationMember> defaultMembers) {
-
-        List<RelationMember> newMembers;
-
-        RelationNodeMap map = new RelationNodeMap(defaultMembers);
+     * @since xxx (signature change, generics)
+     */
+    public static <T extends IRelationMember<? extends IPrimitive>> List<T> sortMembersByConnectivity(List<T> defaultMembers) {
+        List<T> newMembers;
+
+        RelationNodeMap<T> map = new RelationNodeMap<>(defaultMembers);
         // List of groups of linked members
         //
Index: trunk/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java	(revision 17862)
@@ -87,4 +87,5 @@
 import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
 import org.openstreetmap.josm.data.imagery.TileLoaderFactory;
+import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
 import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
 import org.openstreetmap.josm.data.preferences.BooleanProperty;
@@ -110,4 +111,5 @@
 import org.openstreetmap.josm.gui.layer.imagery.LoadAllTilesAction;
 import org.openstreetmap.josm.gui.layer.imagery.LoadErroneousTilesAction;
+import org.openstreetmap.josm.gui.layer.imagery.MVTLayer;
 import org.openstreetmap.josm.gui.layer.imagery.ReprojectionTile;
 import org.openstreetmap.josm.gui.layer.imagery.ShowErrorsAction;
@@ -889,5 +891,5 @@
                 tile = new ReprojectionTile(tileSource, x, y, zoom);
             } else {
-                tile = new Tile(tileSource, x, y, zoom);
+                tile = createTile(tileSource, x, y, zoom);
             }
             tileCache.addTile(tile);
@@ -1042,5 +1044,5 @@
                     anchorImage = getAnchor(tile, img);
                 }
-                if (img == null || anchorImage == null) {
+                if (img == null || anchorImage == null || (tile instanceof VectorTile && !tile.isLoaded())) {
                     miss = true;
                 }
@@ -1051,5 +1053,7 @@
             }
 
-            img = applyImageProcessors(img);
+            if (img != null) {
+                img = applyImageProcessors(img);
+            }
 
             TileAnchor anchorScreen = coordinateConverter.getScreenAnchorForTile(tile);
@@ -1863,5 +1867,5 @@
                 for (int x = minX; x <= maxX; x++) {
                     for (int y = minY; y <= maxY; y++) {
-                        requestedTiles.add(new Tile(tileSource, x, y, currentZoomLevel));
+                        requestedTiles.add(createTile(tileSource, x, y, currentZoomLevel));
                     }
                 }
@@ -1969,4 +1973,18 @@
     }
 
+    /**
+     * Create a new tile. Added to allow use of custom {@link Tile} objects.
+     *
+     * @param source Tile source
+     * @param x X coordinate
+     * @param y Y coordinate
+     * @param zoom Zoom level
+     * @return The new {@link Tile}
+     * @since xxx
+     */
+    public Tile createTile(T source, int x, int y, int zoom) {
+        return new Tile(source, x, y, zoom);
+    }
+
     @Override
     public synchronized void destroy() {
@@ -1989,4 +2007,8 @@
             if (memory != null) {
                 doPaint(graphics);
+                if (AbstractTileSourceLayer.this instanceof MVTLayer) {
+                    AbstractTileSourceLayer.this.paint(graphics.getDefaultGraphics(), graphics.getMapView(), graphics.getMapView()
+                      .getRealBounds());
+                }
             } else {
                 Graphics g = graphics.getDefaultGraphics();
Index: trunk/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java	(revision 17862)
@@ -38,4 +38,5 @@
 import org.openstreetmap.josm.gui.MenuScroller;
 import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings;
+import org.openstreetmap.josm.gui.layer.imagery.MVTLayer;
 import org.openstreetmap.josm.gui.widgets.UrlLabel;
 import org.openstreetmap.josm.tools.GBC;
@@ -169,4 +170,6 @@
         case SCANEX:
             return new TMSLayer(info);
+        case MVT:
+            return new MVTLayer(info);
         default:
             throw new AssertionError(tr("Unsupported imagery type: {0}", info.getImageryType()));
Index: trunk/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java	(revision 17862)
@@ -0,0 +1,290 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Component;
+import java.awt.Graphics2D;
+import java.awt.event.ActionEvent;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.BooleanSupplier;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import javax.swing.AbstractAction;
+import javax.swing.Action;
+import javax.swing.JCheckBoxMenuItem;
+import javax.swing.JMenuItem;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
+import org.openstreetmap.josm.actions.ExpertToggleAction;
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTFile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile.TileListener;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorCachedTileLoader;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorTileSource;
+import org.openstreetmap.josm.data.osm.DataSet;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.data.osm.Relation;
+import org.openstreetmap.josm.data.osm.RelationMember;
+import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.data.osm.visitor.paint.AbstractMapRenderer;
+import org.openstreetmap.josm.data.osm.visitor.paint.MapRendererFactory;
+import org.openstreetmap.josm.data.osm.visitor.paint.StyledMapRenderer;
+import org.openstreetmap.josm.data.vector.VectorDataSet;
+import org.openstreetmap.josm.data.vector.VectorNode;
+import org.openstreetmap.josm.data.vector.VectorPrimitive;
+import org.openstreetmap.josm.data.vector.VectorRelation;
+import org.openstreetmap.josm.data.vector.VectorWay;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.layer.AbstractCachedTileSourceLayer;
+import org.openstreetmap.josm.gui.layer.LayerManager;
+import org.openstreetmap.josm.gui.layer.OsmDataLayer;
+import org.openstreetmap.josm.gui.mappaint.ElemStyles;
+import org.openstreetmap.josm.gui.mappaint.StyleSource;
+
+/**
+ * A layer for Mapbox Vector Tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSource> implements TileListener {
+    private static final String CACHE_REGION_NAME = "MVT";
+    // Just to avoid allocating a bunch of 0 length action arrays
+    private static final Action[] EMPTY_ACTIONS = new Action[0];
+    private final Map<String, Boolean> layerNames = new HashMap<>();
+    private final VectorDataSet dataSet = new VectorDataSet();
+
+    /**
+     * Creates an instance of an MVT layer
+     *
+     * @param info ImageryInfo describing the layer
+     */
+    public MVTLayer(ImageryInfo info) {
+        super(info);
+    }
+
+    @Override
+    protected Class<? extends TileLoader> getTileLoaderClass() {
+        return MapboxVectorCachedTileLoader.class;
+    }
+
+    @Override
+    protected String getCacheName() {
+        return CACHE_REGION_NAME;
+    }
+
+    @Override
+    public Collection<String> getNativeProjections() {
+        // Mapbox Vector Tiles <i>specifically</i> only support EPSG:3857
+        // ("it is exclusively geared towards square pixel tiles in {link to EPSG:3857}").
+        return Collections.singleton(MVTFile.DEFAULT_PROJECTION);
+    }
+
+    @Override
+    public void paint(Graphics2D g, MapView mv, Bounds box) {
+        this.dataSet.setZoom(this.getZoomLevel());
+        AbstractMapRenderer painter = MapRendererFactory.getInstance().createActiveRenderer(g, mv, false);
+        painter.enableSlowOperations(mv.getMapMover() == null || !mv.getMapMover().movementInProgress()
+          || !OsmDataLayer.PROPERTY_HIDE_LABELS_WHILE_DRAGGING.get());
+        // Set the painter to use our custom style sheet
+        if (painter instanceof StyledMapRenderer && this.dataSet.getStyles() != null) {
+            ((StyledMapRenderer) painter).setStyles(this.dataSet.getStyles());
+        }
+        painter.render(this.dataSet, false, box);
+    }
+
+    @Override
+    protected MapboxVectorTileSource getTileSource() {
+        MapboxVectorTileSource source = new MapboxVectorTileSource(this.info);
+        this.info.setAttribution(source);
+        if (source.getStyleSource() != null) {
+            List<ElemStyles> styles = source.getStyleSource().getSources().entrySet().stream()
+              .filter(entry -> entry.getKey() == null || entry.getKey().getUrls().contains(source.getBaseUrl()))
+              .map(Map.Entry::getValue).collect(Collectors.toList());
+            // load the style sources
+            styles.stream().map(ElemStyles::getStyleSources).flatMap(Collection::stream).forEach(StyleSource::loadStyleSource);
+            this.dataSet.setStyles(styles);
+            this.setName(source.getName());
+        }
+        return source;
+    }
+
+    @Override
+    public Tile createTile(MapboxVectorTileSource source, int x, int y, int zoom) {
+        final MVTTile tile = new MVTTile(source, x, y, zoom);
+        tile.addTileLoaderFinisher(this);
+        return tile;
+    }
+
+    @Override
+    public Action[] getMenuEntries() {
+        ArrayList<Action> actions = new ArrayList<>(Arrays.asList(super.getMenuEntries()));
+        // Add separator between Info and the layers
+        actions.add(SeparatorLayerAction.INSTANCE);
+        if (ExpertToggleAction.isExpert()) {
+            for (Map.Entry<String, Boolean> layerConfig : layerNames.entrySet()) {
+                actions.add(new EnableLayerAction(layerConfig.getKey(), () -> layerNames.computeIfAbsent(layerConfig.getKey(), key -> true),
+                        layer -> {
+                            layerNames.compute(layer, (key, value) -> Boolean.FALSE.equals(value));
+                            this.dataSet.setInvisibleLayers(layerNames.entrySet().stream()
+                                    .filter(entry -> Boolean.FALSE.equals(entry.getValue()))
+                                    .map(Map.Entry::getKey).collect(Collectors.toList()));
+                            this.invalidate();
+                        }));
+            }
+            // Add separator between layers and convert action
+            actions.add(SeparatorLayerAction.INSTANCE);
+            actions.add(new ConvertLayerAction(this));
+        }
+        return actions.toArray(EMPTY_ACTIONS);
+    }
+
+    /**
+     * Get the data set for this layer
+     */
+    public VectorDataSet getData() {
+        return this.dataSet;
+    }
+    
+    private static class ConvertLayerAction extends AbstractAction implements LayerAction {
+        private final MVTLayer layer;
+
+        ConvertLayerAction(MVTLayer layer) {
+            this.layer = layer;
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            LayerManager manager = MainApplication.getLayerManager();
+            VectorDataSet dataSet = layer.getData();
+            DataSet osmData = new DataSet();
+            // Add nodes first, map is to ensure we can map new nodes to vector nodes
+            Map<VectorNode, Node> nodeMap = new HashMap<>(dataSet.getNodes().size());
+            for (VectorNode vectorNode : dataSet.getNodes()) {
+                Node newNode = new Node(vectorNode.getCoor());
+                if (vectorNode.isTagged()) {
+                    vectorNode.getInterestingTags().forEach(newNode::put);
+                    newNode.put("layer", vectorNode.getLayer());
+                    newNode.put("id", Long.toString(vectorNode.getId()));
+                }
+                nodeMap.put(vectorNode, newNode);
+            }
+            // Add ways next
+            Map<VectorWay, Way> wayMap = new HashMap<>(dataSet.getWays().size());
+            for (VectorWay vectorWay : dataSet.getWays()) {
+                Way newWay = new Way();
+                List<Node> nodes = vectorWay.getNodes().stream().map(nodeMap::get).filter(Objects::nonNull).collect(Collectors.toList());
+                newWay.setNodes(nodes);
+                if (vectorWay.isTagged()) {
+                    vectorWay.getInterestingTags().forEach(newWay::put);
+                    newWay.put("layer", vectorWay.getLayer());
+                    newWay.put("id", Long.toString(vectorWay.getId()));
+                }
+                wayMap.put(vectorWay, newWay);
+            }
+
+            // Finally, add Relations
+            Map<VectorRelation, Relation> relationMap = new HashMap<>(dataSet.getRelations().size());
+            for (VectorRelation vectorRelation : dataSet.getRelations()) {
+                Relation newRelation = new Relation();
+                if (vectorRelation.isTagged()) {
+                    vectorRelation.getInterestingTags().forEach(newRelation::put);
+                    newRelation.put("layer", vectorRelation.getLayer());
+                    newRelation.put("id", Long.toString(vectorRelation.getId()));
+                }
+                List<RelationMember> members = vectorRelation.getMembers().stream().map(member -> {
+                    final OsmPrimitive primitive;
+                    final VectorPrimitive vectorPrimitive = member.getMember();
+                    if (vectorPrimitive instanceof VectorNode) {
+                        primitive = nodeMap.get(vectorPrimitive);
+                    } else if (vectorPrimitive instanceof VectorWay) {
+                        primitive = wayMap.get(vectorPrimitive);
+                    } else if (vectorPrimitive instanceof VectorRelation) {
+                        // Hopefully, relations are encountered in order...
+                        primitive = relationMap.get(vectorPrimitive);
+                    } else {
+                        primitive = null;
+                    }
+                    if (primitive == null) return null;
+                    return new RelationMember(member.getRole(), primitive);
+                }).filter(Objects::nonNull).collect(Collectors.toList());
+                newRelation.setMembers(members);
+                relationMap.put(vectorRelation, newRelation);
+            }
+            try {
+                osmData.beginUpdate();
+                nodeMap.values().forEach(osmData::addPrimitive);
+                wayMap.values().forEach(osmData::addPrimitive);
+                relationMap.values().forEach(osmData::addPrimitive);
+            } finally {
+                osmData.endUpdate();
+            }
+            manager.addLayer(new OsmDataLayer(osmData, this.layer.getName(), null));
+            manager.removeLayer(this.layer);
+        }
+
+        @Override
+        public boolean supportLayers(List<org.openstreetmap.josm.gui.layer.Layer> layers) {
+            return layers.stream().allMatch(MVTLayer.class::isInstance);
+        }
+
+        @Override
+        public Component createMenuComponent() {
+            JMenuItem menuItem = new JMenuItem(tr("Convert to OSM Data"));
+            menuItem.addActionListener(this);
+            return menuItem;
+        }
+    }
+
+    private static class EnableLayerAction extends AbstractAction implements LayerAction {
+        private final String layer;
+        private final Consumer<String> consumer;
+        private final BooleanSupplier state;
+
+        EnableLayerAction(String layer, BooleanSupplier state, Consumer<String> consumer) {
+            super(tr("Toggle layer {0}", layer));
+            this.layer = layer;
+            this.consumer = consumer;
+            this.state = state;
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            consumer.accept(layer);
+        }
+
+        @Override
+        public boolean supportLayers(List<org.openstreetmap.josm.gui.layer.Layer> layers) {
+            return layers.stream().allMatch(MVTLayer.class::isInstance);
+        }
+
+        @Override
+        public Component createMenuComponent() {
+            JCheckBoxMenuItem item = new JCheckBoxMenuItem(this);
+            item.setSelected(this.state.getAsBoolean());
+            return item;
+        }
+    }
+
+    @Override
+    public void finishedLoading(MVTTile tile) {
+        for (Layer layer : tile.getLayers()) {
+            this.layerNames.putIfAbsent(layer.getName(), true);
+        }
+        this.dataSet.addTileData(tile);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/gui/mappaint/ElemStyles.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/mappaint/ElemStyles.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/gui/mappaint/ElemStyles.java	(revision 17862)
@@ -87,4 +87,15 @@
 
     /**
+     * Constructs a new {@code ElemStyles} with specific style sources. This does not listen to preference changes,
+     * and therefore should only be used with layers that have specific drawing requirements.
+     *
+     * @param sources The style sources (these cannot be added to, or removed from)
+     * @since xxx
+     */
+    public ElemStyles(Collection<StyleSource> sources) {
+        this.styleSources.addAll(sources);
+    }
+
+    /**
      * Clear the style cache for all primitives of all DataSets.
      */
@@ -152,67 +163,69 @@
      */
     public Pair<StyleElementList, Range> getStyleCacheWithRange(IPrimitive osm, double scale, NavigatableComponent nc) {
-        if (!osm.isCachedStyleUpToDate() || scale <= 0) {
-            osm.setCachedStyle(StyleCache.EMPTY_STYLECACHE);
-        } else {
-            Pair<StyleElementList, Range> lst = osm.getCachedStyle().getWithRange(scale, osm.isSelected());
-            if (lst.a != null)
-                return lst;
-        }
-        Pair<StyleElementList, Range> p = getImpl(osm, scale, nc);
-        if (osm instanceof INode && isDefaultNodes()) {
-            if (p.a.isEmpty()) {
-                if (TextLabel.AUTO_LABEL_COMPOSITION_STRATEGY.compose(osm) != null) {
-                    p.a = DefaultStyles.DEFAULT_NODE_STYLELIST_TEXT;
+        synchronized (osm.getStyleCacheSyncObject()) {
+            if (!osm.isCachedStyleUpToDate() || scale <= 0) {
+                osm.setCachedStyle(StyleCache.EMPTY_STYLECACHE);
+            } else {
+                Pair<StyleElementList, Range> lst = osm.getCachedStyle().getWithRange(scale, osm.isSelected());
+                if (lst.a != null)
+                    return lst;
+            }
+            Pair<StyleElementList, Range> p = getImpl(osm, scale, nc);
+            if (osm instanceof INode && isDefaultNodes()) {
+                if (p.a.isEmpty()) {
+                    if (TextLabel.AUTO_LABEL_COMPOSITION_STRATEGY.compose(osm) != null) {
+                        p.a = DefaultStyles.DEFAULT_NODE_STYLELIST_TEXT;
+                    } else {
+                        p.a = DefaultStyles.DEFAULT_NODE_STYLELIST;
+                    }
                 } else {
-                    p.a = DefaultStyles.DEFAULT_NODE_STYLELIST;
-                }
-            } else {
-                boolean hasNonModifier = false;
-                boolean hasText = false;
+                    boolean hasNonModifier = false;
+                    boolean hasText = false;
+                    for (StyleElement s : p.a) {
+                        if (s instanceof BoxTextElement) {
+                            hasText = true;
+                        } else {
+                            if (!s.isModifier) {
+                                hasNonModifier = true;
+                            }
+                        }
+                    }
+                    if (!hasNonModifier) {
+                        p.a = new StyleElementList(p.a, DefaultStyles.SIMPLE_NODE_ELEMSTYLE);
+                        if (!hasText && TextLabel.AUTO_LABEL_COMPOSITION_STRATEGY.compose(osm) != null) {
+                            p.a = new StyleElementList(p.a, DefaultStyles.SIMPLE_NODE_TEXT_ELEMSTYLE);
+                        }
+                    }
+                }
+            } else if (osm instanceof IWay && isDefaultLines()) {
+                boolean hasProperLineStyle = false;
                 for (StyleElement s : p.a) {
-                    if (s instanceof BoxTextElement) {
-                        hasText = true;
-                    } else {
-                        if (!s.isModifier) {
-                            hasNonModifier = true;
-                        }
-                    }
-                }
-                if (!hasNonModifier) {
-                    p.a = new StyleElementList(p.a, DefaultStyles.SIMPLE_NODE_ELEMSTYLE);
-                    if (!hasText && TextLabel.AUTO_LABEL_COMPOSITION_STRATEGY.compose(osm) != null) {
-                        p.a = new StyleElementList(p.a, DefaultStyles.SIMPLE_NODE_TEXT_ELEMSTYLE);
-                    }
-                }
-            }
-        } else if (osm instanceof IWay && isDefaultLines()) {
-            boolean hasProperLineStyle = false;
-            for (StyleElement s : p.a) {
-                if (s.isProperLineStyle()) {
-                    hasProperLineStyle = true;
-                    break;
-                }
-            }
-            if (!hasProperLineStyle) {
-                LineElement line = LineElement.UNTAGGED_WAY;
-                for (StyleElement element : p.a) {
-                    if (element instanceof AreaElement) {
-                        line = LineElement.createSimpleLineStyle(((AreaElement) element).color, true);
+                    if (s.isProperLineStyle()) {
+                        hasProperLineStyle = true;
                         break;
                     }
                 }
-                p.a = new StyleElementList(p.a, line);
-            }
-        }
-        StyleCache style = osm.getCachedStyle() != null ? osm.getCachedStyle() : StyleCache.EMPTY_STYLECACHE;
-        try {
-            osm.setCachedStyle(style.put(p.a, p.b, osm.isSelected()));
-        } catch (RangeViolatedError e) {
-            throw new AssertionError("Range violated: " + e.getMessage()
-                    + " (object: " + osm.getPrimitiveId() + ", current style: "+osm.getCachedStyle()
-                    + ", scale: " + scale + ", new stylelist: " + p.a + ", new range: " + p.b + ')', e);
-        }
-        osm.declareCachedStyleUpToDate();
-        return p;
+                if (!hasProperLineStyle) {
+                    LineElement line = LineElement.UNTAGGED_WAY;
+                    for (StyleElement element : p.a) {
+                        if (element instanceof AreaElement) {
+                            line = LineElement.createSimpleLineStyle(((AreaElement) element).color, true);
+                            break;
+                        }
+                    }
+                    p.a = new StyleElementList(p.a, line);
+                }
+            }
+            StyleCache style = osm.getCachedStyle() != null ? osm.getCachedStyle() : StyleCache.EMPTY_STYLECACHE;
+            try {
+                osm.setCachedStyle(style.put(p.a, p.b, osm.isSelected()));
+            } catch (RangeViolatedError e) {
+                throw new AssertionError("Range violated: " + e.getMessage()
+                  + " (object: " + osm.getPrimitiveId() + ", current style: " + osm.getCachedStyle()
+                  + ", scale: " + scale + ", new stylelist: " + p.a + ", new range: " + p.b + ')', e);
+            }
+            osm.declareCachedStyleUpToDate();
+            return p;
+        }
     }
 
@@ -377,5 +390,4 @@
      */
     public Pair<StyleElementList, Range> generateStyles(IPrimitive osm, double scale, boolean pretendWayIsClosed) {
-
         List<StyleElement> sl = new ArrayList<>();
         MultiCascade mc = new MultiCascade();
Index: trunk/src/org/openstreetmap/josm/gui/mappaint/mapcss/ConditionFactory.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/mappaint/mapcss/ConditionFactory.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/gui/mappaint/mapcss/ConditionFactory.java	(revision 17862)
@@ -871,4 +871,15 @@
             return e.osm.isSelected();
         }
+
+        /**
+         * Check if the object is highlighted (i.e., is hovered over)
+         * @param e The MapCSS environment
+         * @return {@code true} if the object is highlighted
+         * @see IPrimitive#isHighlighted
+         * @since xxx
+         */
+        static boolean highlighted(Environment e) {
+            return e.osm.isHighlighted();
+        }
     }
 
@@ -888,4 +899,5 @@
             PseudoClassCondition.register("completely_downloaded", PseudoClasses::completely_downloaded);
             PseudoClassCondition.register("connection", PseudoClasses::connection);
+            PseudoClassCondition.register("highlighted", PseudoClasses::highlighted);
             PseudoClassCondition.register("inDownloadedArea", PseudoClasses::inDownloadedArea);
             PseudoClassCondition.register("modified", PseudoClasses::modified);
Index: trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java	(revision 17862)
+++ trunk/src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java	(revision 17862)
@@ -0,0 +1,94 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.preferences.imagery;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+import java.util.Arrays;
+
+import javax.swing.JLabel;
+
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
+import org.openstreetmap.josm.gui.widgets.JosmTextArea;
+import org.openstreetmap.josm.gui.widgets.JosmTextField;
+import org.openstreetmap.josm.tools.GBC;
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * A panel for adding Mapbox Vector Tile layers
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class AddMVTLayerPanel extends AddImageryPanel {
+    private final JosmTextField mvtZoom = new JosmTextField();
+    private final JosmTextArea mvtUrl = new JosmTextArea(3, 40).transferFocusOnTab();
+
+    /**
+     * Constructs a new {@code AddMVTLayerPanel}.
+     */
+    public AddMVTLayerPanel() {
+
+        add(new JLabel(tr("{0} Make sure OSM has the permission to use this service", "1.")), GBC.eol());
+        add(new JLabel(tr("{0} Enter URL (may be a style sheet url)", "2.")), GBC.eol());
+        add(new JLabel("<html>" + Utils.joinAsHtmlUnorderedList(Arrays.asList(
+                tr("{0} is replaced by tile zoom level, also supported:<br>" +
+                        "offsets to the zoom level: {1} or {2}<br>" +
+                        "reversed zoom level: {3}", "{zoom}", "{zoom+1}", "{zoom-1}", "{19-zoom}"),
+                tr("{0} is replaced by X-coordinate of the tile", "{x}"),
+                tr("{0} is replaced by Y-coordinate of the tile", "{y}"),
+                tr("{0} is replaced by a random selection from the given comma separated list, e.g. {1}", "{switch:...}", "{switch:a,b,c}")
+        )) + "</html>"), GBC.eol().fill());
+
+        final KeyAdapter keyAdapter = new KeyAdapter() {
+            @Override
+            public void keyReleased(KeyEvent e) {
+                mvtUrl.setText(buildMvtUrl());
+            }
+        };
+
+        add(rawUrl, GBC.eop().fill());
+        rawUrl.setLineWrap(true);
+        rawUrl.addKeyListener(keyAdapter);
+
+        add(new JLabel(tr("{0} Enter maximum zoom (optional)", "3.")), GBC.eol());
+        mvtZoom.addKeyListener(keyAdapter);
+        add(mvtZoom, GBC.eop().fill());
+
+        add(new JLabel(tr("{0} Edit generated {1} URL (optional)", "4.", "MVT")), GBC.eol());
+        add(mvtUrl, GBC.eop().fill());
+        mvtUrl.setLineWrap(true);
+
+        add(new JLabel(tr("{0} Enter name for this layer", "5.")), GBC.eol());
+        add(name, GBC.eop().fill());
+
+        registerValidableComponent(mvtUrl);
+    }
+
+    private String buildMvtUrl() {
+        StringBuilder a = new StringBuilder("mvt");
+        String z = sanitize(mvtZoom.getText());
+        if (!z.isEmpty()) {
+            a.append('[').append(z).append(']');
+        }
+        a.append(':').append(sanitize(getImageryRawUrl(), ImageryType.MVT));
+        return a.toString();
+    }
+
+    @Override
+    public ImageryInfo getImageryInfo() {
+        final ImageryInfo generated = new ImageryInfo(getImageryName(), getMvtUrl());
+        generated.setImageryType(ImageryType.MVT);
+        return generated;
+    }
+
+    protected final String getMvtUrl() {
+        return sanitize(mvtUrl.getText());
+    }
+
+    @Override
+    protected boolean isImageryValid() {
+        return !getImageryName().isEmpty() && !getMvtUrl().isEmpty();
+    }
+}
Index: trunk/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java	(revision 17861)
+++ trunk/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java	(revision 17862)
@@ -313,4 +313,5 @@
         activeToolbar.add(new NewEntryAction(ImageryInfo.ImageryType.TMS));
         activeToolbar.add(new NewEntryAction(ImageryInfo.ImageryType.WMTS));
+        activeToolbar.add(new NewEntryAction(ImageryInfo.ImageryType.MVT));
         activeToolbar.add(remove);
         activePanel.add(activeToolbar, BorderLayout.EAST);
@@ -441,4 +442,7 @@
                 icon = /* ICON(dialogs/) */ "add_wmts";
                 break;
+            case MVT:
+                icon = /* ICON(dialogs/) */ "add_mvt";
+                break;
             default:
                 break;
@@ -460,4 +464,7 @@
             case WMTS:
                 p = new AddWMTSLayerPanel();
+                break;
+            case MVT:
+                p = new AddMVTLayerPanel();
                 break;
             default:
@@ -742,5 +749,5 @@
         URL url;
         try {
-            url = new URL(eulaUrl.replaceAll("\\{lang\\}", LanguageInfo.getWikiLanguagePrefix()));
+            url = new URL(eulaUrl.replaceAll("\\{lang}", LanguageInfo.getWikiLanguagePrefix()));
             JosmEditorPane htmlPane;
             try {
@@ -750,5 +757,5 @@
                 // give a second chance with a default Locale 'en'
                 try {
-                    url = new URL(eulaUrl.replaceAll("\\{lang\\}", ""));
+                    url = new URL(eulaUrl.replaceAll("\\{lang}", ""));
                     htmlPane = new JosmEditorPane(url);
                 } catch (IOException e2) {
