Index: resources/images/dialogs/add_mvt.svg
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/resources/images/dialogs/add_mvt.svg b/resources/images/dialogs/add_mvt.svg
new file mode 100644
--- /dev/null	(date 1612971564180)
+++ b/resources/images/dialogs/add_mvt.svg	(date 1612971564180)
@@ -0,0 +1,148 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="24"
+   height="24"
+   viewBox="0 0 24 24"
+   id="svg2"
+   version="1.1"
+   inkscape:version="1.0.1 (c497b03c, 2020-09-10)"
+   sodipodi:docname="add_mvt.svg">
+  <defs
+     id="defs4">
+    <linearGradient
+       gradientTransform="translate(4)"
+       gradientUnits="userSpaceOnUse"
+       y2="1049.3622"
+       x2="12"
+       y1="1041.3622"
+       x1="4"
+       id="linearGradient868"
+       xlink:href="#linearGradient866"
+       inkscape:collect="always" />
+    <linearGradient
+       id="linearGradient866"
+       inkscape:collect="always">
+      <stop
+         id="stop862"
+         offset="0"
+         style="stop-color:#dfdfdf;stop-opacity:1" />
+      <stop
+         id="stop864"
+         offset="1"
+         style="stop-color:#949593;stop-opacity:1" />
+    </linearGradient>
+  </defs>
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="45.254834"
+     inkscape:cx="11.376506"
+     inkscape:cy="17.057298"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="true"
+     units="px"
+     inkscape:window-width="1920"
+     inkscape:window-height="955"
+     inkscape:window-x="0"
+     inkscape:window-y="23"
+     inkscape:window-maximized="1"
+     viewbox-height="16"
+     inkscape:document-rotation="0">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4136"
+       originx="0"
+       originy="0"
+       spacingx="1"
+       spacingy="1" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+        <cc:license
+           rdf:resource="http://creativecommons.org/publicdomain/zero/1.0/" />
+      </cc:Work>
+      <cc:License
+         rdf:about="http://creativecommons.org/publicdomain/zero/1.0/">
+        <cc:permits
+           rdf:resource="http://creativecommons.org/ns#Reproduction" />
+        <cc:permits
+           rdf:resource="http://creativecommons.org/ns#Distribution" />
+        <cc:permits
+           rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
+      </cc:License>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1037.3622)">
+    <rect
+       ry="0.48361239"
+       y="1043.8622"
+       x="5.5"
+       height="3"
+       width="13"
+       id="rect833"
+       style="opacity:1;fill:#c1c2c0;fill-opacity:1;fill-rule:nonzero;stroke:#555753;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0.839909;stroke-opacity:1;paint-order:normal" />
+    <rect
+       transform="rotate(-90)"
+       ry="0.48361239"
+       y="10.5"
+       x="-1051.8622"
+       height="3"
+       width="13"
+       id="rect833-5"
+       style="opacity:1;fill:#c1c2c0;fill-opacity:1;fill-rule:nonzero;stroke:#555753;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0.839909;stroke-opacity:1;paint-order:normal" />
+    <path
+       inkscape:connector-curvature="0"
+       id="path852"
+       d="M 6.0000001,1044.3622 H 11 v -5 h 2 v 5 h 5 v 2 h -5 v 5 h -2 v -5 H 6.0000001 Z"
+       style="fill:url(#linearGradient868);fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+    <path
+       style="fill:none;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1"
+       d="m 4.5,1060.3625 v -7.5948 l 2,4.3971 2,-4.3971 v 7.5948"
+       id="path894"
+       sodipodi:nodetypes="ccccc" />
+    <path
+       style="fill:none;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+       d="m 17.5,1060.3622 v -8"
+       id="path896" />
+    <path
+       style="fill:none;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1"
+       d="m 15,1052.8622 h 5"
+       id="path898" />
+    <text
+       xml:space="preserve"
+       style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:9.3042px;line-height:1.25;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.894202;stroke-miterlimit:4;stroke-dasharray:none"
+       x="10.59868"
+       y="898.41876"
+       id="text854"
+       transform="scale(0.84728029,1.180247)"><tspan
+         sodipodi:role="line"
+         id="tspan852"
+         x="10.59868"
+         y="898.41876"
+         style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:9.3042px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill-rule:nonzero;stroke-width:0.894202;stroke-miterlimit:4;stroke-dasharray:none">V</tspan></text>
+  </g>
+</svg>
Index: src/org/openstreetmap/josm/data/imagery/ImageryInfo.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java b/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java
--- a/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java	(revision 17557)
+++ b/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java	(date 1612999317664)
@@ -61,7 +61,9 @@
         /** A WMS endpoint entry only stores the WMS server info, without layer, which are chosen later by the user. **/
         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;
 
@@ -654,7 +656,7 @@
         defaultMaxZoom = 0;
         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);
                 this.sourceType = type;
@@ -669,7 +671,7 @@
         }
 
         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: src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java b/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java
--- a/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java	(revision 17557)
+++ b/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java	(date 1615309423456)
@@ -10,6 +10,7 @@
 import java.nio.charset.StandardCharsets;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Optional;
@@ -32,6 +33,8 @@
 import org.openstreetmap.josm.data.cache.CacheEntryAttributes;
 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;
 import org.openstreetmap.josm.tools.Logging;
@@ -147,7 +150,7 @@
     private boolean isNotImage(Map<String, List<String>> headers, int statusCode) {
         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
                 return true;
@@ -321,7 +324,8 @@
             if (content.length > 0) {
                 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);
                         if (m.matches()) {
Index: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Command.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Command.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Command.java
new file mode 100644
--- /dev/null	(date 1612915206957)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Command.java	(date 1612915206957)
@@ -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: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/CommandInteger.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/CommandInteger.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/CommandInteger.java
new file mode 100644
--- /dev/null	(date 1612974322838)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/CommandInteger.java	(date 1612974322838)
@@ -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: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java
new file mode 100644
--- /dev/null	(date 1612974660134)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java	(date 1612974660134)
@@ -0,0 +1,150 @@
+// 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 tags of the feature. Optional. */
+    private TagMap tags;
+
+    /** The id of the feature. Optional. */
+    // Technically, uint64
+    private final long id;
+
+    /**
+     * 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(ParameterInteger.decode(number.intValue()));
+                            }
+                        }
+                        // 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;
+    }
+}
Index: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java
new file mode 100644
--- /dev/null	(date 1615304222278)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java	(date 1615304222278)
@@ -0,0 +1,116 @@
+// 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.awt.geom.Point2D;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * A class to generate geometry for a vector tile
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class Geometry {
+    private static final byte CIRCLE_SIZE = 0;
+    final Collection<Shape> shapes = new HashSet<>();
+    private final Feature feature;
+
+    /**
+     * Create a {@link Geometry} for a {@link Feature}
+     * @param feature the {@link Feature} for the geometry
+     */
+    public Geometry(final Feature feature) {
+        this.feature = feature;
+        final GeometryTypes geometryType = this.feature.getGeometryType();
+        final List<CommandInteger> commands = this.feature.getGeometry();
+        final byte circleSize = CIRCLE_SIZE;
+        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) {
+                    for (int i = 0; i < operations.length / 2; i++) {
+                        // move left/up by 1/2 circleSize, so that the circle is centered
+                        shapes.add(new Ellipse2D.Float(operations[2 * i] - circleSize / 2f,
+                                operations[2 * i + 1] - circleSize / 2f, circleSize, circleSize));
+                    }
+                } 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) {
+                    // ClosePath specifically does not change the cursor position
+                    line.closePath();
+                    line.setWindingRule(Path2D.WIND_NON_ZERO);
+                    shapes.remove(line);
+                    if (area == null) {
+                        area = new Area(line);
+                        shapes.add(area);
+                    } else {
+                        Area nArea = new Area(line);
+                        // SonarLint thinks that this is never > 0. It can be.
+                        if (areaAreaSq > 0) {
+                            area.add(nArea);
+                        } else {
+                            area.exclusiveOr(nArea);
+                        }
+                    }
+                } else {
+                    throw new IllegalArgumentException(tr("{0} with {1} arguments is not understood", geometryType, operations.length));
+                }
+            }
+        }
+    }
+
+    /**
+     * Get the feature for this geometry
+     * @return The feature
+     */
+    public Feature getFeature() {
+        return this.feature;
+    }
+
+    /**
+     * Get the shapes to draw this geometry with
+     * @return A collection of shapes
+     */
+    public Collection<Shape> getShapes() {
+        return Collections.unmodifiableCollection(this.shapes);
+    }
+}
Index: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java
new file mode 100644
--- /dev/null	(date 1612915206973)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java	(date 1612915206973)
@@ -0,0 +1,44 @@
+// 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((byte) 0),
+    /** 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((byte) 1),
+    /** May be a line or a multiline geometry. Each line {@link Command#MoveTo} and one or more {@link Command#LineTo}. */
+    LINESTRING((byte) 2),
+    /** 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((byte) 3);
+
+    private final byte id;
+    GeometryTypes(byte id) {
+        this.id = id;
+    }
+
+    /**
+     * Get the id for the geometry type
+     * @return The id
+     */
+    public byte getId() {
+        return this.id;
+    }
+
+    /**
+     * 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: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java
new file mode 100644
--- /dev/null	(date 1612915206975)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java	(date 1612915206975)
@@ -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 InvalidMapboxVectorTile} exception with a message
+     * @param message The message
+     */
+    public InvalidMapboxVectorTileException(final String message) {
+        super(message);
+    }
+}
Index: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
new file mode 100644
--- /dev/null	(date 1613423088453)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java	(date 1613423088453)
@@ -0,0 +1,216 @@
+// 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.Logging;
+
+/**
+ * A Mapbox Vector Tile Layer
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class Layer {
+    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);
+
+        public static final Collection<ValueFields<?>> MAPPERS = 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;
+    /** The shapes to use to draw this layer */
+    private final List<Geometry> geometryCollection;
+
+    /**
+     * 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.get((int) VERSION_FIELD).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::asSignedVarInt)
+                .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) {
+                        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());
+        this.geometryCollection = this.featureCollection.stream().map(Geometry::new).collect(Collectors.toList());
+        if (!exceptions.isEmpty()) {
+            throw exceptions.iterator().next();
+        }
+        // Cleanup bytes (for memory)
+        for (ProtoBufRecord record : records) {
+            try {
+                record.close();
+            } catch (Exception e) {
+                Logging.error(e);
+            }
+        }
+    }
+
+    /**
+     * 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(new ProtoBufParser(bytes).allRecords());
+    }
+
+    /**
+     * 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 Collections.unmodifiableCollection(this.geometryCollection);
+    }
+
+    /**
+     * 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;
+    }
+}
Index: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoader.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoader.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoader.java
new file mode 100644
--- /dev/null	(date 1612974051864)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoader.java	(date 1612974051864)
@@ -0,0 +1,78 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+import org.apache.commons.jcs3.access.behavior.ICacheAccess;
+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;
+
+/**
+ * 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 ThreadPoolExecutor getDownloadExecutor() {
+        return DEFAULT_DOWNLOAD_JOB_DISPATCHER;
+    }
+}
Index: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoaderJob.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoaderJob.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoaderJob.java
new file mode 100644
--- /dev/null	(date 1612973954795)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoaderJob.java	(date 1612973954795)
@@ -0,0 +1,25 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+import org.apache.commons.jcs3.access.behavior.ICacheAccess;
+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;
+
+/**
+ * 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: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java
new file mode 100644
--- /dev/null	(date 1612978601500)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java	(date 1612978601500)
@@ -0,0 +1,17 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.JosmTemplatedTMSTileSource;
+import org.openstreetmap.josm.data.projection.Projection;
+
+/**
+ * Tile Source handling for Mapbox Vector Tile sources
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MapboxVectorTileSource extends JosmTemplatedTMSTileSource {
+    public MapboxVectorTileSource(ImageryInfo info) {
+        super(info);
+    }
+}
Index: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java
new file mode 100644
--- /dev/null	(date 1612915206992)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java	(date 1612915206992)
@@ -0,0 +1,33 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.util.Arrays;
+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.
+     * This is a SHOULD, <i>not</i> a MUST.
+     */
+    public static final List<String> EXTENSION = Arrays.asList("mvt");
+
+    /**
+     * mimetypes for Mapbox Vector Tiles
+     * This is a SHOULD, <i>not</i> a MUST.
+     */
+    public static final List<String> MIMETYPE = Arrays.asList("application/vnd.mapbox-vector-tile");
+
+    /**
+     * The default projection. This is Web Mercator, per specification.
+     */
+    public static final String DEFAULT_PROJECTION = "EPSG:3857";
+
+    private MVTFile() {
+        // Hide the constructor
+    }
+}
Index: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
new file mode 100644
--- /dev/null	(date 1615300879891)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java	(date 1615300879891)
@@ -0,0 +1,209 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.awt.Color;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.Shape;
+import java.awt.Stroke;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Area;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Path2D;
+import java.awt.image.BufferedImage;
+import java.awt.image.ImageObserver;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+
+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.imagery.vectortile.VectorTile;
+import org.openstreetmap.josm.data.protobuf.ProtoBufParser;
+import org.openstreetmap.josm.data.protobuf.ProtoBufRecord;
+import org.openstreetmap.josm.data.vector.VectorDataSet;
+import org.openstreetmap.josm.tools.ListenerList;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A class for MapBox Vector Tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MVTTile extends Tile implements VectorTile {
+    private Collection<Layer> layers;
+    private int extent = Layer.DEFAULT_EXTENT;
+    private final ListenerList<TileListener> listenerList = ListenerList.create();
+    private LayerShower layerShower;
+
+    public MVTTile(TileSource source, int xtile, int ytile, int zoom) {
+        super(source, xtile, ytile, zoom);
+    }
+
+    @Override
+    public void paint(final Graphics g, final int x, final int y) {
+        this.paint(g, x, y, 256, 256);
+    }
+
+    @Override
+    public void paint(Graphics g, int x, int y, int width, int height, int zoom, ImageObserver observer) {
+        if (!(g instanceof Graphics2D) || this.layers == null) {
+            if (getImage() != null) {
+                g.drawImage(image, x, y, width, height, observer);
+            }
+            return;
+        }
+        final Graphics2D graphics = (Graphics2D) g;
+        graphics.setColor(Color.GREEN);
+        final AffineTransform originalTransform = graphics.getTransform();
+        final Stroke originalStroke = graphics.getStroke();
+        try {
+            graphics.translate(x, y);
+            // TODO figure out HiDPI (maybe GuiSizesHelper?)
+            // 131072 seems to be the magic number for my screens. This needs to be investigated more.
+            final double scale = width / (double) (32768);
+            // The scaleTransform is separate to avoid wide lines at high zoom (e.g., when vector tiles go to z14, but
+            // we are currently at z20, the graphics.scale function makes everything big. Unfortunately, this creates
+            // a new shape object.
+            final UnaryOperator<Shape> scaleShape;
+            if (scale > 1) {
+                final AffineTransform scaleTransform = AffineTransform.getScaleInstance(scale, scale);
+                scaleShape = scaleTransform::createTransformedShape;
+            } else {
+                // Cannot use Objects.identity() since it isn't a UnaryOperator
+                scaleShape = s -> s;
+                graphics.scale(scale, scale);
+            }
+            final Color transparentYellow = new Color(Color.YELLOW.getRed(), Color.YELLOW.getGreen(), Color.YELLOW.getBlue(), 120);
+            Collection<Layer> layersToShow = new ArrayList<>();
+            if (this.layerShower != null) {
+                this.layerShower.layersToShow().stream().map(layer -> this.layers.stream().filter(l -> Objects.equals(layer, l.getName())).findAny().orElse(null)).filter(Objects::nonNull).forEach(layersToShow::add);
+            } else {
+                layersToShow.addAll(this.layers);
+            }
+            for (Layer layer : layersToShow) {
+                layer.getGeometry().forEach(shapes -> {
+                    for (Shape shape : shapes.getShapes()) {
+                        final Shape scaledShape = scaleShape.apply(shape);
+                        if (shape instanceof Ellipse2D) {
+                            graphics.setColor(Color.GREEN);
+                        } else if (shape instanceof Path2D) {
+                            graphics.setColor(Color.RED);
+                        } else if (shape instanceof Area) {
+                            graphics.setColor(transparentYellow);
+                            graphics.fill(scaledShape);
+                            graphics.setColor(Color.YELLOW);
+                        }
+                        graphics.draw(scaledShape);
+                    }
+                });
+            }
+        } finally {
+            graphics.setTransform(originalTransform);
+            graphics.setStroke(originalStroke);
+        }
+    }
+
+    @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(record -> {
+                Layer mvtLayer = null;
+                if (record.getField() == Layer.LAYER_FIELD) {
+                    try (ProtoBufParser tParser = new ProtoBufParser(record.getBytes())) {
+                        mvtLayer = new Layer(tParser.allRecords());
+                    } catch (IOException e) {
+                        Logging.error(e);
+                    } finally {
+                        // Cleanup bytes
+                        record.close();
+                    }
+                }
+                return mvtLayer;
+            }).collect(Collectors.toCollection(HashSet::new));
+            this.extent = layers.stream().map(Layer::getExtent).max(Integer::compare).orElse(Layer.DEFAULT_EXTENT);
+            BufferedImage bufferedImage = new BufferedImage(this.extent, this.extent, BufferedImage.TYPE_4BYTE_ABGR);
+            Graphics2D graphics = bufferedImage.createGraphics();
+            this.paint(graphics, 0, 0);
+
+            // TODO figure out a better way to free memory
+            final int maxSize = 256;
+            final BufferedImage resized = new BufferedImage(maxSize, maxSize, bufferedImage.getType());
+            resized.getGraphics().drawImage(bufferedImage, 0, 0, resized.getWidth(), resized.getHeight(), null);
+            this.image = resized;
+            this.finishLoading();
+            Collection<TileListener> fired = new ArrayList<>();
+            this.listenerList.fireEvent(event -> {
+                event.finishedLoading(this);
+                fired.add(event);
+            });
+            // We shouldn't keep object references around once we no longer need them
+            fired.forEach(this.listenerList::removeListener);
+        }
+    }
+
+    @Override
+    public Collection<Layer> getLayers() {
+        return this.layers;
+    }
+
+    @Override
+    public int getExtent() {
+        return this.extent;
+    }
+
+    /**
+     * Set the object that will determine what layers are shown
+     * @param shower The class that will determine the layers to paint
+     */
+    public void setLayerShower(LayerShower shower) {
+        this.layerShower = shower;
+    }
+
+
+    /**
+     * A class that can be notified that a tile has finished loading
+     * @author Taylor Smock
+     *
+     */
+    public static interface TileListener {
+        /**
+         * Called when the MVTTile is finished loading
+         * @param tile The tile that finished loading
+         */
+        void finishedLoading(final MVTTile tile);
+    }
+
+    /**
+     * A class used to set the layers that an MVTTile will show.
+     * @author Taylor Smock
+     *
+     */
+    public static interface LayerShower {
+        /**
+         * Get a list of layers to show
+         * @return A list of layer names
+         */
+        List<String> layersToShow();
+    }
+
+    /**
+     * 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);
+    }
+}
Index: src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/ParameterInteger.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/ParameterInteger.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/ParameterInteger.java
new file mode 100644
--- /dev/null	(date 1612975726379)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/ParameterInteger.java	(date 1612975726379)
@@ -0,0 +1,22 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+/**
+ * The parameters that follow the {@link CommandInteger}.
+ * @author Taylor Smock
+ * @since xxx
+ */
+public final class ParameterInteger {
+    private ParameterInteger() {
+        // Hide constructor
+    }
+
+    /**
+     * Get the value for this ParameterInteger
+     * @param value The zig-zag and delta encoded value to decode
+     * @return The decoded integer value
+     */
+    public static int decode(int value) {
+        return ((value >> 1) ^ -(value & 1));
+    }
+}
Index: src/org/openstreetmap/josm/data/imagery/vectortile/VectorTile.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/VectorTile.java b/src/org/openstreetmap/josm/data/imagery/vectortile/VectorTile.java
new file mode 100644
--- /dev/null	(date 1615299587444)
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/VectorTile.java	(date 1615299587444)
@@ -0,0 +1,53 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile;
+
+import java.awt.Graphics;
+import java.awt.image.ImageObserver;
+import java.util.Collection;
+
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
+import org.openstreetmap.josm.data.vector.VectorDataSet;
+
+/**
+ * An interface that is used to draw vector tiles, instead of using images
+ * @author Taylor Smock
+ * @since xxx
+ */
+public interface VectorTile {
+    /**
+     * Paints the vector tile on the {@link Graphics} <code>g</code> at the
+     * position <code>x</code>/<code>y</code>.
+     *
+     * @param g the Graphics object
+     * @param x x-coordinate in <code>g</code>
+     * @param y y-coordinate in <code>g</code>
+     */
+    @Deprecated
+    void paint(Graphics g, int x, int y);
+
+    /**
+     * Paints the vector tile on the {@link Graphics} <code>g</code> at the
+     * position <code>x</code>/<code>y</code>.
+     *  @param g the Graphics object
+     * @param x x-coordinate in <code>g</code>
+     * @param y y-coordinate in <code>g</code>
+     * @param width width that tile should have
+     * @param height height that tile should have
+     * @param observer The paint observer. May be {@code null}.
+     * @param zoom The current zoom level
+     */
+    @Deprecated
+    void paint(Graphics g, int x, int y, int width, int height, int zoom, ImageObserver observer);
+
+    /**
+     * 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: src/org/openstreetmap/josm/data/osm/IWaySegment.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/osm/IWaySegment.java b/src/org/openstreetmap/josm/data/osm/IWaySegment.java
new file mode 100644
--- /dev/null	(date 1615241654093)
+++ b/src/org/openstreetmap/josm/data/osm/IWaySegment.java	(date 1615241654093)
@@ -0,0 +1,146 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm;
+
+  import java.awt.geom.Line2D;
+  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.
+ */
+public class IWaySegment<N extends INode, W extends IWay<N>> implements Comparable<IWaySegment> {
+
+    /**
+     * 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}
+     */
+    public W toWay() throws IllegalAccessException, InstantiationException {
+        W w = (W) this.way.getClass().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 (IllegalAccessException | InstantiationException 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: src/org/openstreetmap/josm/data/osm/WaySegment.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/osm/WaySegment.java b/src/org/openstreetmap/josm/data/osm/WaySegment.java
--- a/src/org/openstreetmap/josm/data/osm/WaySegment.java	(revision 17557)
+++ b/src/org/openstreetmap/josm/data/osm/WaySegment.java	(date 1615241811762)
@@ -1,57 +1,26 @@
 // 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> {
-
-    /**
-     * 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;
+public final class WaySegment extends IWaySegment<Node, Way> {
 
     /**
-     * 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());
-        }
-    }
-
-    /**
-     * Returns the first node of the way segment.
-     * @return the first node
-     */
-    public Node getFirstNode() {
-        return way.getNode(lowerIndex);
+    public WaySegment(Way way, int i) {
+        super(way, i);
     }
 
     /**
-     * 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.
+     * 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
@@ -74,6 +43,7 @@
      * Returns this way segment as complete way.
      * @return the way segment as {@code Way}
      */
+    @Override
     public Way toWay() {
         Way w = new Way();
         w.addNode(getFirstNode());
@@ -81,53 +51,6 @@
         return w;
     }
 
-    @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);
-    }
-
-    @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()));
-    }
-
-    /**
-     * Checks whether this segment crosses other segment
-     *
-     * @param s2 The other segment
-     * @return true if both segments crosses
-     */
-    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());
-    }
-
-    /**
-     * 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(WaySegment s2) {
-        return (getFirstNode().equals(s2.getFirstNode()) && getSecondNode().equals(s2.getSecondNode()))
-            || (getFirstNode().equals(s2.getSecondNode()) && getSecondNode().equals(s2.getFirstNode()));
-    }
-
     @Override
     public String toString() {
         return "WaySegment [way=" + way.getUniqueId() + ", lowerIndex=" + lowerIndex + ']';
Index: src/org/openstreetmap/josm/data/protobuf/ProtoBufPacked.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtoBufPacked.java b/src/org/openstreetmap/josm/data/protobuf/ProtoBufPacked.java
new file mode 100644
--- /dev/null	(date 1613500750294)
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtoBufPacked.java	(date 1613500750294)
@@ -0,0 +1,65 @@
+// 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 int location;
+    private final Number[] numbers;
+    /**
+     * 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);
+        }
+    }
+
+    /**
+     * The number of expected values
+     * @return The expected values
+     */
+    public int size() {
+        return this.numbers.length;
+    }
+
+    /**
+     * 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: src/org/openstreetmap/josm/data/protobuf/ProtoBufParser.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtoBufParser.java b/src/org/openstreetmap/josm/data/protobuf/ProtoBufParser.java
new file mode 100644
--- /dev/null	(date 1613514992336)
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtoBufParser.java	(date 1613514992336)
@@ -0,0 +1,223 @@
+// 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 {
+    /**
+     * Used to get the most significant byte
+     */
+    static final byte MOST_SIGNIFICANT_BYTE = (byte) (1 << 7);
+    /** 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;
+    // TODO switch to a better parser
+    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);
+        }
+    }
+
+    @Override
+    public void close() {
+        try {
+            this.inputStream.close();
+        } catch (IOException e) {
+            Logging.error(e);
+        }
+    }
+
+    /**
+     * 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();
+    }
+
+    /**
+     * 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 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) {
+            // 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;
+    }
+
+    /**
+     * 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);
+    }
+
+    /**
+     * 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;
+    }
+
+    /**
+     * 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);
+    }
+
+    /**
+     * 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;
+    }
+
+    /**
+     * 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;
+    }
+
+    /**
+     * 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();
+        final int shift = (value > Integer.MAX_VALUE ? Long.BYTES : Integer.BYTES) * 8 - 1;
+        return convertLong((value << 1) ^ (value >>> shift));
+    }
+}
Index: src/org/openstreetmap/josm/data/protobuf/ProtoBufRecord.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtoBufRecord.java b/src/org/openstreetmap/josm/data/protobuf/ProtoBufRecord.java
new file mode 100644
--- /dev/null	(date 1613514399067)
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtoBufRecord.java	(date 1613514399067)
@@ -0,0 +1,140 @@
+// 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 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;
+    }
+
+    /**
+     * Get the raw bytes for this record
+     * @return The bytes
+     */
+    public byte[] getBytes() {
+        return this.bytes;
+    }
+
+    /**
+     * 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);
+    }
+
+    /**
+     * 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 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 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 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 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;
+    }
+
+    @Override
+    public void close() {
+        this.bytes = null;
+    }
+}
Index: src/org/openstreetmap/josm/data/protobuf/WireType.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/protobuf/WireType.java b/src/org/openstreetmap/josm/data/protobuf/WireType.java
new file mode 100644
--- /dev/null	(date 1612915207059)
+++ b/src/org/openstreetmap/josm/data/protobuf/WireType.java	(date 1612915207059)
@@ -0,0 +1,46 @@
+// 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: src/org/openstreetmap/josm/data/vector/VectorDataSet.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
new file mode 100644
--- /dev/null	(date 1615333673005)
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java	(date 1615333673005)
@@ -0,0 +1,537 @@
+package org.openstreetmap.josm.data.vector;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Color;
+import java.awt.geom.Area;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Path2D;
+import java.awt.geom.PathIterator;
+import java.awt.geom.Point2D;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.openstreetmap.gui.jmapviewer.Coordinate;
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.josm.data.DataSource;
+import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Feature;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
+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.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.PrimitiveId;
+import org.openstreetmap.josm.data.osm.QuadBucketPrimitiveStore;
+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.DataSetListener;
+import org.openstreetmap.josm.tools.Geometry;
+import org.openstreetmap.josm.tools.ListenerList;
+import org.openstreetmap.josm.tools.Logging;
+import org.openstreetmap.josm.tools.SubclassFilteredCollection;
+
+import sun.reflect.generics.reflectiveObjects.NotImplementedException;
+
+/**
+ * A data class for Vector Data
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, VectorWay, VectorRelation> {
+    private final Map<Integer, VectorDataStore> dataStoreMap = new HashMap<>();
+    private final CopyOnWriteArrayList<DataSetListener> listeners = new CopyOnWriteArrayList<>();
+    private final Collection<PrimitiveId> selected = new HashSet<>();
+    // 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 ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
+    private String name;
+    /** 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;
+
+    @Override public Collection<DataSource> getDataSources() {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        return dataStore.getDataSources();
+    }
+
+    /**
+     * Add a data source
+     * @param currentZoom the zoom
+     * @param dataSource The datasource to add at the zoom level
+     */
+    public void addDataSource(int currentZoom, DataSource dataSource) {
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        dataStore.addDataSource(dataSource);
+    }
+
+    @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
+    }
+
+    @Override public String getName() {
+        return this.name;
+    }
+
+    @Override public void setName(String name) {
+        this.name = name;
+    }
+
+    @Override public void addPrimitive(VectorPrimitive primitive) {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        if (!dataStore.getPrimitivesMap().containsKey(primitive.getPrimitiveId())) {
+            dataStore.getPrimitivesMap().put(primitive.getPrimitiveId(), primitive);
+            dataStore.getAllPrimitives().add(primitive);
+        }
+    }
+
+    @Override public void clear() {
+        this.dataStoreMap.clear();
+    }
+
+    @Override public List<VectorNode> searchNodes(BBox bbox) {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        return dataStore.getStore().searchNodes(bbox);
+    }
+
+    @Override public boolean containsNode(VectorNode vectorNode) {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        return dataStore.getStore().containsNode(vectorNode);
+    }
+
+    @Override public List<VectorWay> searchWays(BBox bbox) {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        return dataStore.getStore().searchWays(bbox);
+    }
+
+    @Override public boolean containsWay(VectorWay vectorWay) {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        return dataStore.getStore().containsWay(vectorWay);
+    }
+
+    @Override public List<VectorRelation> searchRelations(BBox bbox) {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        return dataStore.getStore().searchRelations(bbox);
+    }
+
+    @Override public boolean containsRelation(VectorRelation vectorRelation) {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        return dataStore.getStore().containsRelation(vectorRelation);
+    }
+
+    @Override public VectorPrimitive getPrimitiveById(PrimitiveId primitiveId) {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        return dataStore.getPrimitivesMap().get(primitiveId);
+    }
+
+    @Override public <T extends VectorPrimitive> Collection<T> getPrimitives(
+      Predicate<? super VectorPrimitive> predicate) {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        return new SubclassFilteredCollection<>(dataStore.getAllPrimitives(), 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;
+    }
+
+    @Override public Lock getReadLock() {
+        return this.readWriteLock.readLock();
+    }
+
+    @Override public Collection<WaySegment> getHighlightedVirtualNodes() {
+        // TODO?
+        return Collections.emptyList();
+    }
+
+    @Override public void setHighlightedVirtualNodes(Collection<WaySegment> waySegments) {
+        // TODO?
+    }
+
+    @Override public Collection<WaySegment> getHighlightedWaySegments() {
+        // TODO?
+        return Collections.emptyList();
+    }
+
+    @Override public void setHighlightedWaySegments(Collection<WaySegment> waySegments) {
+        // TODO?
+    }
+
+    @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() {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, VectorDataStore::new);
+        return dataStore.getAllPrimitives().stream().filter(primitive -> this.selected.contains(primitive.getPrimitiveId())).collect(
+          Collectors.toList());
+    }
+
+    @Override public boolean selectionEmpty() {
+        return this.selected.isEmpty();
+    }
+
+    @Override public boolean isSelected(VectorPrimitive osm) {
+        return this.selected.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) {
+        osm.forEach(primitiveId -> {
+            if (this.selected.contains(primitiveId)) {
+                this.selected.remove(primitiveId);
+            } else {
+                this.selected.add(primitiveId);
+            }
+        });
+    }
+
+    @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.selected.clear();
+        osm.forEach(this.selected::add);
+    }
+
+    @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) {
+        osm.forEach(this.selected::add);
+    }
+
+    @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.selected).stream());
+    }
+
+    private void clearSelectionImpl(Stream<? extends PrimitiveId> osm) {
+        osm.forEach(this.selected::remove);
+    }
+
+    @Override public void addSelectionListener(DataSelectionListener listener) {
+        this.dataSelectionListenerListenerList.addListener(listener);
+    }
+
+    @Override public void removeSelectionListener(DataSelectionListener listener) {
+        this.dataSelectionListenerListenerList.removeListener(listener);
+    }
+
+    @Override public void clearMappaintCache() {
+        // TODO?
+    }
+
+    public void setZoom(int zoom) {
+        try {
+            this.readWriteLock.writeLock().lockInterruptibly();
+            this.zoom = zoom;
+        } catch (InterruptedException e) {
+            Logging.error(e);
+            Thread.currentThread().interrupt();
+        } finally {
+            if (this.readWriteLock.isWriteLockedByCurrentThread()) {
+                this.readWriteLock.writeLock().unlock();
+            }
+        }
+    }
+
+    public int getZoom() {
+        return this.zoom;
+    }
+
+    public <T extends Tile & VectorTile> void addTileData(T tile) {
+        ICoordinate upperLeft = tile.getTileSource().tileXYToLatLon(tile);
+        ICoordinate lowerRight = tile.getTileSource().xyToLatLon(tile.getExtent(), tile.getExtent(), tile.getZoom());
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(tile.getZoom(), VectorDataStore::new);
+        try {
+            this.readWriteLock.writeLock().lockInterruptibly();
+            dataStore.addTile(tile);
+        } catch (InterruptedException e) {
+            Logging.error(e);
+            Thread.currentThread().interrupt();
+        } finally {
+            if (this.readWriteLock.isWriteLockedByCurrentThread()) {
+                this.readWriteLock.writeLock().unlock();
+            }
+        }
+    }
+
+    private static class DataStore<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>> {
+        protected final int zoom;
+        protected final QuadBucketPrimitiveStore<N, W, R> store = new QuadBucketPrimitiveStore<>();
+        protected final Storage<O> allPrimitives = new Storage<>(new Storage.PrimitiveIdHash(), true);
+        protected final Set<Tile> addedTiles = new HashSet<>();
+        protected final Map<PrimitiveId, O> primitivesMap = allPrimitives
+          .foreignKey(new Storage.PrimitiveIdHash());
+        protected final Collection<DataSource> dataSources = new LinkedList<>();
+
+        public DataStore(int zoom) {
+            this.zoom = zoom;
+        }
+
+        public int getZoom() {
+            return this.zoom;
+        }
+
+        public QuadBucketPrimitiveStore<N, W, R> getStore() {
+            return this.store;
+        }
+
+        public Storage<O> getAllPrimitives() {
+            return this.allPrimitives;
+        }
+
+        public Map<PrimitiveId, O> getPrimitivesMap() {
+            return this.primitivesMap;
+        }
+
+        public Collection<DataSource> getDataSources() {
+            return Collections.unmodifiableCollection(dataSources);
+        }
+
+        public void addDataSource(DataSource dataSource) {
+            this.dataSources.add(dataSource);
+        }
+
+        protected void addPrimitive(O primitive) {
+            this.store.addPrimitive(primitive);
+            this.allPrimitives.add(primitive);
+            this.primitivesMap.put(primitive.getPrimitiveId(), primitive);
+        }
+    }
+
+    private static class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay, VectorRelation> {
+        public VectorDataStore(int zoom) {
+            super(zoom);
+        }
+
+        private <T extends Tile & VectorTile> VectorNode pointToNode(T tile, Layer layer, Collection<VectorPrimitive> featureObjects, int x, int y) {
+            final ICoordinate upperLeft = tile.getTileSource().tileXYToLatLon(tile);
+            final int layerExtent = layer.getExtent() * 2;
+            final ICoordinate lowerRight = tile.getTileSource().tileXYToLatLon(tile.getXtile() + 1, tile.getYtile() + 1, tile.getZoom());
+            final ICoordinate coords = new Coordinate(upperLeft.getLat() - (upperLeft.getLat() - lowerRight.getLat()) * y / layerExtent,
+              upperLeft.getLon() + (lowerRight.getLon() - upperLeft.getLon()) * x / layerExtent);
+            final Collection<VectorNode> nodes = this.store.searchNodes(new BBox(coords.getLon(), coords.getLat()));
+            if (!nodes.isEmpty()) {
+                return nodes.iterator().next();
+            }
+            final VectorNode 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);
+            return pathIteratorToObjects(tile, layer, featureObjects, pathIterator).stream().filter(VectorWay.class::isInstance).map(VectorWay.class::cast).collect(
+              Collectors.toList());
+        }
+
+        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 NotImplementedException();
+                }
+            } 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;
+        }
+
+        public synchronized <T extends Tile & VectorTile> void addTile(T tile) {
+            Optional<Tile> previous = this.addedTiles.stream().filter(t -> t.getTileXY().equals(tile.getTileXY()) && t.getZoom() == tile.getZoom()).findAny();
+            // Check if we have already added the tile (just to save processing time)
+            if (!previous.isPresent() || !previous.get().isLoaded() && !previous.get().isLoading()) {
+                if (previous.isPresent()) {
+                    this.addedTiles.remove(previous.get());
+                }
+                this.addedTiles.add(tile);
+                for (Layer layer : tile.getLayers()) {
+                    layer.getGeometry().forEach(geometry -> {
+                        Collection<VectorPrimitive> primitives = new ArrayList<>(geometry.getShapes().size());
+                        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);
+                            } else {
+                                // We shouldn't hit this, but just in case
+                                throw new NotImplementedException();
+                            }
+                            primaryFeatureObjects.add(primitive);
+                        });
+                        final VectorPrimitive primitive;
+                        if (primaryFeatureObjects.size() == 1) {
+                            primitive = primaryFeatureObjects.get(0);
+                            if (primitive instanceof IRelation) {
+                                // This should always be a multipolygon
+                                primitive.put("type", "multipolygon");
+                            }
+                        } else if (!primaryFeatureObjects.isEmpty()) {
+                            VectorRelation relation = new VectorRelation(layer.getName());
+                            primaryFeatureObjects.stream().map(prim -> new VectorRelationMember("", prim)).forEach(relation::addRelationMember);
+                            primitive = relation;
+                        } else {
+                            return;
+                        }
+                        Feature feature = geometry.getFeature();
+                        primitive.setId(feature.getId());
+                        feature.getTags().forEach(primitive::put);
+                        featureObjects.forEach(this::addPrimitive);
+                        primaryFeatureObjects.forEach(this::addPrimitive);
+                        this.addPrimitive(primitive);
+                    });
+                }
+            }
+        }
+    }
+}
Index: src/org/openstreetmap/josm/data/vector/VectorNode.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/vector/VectorNode.java b/src/org/openstreetmap/josm/data/vector/VectorNode.java
new file mode 100644
--- /dev/null	(date 1615311643215)
+++ b/src/org/openstreetmap/josm/data/vector/VectorNode.java	(date 1615311643215)
@@ -0,0 +1,95 @@
+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;
+
+    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 coor) {
+        this.lat = coor.lat();
+        this.lon = coor.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);
+    }
+
+    @Override public OsmPrimitiveType getType() {
+        return OsmPrimitiveType.NODE;
+    }
+}
Index: src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java b/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
new file mode 100644
--- /dev/null	(date 1615305655277)
+++ b/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java	(date 1615305655277)
@@ -0,0 +1,222 @@
+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.NameFormatter;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.data.osm.TagMap;
+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 {
+    private VectorDataSet dataSet;
+    private TagMap tags;
+    private boolean highlighted;
+    private StyleCache mappaintStyle;
+    private boolean mappaintStyleUpToDate;
+    private final String layer;
+
+    public VectorPrimitive(String layer) {
+        this.layer = layer;
+    }
+
+    @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.tags.size() > 0;
+    }
+
+    @Override
+    public VectorDataSet getDataSet() {
+        return this.dataSet;
+    }
+
+    protected void setDataSet(VectorDataSet dataSet) {
+        this.dataSet = dataSet;
+    }
+
+    @Override public StyleCache getCachedStyle() {
+        return this.mappaintStyle;
+    }
+
+    @Override public void setCachedStyle(StyleCache mappaintStyle) {
+        this.mappaintStyle = mappaintStyle;
+        if (mappaintStyle != null) {
+            this.declareCachedStyleUpToDate();
+        } else {
+            this.mappaintStyleUpToDate = false;
+        }
+    }
+
+    @Override public boolean isCachedStyleUpToDate() {
+        return this.mappaintStyleUpToDate;
+    }
+
+    @Override public void declareCachedStyleUpToDate() {
+        this.mappaintStyleUpToDate = true;
+        this.clearCachedStyle();
+    }
+
+    @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<OsmPrimitive> getReferrers(boolean allowWithoutDataset) {
+        return referrers(allowWithoutDataset, OsmPrimitive.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 == null)
+            return;
+        else 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;
+    }
+}
Index: src/org/openstreetmap/josm/data/vector/VectorRelation.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/vector/VectorRelation.java b/src/org/openstreetmap/josm/data/vector/VectorRelation.java
new file mode 100644
--- /dev/null	(date 1615308122983)
+++ b/src/org/openstreetmap/josm/data/vector/VectorRelation.java	(date 1615308122983)
@@ -0,0 +1,77 @@
+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<>();
+    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() {
+        final BBox bbox = new BBox();
+        for (IPrimitive member : this.getMemberPrimitivesList()) {
+            bbox.add(member.getBBox());
+        }
+        return bbox;
+    }
+
+    protected void addRelationMember(VectorRelationMember member) {
+        this.members.add(member);
+    }
+
+    @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: src/org/openstreetmap/josm/data/vector/VectorRelationMember.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/vector/VectorRelationMember.java b/src/org/openstreetmap/josm/data/vector/VectorRelationMember.java
new file mode 100644
--- /dev/null	(date 1615308200662)
+++ b/src/org/openstreetmap/josm/data/vector/VectorRelationMember.java	(date 1615308200662)
@@ -0,0 +1,51 @@
+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;
+
+public class VectorRelationMember implements IRelationMember<VectorPrimitive> {
+    private final String role;
+    private final VectorPrimitive member;
+    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: src/org/openstreetmap/josm/data/vector/VectorWay.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/vector/VectorWay.java b/src/org/openstreetmap/josm/data/vector/VectorWay.java
new file mode 100644
--- /dev/null	(date 1615300579563)
+++ b/src/org/openstreetmap/josm/data/vector/VectorWay.java	(date 1615300579563)
@@ -0,0 +1,102 @@
+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<>();
+    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() {
+        final BBox bbox = new BBox();
+        for (INode node : this.getNodes()) {
+            bbox.add(node.getBBox());
+        }
+        return bbox;
+    }
+
+    @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 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 void setNodes(List<VectorNode> nodes) {
+        this.nodes.clear();
+        this.nodes.addAll(nodes);
+    }
+
+    @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: src/org/openstreetmap/josm/gui/io/importexport/ImageImporter.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/gui/io/importexport/ImageImporter.java b/src/org/openstreetmap/josm/gui/io/importexport/ImageImporter.java
--- a/src/org/openstreetmap/josm/gui/io/importexport/ImageImporter.java	(revision 17557)
+++ b/src/org/openstreetmap/josm/gui/io/importexport/ImageImporter.java	(date 1615219604492)
@@ -8,9 +8,12 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import javax.imageio.ImageIO;
@@ -19,6 +22,7 @@
 import org.openstreetmap.josm.gui.layer.GpxLayer;
 import org.openstreetmap.josm.gui.layer.geoimage.GeoImageLayer;
 import org.openstreetmap.josm.gui.progress.ProgressMonitor;
+import org.openstreetmap.josm.io.CachedFile;
 import org.openstreetmap.josm.io.IllegalDataException;
 
 /**
@@ -26,6 +30,12 @@
  * @since 17548
  */
 public class ImageImporter extends FileImporter {
+
+    /** Check if the filename starts with a borked path ({@link java.io.File#File} drops consecutive {@code /} characters). */
+    private static final Pattern URL_START_BAD = Pattern.compile("^(https?:/)([^/].*)$");
+    /** Check for the beginning of a "good" url */
+    private static final Pattern URL_START_GOOD = Pattern.compile("^https?://.*$");
+
     private GpxLayer gpx;
 
     /**
@@ -90,7 +100,7 @@
         try {
             List<File> files = new ArrayList<>();
             Set<String> visitedDirs = new HashSet<>();
-            addRecursiveFiles(files, visitedDirs, sel, progressMonitor.createSubTaskMonitor(1, true));
+            addRecursiveFiles(this.options, files, visitedDirs, sel, progressMonitor.createSubTaskMonitor(1, true));
 
             if (progressMonitor.isCanceled())
                 return;
@@ -106,6 +116,11 @@
 
     static void addRecursiveFiles(List<File> files, Set<String> visitedDirs, List<File> sel, ProgressMonitor progressMonitor)
             throws IOException {
+        addRecursiveFiles(EnumSet.noneOf(Options.class), files, visitedDirs, sel, progressMonitor);
+    }
+
+    static void addRecursiveFiles(Set<Options> options, List<File> files, Set<String> visitedDirs, List<File> sel,
+            ProgressMonitor progressMonitor) throws IOException {
 
         if (progressMonitor.isCanceled())
             return;
@@ -117,13 +132,28 @@
                     if (visitedDirs.add(f.getCanonicalPath())) { // Do not loop over symlinks
                         File[] dirFiles = f.listFiles(); // Can be null for some strange directories (like lost+found)
                         if (dirFiles != null) {
-                            addRecursiveFiles(files, visitedDirs, Arrays.asList(dirFiles), progressMonitor.createSubTaskMonitor(1, true));
+                            addRecursiveFiles(options, files, visitedDirs, Arrays.asList(dirFiles),
+                                    progressMonitor.createSubTaskMonitor(1, true));
                         }
                     } else {
                         progressMonitor.worked(1);
                     }
                 } else {
-                    if (FILE_FILTER.accept(f)) {
+                    /* Check if the path is a web path, and if so, ensure that it is "correct" */
+                    final String path = f.getPath();
+                    Matcher matcherBad = URL_START_BAD.matcher(path);
+                    final String realPath;
+                    if (matcherBad.matches()) {
+                        realPath = matcherBad.replaceFirst(matcherBad.group(1) + "/" + matcherBad.group(2));
+                    } else {
+                        realPath = path;
+                    }
+                    if (URL_START_GOOD.matcher(realPath).matches() && FILE_FILTER.accept(f)
+                            && options.contains(Options.ALLOW_WEB_RESOURCES)) {
+                        try (CachedFile cachedFile = new CachedFile(realPath)) {
+                            files.add(cachedFile.getFile());
+                        }
+                    } else if (FILE_FILTER.accept(f)) {
                         files.add(f);
                     }
                     progressMonitor.worked(1);
Index: src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
new file mode 100644
--- /dev/null	(date 1615331765548)
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java	(date 1615331765548)
@@ -0,0 +1,158 @@
+// 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.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 org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
+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.LayerShower;
+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.visitor.paint.AbstractMapRenderer;
+import org.openstreetmap.josm.data.osm.visitor.paint.MapRendererFactory;
+import org.openstreetmap.josm.data.vector.VectorDataSet;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.NavigatableComponent;
+import org.openstreetmap.josm.gui.layer.AbstractCachedTileSourceLayer;
+import org.openstreetmap.josm.gui.layer.OsmDataLayer;
+import org.openstreetmap.josm.spi.preferences.Config;
+
+/**
+ * A layer for MapBox Vector Tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSource> implements LayerShower, TileListener,
+  NavigatableComponent.ZoomChangeListener {
+    private static final String CACHE_REGION_NAME = "MVT";
+    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);
+        NavigatableComponent.addZoomChangeListener(this);
+    }
+
+    @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());
+        boolean active = mv.getLayerManager().getActiveLayer() == this;
+        boolean inactive = !active && Config.getPref().getBoolean("draw.data.inactive_color", true);
+        boolean virtual = !inactive && mv.isVirtualNodesEnabled();
+        AbstractMapRenderer painter = MapRendererFactory.getInstance().createActiveRenderer(g, mv, inactive);
+        painter.enableSlowOperations(mv.getMapMover() == null || !mv.getMapMover().movementInProgress()
+          || !OsmDataLayer.PROPERTY_HIDE_LABELS_WHILE_DRAGGING.get());
+        painter.render(this.dataSet, virtual, box);
+    }
+
+    @Override
+    protected MapboxVectorTileSource getTileSource() {
+        MapboxVectorTileSource source = new MapboxVectorTileSource(this.info);
+        this.info.setAttribution(source);
+        return source;
+    }
+
+    @Override
+    public Tile createTile(MapboxVectorTileSource source, int x, int y, int zoom) {
+        final MVTTile tile = new MVTTile(source, x, y, zoom);
+        tile.setLayerShower(this);
+        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);
+        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) -> !value); this.invalidate(); }));
+        }
+        return actions.toArray(new Action[0]);
+    }
+
+    private static class EnableLayerAction extends AbstractAction implements LayerAction {
+        private final String layer;
+        private final Consumer<String> consumer;
+        private final BooleanSupplier state;
+        public 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);
+    }
+
+    @Override
+    public List<String> layersToShow() {
+        return this.layerNames.entrySet().stream().filter(Map.Entry::getValue).map(Map.Entry::getKey).collect(Collectors.toList());
+    }
+}
Index: src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java b/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
--- a/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java	(revision 17557)
+++ b/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java	(date 1615333106450)
@@ -87,6 +87,7 @@
 import org.openstreetmap.josm.data.imagery.OffsetBookmark;
 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;
 import org.openstreetmap.josm.data.preferences.IntegerProperty;
@@ -110,6 +111,7 @@
 import org.openstreetmap.josm.gui.layer.imagery.IncreaseZoomAction;
 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;
 import org.openstreetmap.josm.gui.layer.imagery.TileAnchor;
@@ -890,7 +892,7 @@
             if (coordinateConverter.requiresReprojection()) {
                 tile = new ReprojectionTile(tileSource, x, y, zoom);
             } else {
-                tile = new Tile(tileSource, x, y, zoom);
+                tile = createTile(tileSource, x, y, zoom);
             }
             tileCache.addTile(tile);
         }
@@ -1029,6 +1031,33 @@
         }
     }
 
+    /**
+     * Draw a vector tile on screen.
+     * @param g the Graphics2D
+     * @param tile the vector tile
+     * @param anchorImage tile anchor in image coordinates
+     * @param anchorScreen tile anchor in screen coordinates
+     * @param clip clipping region in screen coordinates (can be null)
+     */
+    private void drawVectorTileInside(Graphics2D g, VectorTile tile, TileAnchor anchorImage, TileAnchor anchorScreen, Shape clip) {
+        AffineTransform imageToScreen = anchorImage.convert(anchorScreen);
+        Point2D screen0 = imageToScreen.transform(new Point2D.Double(0, 0), null);
+        Point2D screen1 = imageToScreen.transform(new Point2D.Double(
+                tile.getExtent(), tile.getExtent()), null);
+
+        Shape oldClip = null;
+        if (clip != null) {
+            oldClip = g.getClip();
+            g.clip(clip);
+        }
+        tile.paint(g, (int) Math.round(screen0.getX()), (int) Math.round(screen0.getY()),
+                (int) Math.round(screen1.getX()) - (int) Math.round(screen0.getX()),
+                (int) Math.round(screen1.getY()) - (int) Math.round(screen0.getY()), this.currentZoomLevel, this);
+        if (clip != null) {
+            g.setClip(oldClip);
+        }
+    }
+
     private List<Tile> paintTileImages(Graphics2D g, TileSet ts) {
         Object paintMutex = new Object();
         List<TilePosition> missed = Collections.synchronizedList(new ArrayList<>());
@@ -1043,7 +1072,7 @@
                     img = getLoadedTileImage(tile);
                     anchorImage = getAnchor(tile, img);
                 }
-                if (img == null || anchorImage == null) {
+                if (img == null || anchorImage == null || (tile instanceof VectorTile && !tile.isLoaded())) {
                     miss = true;
                 }
             }
@@ -1052,12 +1081,18 @@
                 return;
             }
 
-            img = applyImageProcessors(img);
+            if (img != null) {
+                img = applyImageProcessors(img);
+            }
 
             TileAnchor anchorScreen = coordinateConverter.getScreenAnchorForTile(tile);
             synchronized (paintMutex) {
                 //cannot paint in parallel
-                drawImageInside(g, img, anchorImage, anchorScreen, null);
+                if (tile instanceof VectorTile) {
+                    // drawVectorTileInside(g, (VectorTile) tile, anchorImage, anchorScreen, null); TODO
+                } else {
+                    drawImageInside(g, img, anchorImage, anchorScreen, null);
+                }
             }
             MapView mapView = MainApplication.getMap().mapView;
             if (tile instanceof ReprojectionTile && ((ReprojectionTile) tile).needsUpdate(mapView.getScale())) {
@@ -1864,7 +1899,7 @@
 
                 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));
                     }
                 }
             }
@@ -1970,6 +2005,20 @@
         return SaveActionBase.createAndOpenSaveFileChooser(tr("Save WMS file"), WMSLayerImporter.FILE_FILTER);
     }
 
+    /**
+     * 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() {
         super.destroy();
@@ -1990,6 +2039,10 @@
             allocateCacheMemory();
             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();
                 Color oldColor = g.getColor();
Index: src/org/openstreetmap/josm/gui/layer/ImageryLayer.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java b/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java
--- a/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java	(revision 17557)
+++ b/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java	(date 1612915207126)
@@ -37,6 +37,7 @@
 import org.openstreetmap.josm.gui.MapView;
 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;
 import org.openstreetmap.josm.tools.ImageProcessor;
@@ -168,6 +169,8 @@
         case BING:
         case SCANEX:
             return new TMSLayer(info);
+        case MVT:
+            return new MVTLayer(info);
         default:
             throw new AssertionError(tr("Unsupported imagery type: {0}", info.getImageryType()));
         }
Index: src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java b/src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java
new file mode 100644
--- /dev/null	(date 1612973816367)
+++ b/src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java	(date 1612973816367)
@@ -0,0 +1,92 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.preferences.imagery;
+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;
+
+import javax.swing.JLabel;
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+import java.util.Arrays;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+/**
+ * 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", "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() {
+        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: src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java b/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java
--- a/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java	(revision 17557)
+++ b/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java	(date 1613002098052)
@@ -312,6 +312,7 @@
         activeToolbar.add(new NewEntryAction(ImageryInfo.ImageryType.WMS));
         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);
         add(activePanel, GBC.eol().fill(GridBagConstraints.BOTH).weight(2.0, 0.4).insets(5, 0, 0, 5));
@@ -439,6 +440,9 @@
                 break;
             case WMTS:
                 icon = /* ICON(dialogs/) */ "add_wmts";
+                break;
+            case MVT:
+                icon = /* ICON(dialogs/) */ "add_mvt";
                 break;
             default:
                 break;
@@ -460,6 +464,9 @@
             case WMTS:
                 p = new AddWMTSLayerPanel();
                 break;
+            case MVT:
+                p = new AddMVTLayerPanel();
+                break;
             default:
                 throw new IllegalStateException("Type " + type + " not supported");
             }
@@ -741,7 +748,7 @@
     private static boolean confirmEulaAcceptance(PreferenceTabbedPane gui, String eulaUrl) {
         URL url;
         try {
-            url = new URL(eulaUrl.replaceAll("\\{lang\\}", LanguageInfo.getWikiLanguagePrefix()));
+            url = new URL(eulaUrl.replaceAll("\\{lang}", LanguageInfo.getWikiLanguagePrefix()));
             JosmEditorPane htmlPane;
             try {
                 htmlPane = new JosmEditorPane(url);
@@ -749,7 +756,7 @@
                 Logging.trace(e1);
                 // 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) {
                     Logging.debug(e2);
Index: test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java b/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java
new file mode 100644
--- /dev/null	(date 1613517720331)
+++ b/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java	(date 1613517720331)
@@ -0,0 +1,309 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.awt.Shape;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.PathIterator;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Paths;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Feature;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Geometry;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
+import org.openstreetmap.josm.data.osm.BBox;
+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.gui.layer.OsmDataLayer;
+import org.openstreetmap.josm.io.Compression;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+/**
+ * Test class for {@link ProtoBufParser} and {@link ProtoBufRecord}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class ProtoBufTest {
+    @RegisterExtension
+    JOSMTestRules josmTestRules = new JOSMTestRules().preferences();
+
+    /**
+     * Test simple message.
+     * Check that a simple message is readable
+     * @throws IOException - if an IO error occurs
+     */
+    @Test
+    void testSimpleMessage() throws IOException {
+        ProtoBufParser parser = new ProtoBufParser(new byte[] {(byte) 0x08, (byte) 0x96, (byte) 0x01});
+        ProtoBufRecord record = new ProtoBufRecord(parser);
+        assertEquals(WireType.VARINT, record.getType());
+        assertEquals(150, record.asUnsignedVarInt().intValue());
+    }
+
+    /**
+     * Test reading tile from Mapillary ( 14/3251/6258 )
+     * @throws IOException if there is a problem reading the file
+     */
+    @Test
+    void testRead_14_3251_6258() throws IOException {
+        File vectorTile = Paths.get(TestUtils.getTestDataRoot(), "pbf", "6258.mvt").toFile();
+        InputStream inputStream = Compression.getUncompressedFileInputStream(vectorTile);
+        Collection<ProtoBufRecord> records = new ProtoBufParser(inputStream).allRecords();
+        assertEquals(2, records.size());
+        List<Layer> layers = new ArrayList<>();
+        for (ProtoBufRecord record : records) {
+            if (record.getField() == Layer.LAYER_FIELD) {
+                layers.add(new Layer(record.getBytes()));
+            } else {
+                fail(MessageFormat.format("Invalid field {0}", record.getField()));
+            }
+        }
+        Layer mapillarySequences = layers.get(0);
+        Layer mapillaryPictures = layers.get(1);
+        assertEquals("mapillary-sequences", mapillarySequences.getName());
+        assertEquals("mapillary-images", mapillaryPictures.getName());
+        assertEquals(2048, mapillarySequences.getExtent());
+        assertEquals(2048, mapillaryPictures.getExtent());
+
+        assertEquals(1, mapillarySequences.getFeatures().stream().filter(feature -> feature.getId() == 241083111).count());
+        Feature testSequence = mapillarySequences.getFeatures().stream().filter(feature -> feature.getId() == 241083111).findAny().orElse(null);
+        assertEquals("jgxkXqVFM4jepMG3vP5Q9A", testSequence.getTags().get("key"));
+        assertEquals("C15Ul6qVMfQFlzRcmQCLcA", testSequence.getTags().get("ikey"));
+        assertEquals("x0hTY8cakpy0m3ui1GaG1A", testSequence.getTags().get("userkey"));
+        assertEquals(Long.valueOf(1565196718638L), Long.valueOf(testSequence.getTags().get("captured_at")));
+        assertEquals(0, Integer.parseInt(testSequence.getTags().get("pano")));
+    }
+
+    /**
+     * Test reading tile from OpenInfraMap ( 16/13014/25030 )
+     * @throws IOException if there is a problem reading the file
+     */
+    @Test
+    void testRead_16_13014_25030() throws IOException {
+        // TODO finish
+        File vectorTile = Paths.get(TestUtils.getTestDataRoot(), "pbf", "16", "13014", "25030.pbf").toFile();
+        InputStream inputStream = Compression.getUncompressedFileInputStream(vectorTile);
+        Collection<ProtoBufRecord> records = new ProtoBufParser(inputStream).allRecords();
+        List<Layer> layers = new ArrayList<>();
+        for (ProtoBufRecord record : records) {
+            if (record.getField() == Layer.LAYER_FIELD) {
+                layers.add(new Layer(record.getBytes()));
+            } else {
+                fail(MessageFormat.format("Invalid field {0}", record.getField()));
+            }
+        }
+        assertEquals(19, layers.size());
+        List<Layer> dataLayers = layers.stream().filter(layer -> !layer.getFeatures().isEmpty()).collect(Collectors.toList());
+        // power_plant, power_plant_point, power_generator, power_heatmap_solar, and power_generator_area
+        assertEquals(5, dataLayers.size());
+    }
+
+    @Test
+    void testRead_17_26028_50060() throws IOException {
+        File vectorTile = Paths.get(TestUtils.getTestDataRoot(), "pbf", "17", "26028", "50060.pbf").toFile();
+        InputStream inputStream = Compression.getUncompressedFileInputStream(vectorTile);
+        Collection<ProtoBufRecord> records = new ProtoBufParser(inputStream).allRecords();
+        List<Layer> layers = new ArrayList<>();
+        for (ProtoBufRecord record : records) {
+            if (record.getField() == Layer.LAYER_FIELD) {
+                layers.add(new Layer(record.getBytes()));
+            } else {
+                fail(MessageFormat.format("Invalid field {0}", record.getField()));
+            }
+        }
+        assertEquals(19, layers.size());
+        List<Layer> dataLayers = layers.stream().filter(layer -> !layer.getFeatures().isEmpty()).collect(Collectors.toList());
+        // power_plant, power_plant_point, power_generator, power_heatmap_solar, and power_generator_area
+        assertEquals(5, dataLayers.size());
+
+        // power_generator_area was rendered incorrectly
+        final Layer powerGeneratorArea = dataLayers.stream().filter(layer -> "power_generator_area".equals(layer.getName())).findAny().orElse(null);
+        assertNotNull(powerGeneratorArea);
+        final int extent = powerGeneratorArea.getExtent();
+        // 17/26028/50060 bounds
+        final BBox tileExtent = new BBox(new LatLon(39.068246, -108.511959), new LatLon(39.070381, -108.509219));
+        final DataSet ds = new DataSet();
+        for (Geometry feature : powerGeneratorArea.getGeometry()) {
+            final Collection<OsmPrimitive> primitives = feature.getShapes().stream().flatMap(shape -> convertShape(tileExtent, extent, shape).stream()).collect(Collectors.toList());
+            primitives.forEach(ds::addPrimitive);
+            final OsmPrimitive toTag;
+            if (primitives.size() > 1) {
+                final Relation relation = new Relation();
+                primitives.forEach(prim -> relation.addMember(new RelationMember("", prim)));
+                ds.addPrimitive(relation);
+                toTag = relation;
+            } else {
+                toTag = primitives.iterator().next();
+            }
+            feature.getFeature().getTags().forEach((key, value) -> toTag.put(key, value));
+        }
+        final Way one = new Way();
+        one.addNode(new Node(new LatLon(39.0687509, -108.5100816)));
+        one.addNode(new Node(new LatLon(39.0687509, -108.5095751)));
+        one.addNode(new Node(new LatLon(39.0687169, -108.5095751)));
+        one.addNode(new Node(new LatLon(39.0687169, -108.5100816)));
+        one.addNode(one.getNode(0));
+        one.setOsmId(666293899, 2);
+        final BBox searchBBox = one.getBBox();
+        searchBBox.addPrimitive(one, 0.001);
+        final Collection<Node> searchedNodes = ds.searchNodes(searchBBox);
+        OsmDataLayer testLayer = new OsmDataLayer(ds, "", null);
+        testLayer.autosave(new File("/tmp/test.osm"));
+        assertEquals(4, searchedNodes.size());
+    }
+
+    /**
+     * Convert a latlon to a relative latlon for the bbox
+     * @param tileExtent The tile extent
+     * @param toConvert The shape
+     * @return An OSM primitive representing the shape
+     */
+    private static Collection<OsmPrimitive> convertShape(BBox tileExtent, int extent, Shape toConvert) {
+        final List<Node> nodes = new ArrayList<>();
+        final List<Way> ways = new ArrayList<>();
+        final List<Relation> relations = new ArrayList<>();
+        final PathIterator iterator = toConvert.getPathIterator(null);
+        final List<Node> wayNodes = new ArrayList<>();
+        while (!iterator.isDone()) {
+            final double[] coords = new double[6];
+            final int type = iterator.currentSegment(coords);
+            if (type == PathIterator.SEG_MOVETO || type == PathIterator.SEG_LINETO) {
+                final Node node = convertPointToNode(tileExtent, extent, coords[0], coords[1]);
+                nodes.add(node);
+                if (type == PathIterator.SEG_MOVETO && wayNodes.size() > 1) {
+                    final Way way = new Way();
+                    way.setNodes(wayNodes);
+                    ways.add(way);
+                    wayNodes.clear();
+                } else if (type == PathIterator.SEG_MOVETO) {
+                    wayNodes.clear();
+                }
+                wayNodes.add(node);
+            } else if (type == PathIterator.SEG_CLOSE) {
+                wayNodes.add(wayNodes.get(0));
+                final Way way = new Way();
+                way.setNodes(wayNodes);
+                ways.add(way);
+                wayNodes.clear();
+            }
+            iterator.next();
+        }
+
+        final Collection<OsmPrimitive> primitives = new ArrayList<>(nodes);
+        primitives.addAll(ways);
+        primitives.addAll(relations);
+        return primitives;
+    }
+
+    private static Node convertPointToNode(BBox tileExtent, int extent, double x, double y) {
+        final double latDiff = tileExtent.getTopLeftLat() - tileExtent.getBottomRightLat();
+        final double lonDiff = tileExtent.getBottomRightLon() - tileExtent.getTopLeftLon();
+        final double lat = tileExtent.getTopLeftLat() - y * latDiff / extent;
+        final double lon = tileExtent.getTopLeftLon() - x * lonDiff / extent;
+        return new Node(new LatLon(lat, lon));
+    }
+
+
+    // TODO remove temporary tests or indicate that they are from the vector-tile-js library (BSD-3)
+    @Test
+    void test_14_8801_5371() throws IOException {
+        File vectorTile = Paths.get(TestUtils.getTestDataRoot(), "pbf", "tmp", "14-8801-5371.vector.pbf").toFile();
+        InputStream inputStream = Compression.getUncompressedFileInputStream(vectorTile);
+        Collection<ProtoBufRecord> records = new ProtoBufParser(inputStream).allRecords();
+        List<Layer> layers = new ArrayList<>();
+        for (ProtoBufRecord record : records) {
+            if (record.getField() == Layer.LAYER_FIELD) {
+                layers.add(new Layer(record.getBytes()));
+            } else {
+                fail(MessageFormat.format("Invalid field {0}", record.getField()));
+            }
+        }
+        assertEquals(20, layers.size());
+        Geometry park = layers.stream().filter(layer -> "poi_label".equals(layer.getName())).flatMap(layer -> layer.getGeometry().stream()).filter(g -> g.getFeature().getId() == 3000003150561L).findAny().orElse(null);
+        assertEquals("Mauerpark", park.getFeature().getTags().get("name"));
+        assertEquals("Park", park.getFeature().getTags().get("type"));
+
+        Ellipse2D parkShape = (Ellipse2D) park.getShapes().iterator().next();
+        assertEquals(3898, parkShape.getCenterX());
+        assertEquals(1731, parkShape.getCenterY());
+
+        Geometry road = layers.stream().filter(layer -> "road".equals(layer.getName())).flatMap(layer -> layer.getGeometry().stream()).skip(656).findFirst().orElse(null);
+        PathIterator roadIterator = road.getShapes().iterator().next().getPathIterator(null);
+        double[] coords = new double[6];
+        assertEquals(PathIterator.SEG_MOVETO, roadIterator.currentSegment(coords));
+        assertEquals(1988, coords[0]);
+        assertEquals(306, coords[1]);
+        roadIterator.next();
+        assertEquals(PathIterator.SEG_LINETO, roadIterator.currentSegment(coords));
+        assertEquals(1808, coords[0]);
+        assertEquals(321, coords[1]);
+        roadIterator.next();
+        assertEquals(PathIterator.SEG_LINETO, roadIterator.currentSegment(coords));
+        assertEquals(1506, coords[0]);
+        assertEquals(347, coords[1]);
+    }
+
+    @Test
+    void testSingletonMultiPoint() throws IOException {
+        File vectorTile = Paths.get(TestUtils.getTestDataRoot(), "pbf", "tmp", "singleton-multi-point.pbf").toFile();
+        InputStream inputStream = Compression.getUncompressedFileInputStream(vectorTile);
+        Collection<ProtoBufRecord> records = new ProtoBufParser(inputStream).allRecords();
+        List<Layer> layers = new ArrayList<>();
+        for (ProtoBufRecord record : records) {
+            if (record.getField() == Layer.LAYER_FIELD) {
+                layers.add(new Layer(record.getBytes()));
+            } else {
+                fail(MessageFormat.format("Invalid field {0}", record.getField()));
+            }
+        }
+        assertEquals(1, layers.size());
+        assertEquals(1, layers.get(0).getGeometry().size());
+        Ellipse2D shape = (Ellipse2D) layers.get(0).getGeometry().iterator().next().getShapes().iterator().next();
+        assertEquals(2059, shape.getCenterX());
+        assertEquals(2071, shape.getCenterY());
+    }
+
+    @Test
+    void testReadVarInt() {
+        assertEquals(ProtoBufParser.convertLong(0), bytesToVarInt(0x0));
+        assertEquals(ProtoBufParser.convertLong(1), bytesToVarInt(0x1));
+        assertEquals(ProtoBufParser.convertLong(127), bytesToVarInt(0x7f));
+        // This should b 0xff 0xff 0xff 0xff 0x07, but we drop the leading bit when reading to a byte array
+        Number actual = bytesToVarInt(0x7f, 0x7f, 0x7f, 0x7f, 0x07);
+        assertEquals(ProtoBufParser.convertLong(Integer.MAX_VALUE), actual, MessageFormat.format("Expected {0} but got {1}", Integer.toBinaryString(Integer.MAX_VALUE), Long.toBinaryString(actual.longValue())));
+    }
+
+    @Test
+    void testZigZag() {
+        assertEquals(0, ProtoBufParser.decodeZigZag(0).intValue());
+        assertEquals(-1, ProtoBufParser.decodeZigZag(1).intValue());
+        assertEquals(1, ProtoBufParser.decodeZigZag(2).intValue());
+        assertEquals(-2, ProtoBufParser.decodeZigZag(3).intValue());
+    }
+
+    private Number bytesToVarInt(int... bytes) {
+        byte[] byteArray = new byte[bytes.length];
+        for (int i = 0; i < bytes.length; i++) {
+            byteArray[i] = (byte) bytes[i];
+        }
+        return ProtoBufParser.convertByteArray(byteArray, ProtoBufParser.VAR_INT_BYTE_SIZE);
+    }
+}
