From 900593d0f434dc3ca92919ae2104043a402d0e8e Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 8 Apr 2021 13:37:54 -0600
Subject: [PATCH 01/50] Protobuf: Initial implementation

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/protobuf/ProtoBufPacked.java    |  62 +++++
 .../josm/data/protobuf/ProtoBufParser.java    | 245 ++++++++++++++++++
 .../josm/data/protobuf/ProtoBufRecord.java    | 152 +++++++++++
 .../josm/data/protobuf/WireType.java          |  61 +++++
 test/data/pbf/openinframap/17/26028/50060.pbf | Bin 0 -> 1082 bytes
 .../data/protobuf/ProtoBufParserTest.java     |  51 ++++
 .../data/protobuf/ProtoBufRecordTest.java     |  30 +++
 .../josm/data/protobuf/ProtoBufTest.java      | 211 +++++++++++++++
 8 files changed, 812 insertions(+)
 create mode 100644 src/org/openstreetmap/josm/data/protobuf/ProtoBufPacked.java
 create mode 100644 src/org/openstreetmap/josm/data/protobuf/ProtoBufParser.java
 create mode 100644 src/org/openstreetmap/josm/data/protobuf/ProtoBufRecord.java
 create mode 100644 src/org/openstreetmap/josm/data/protobuf/WireType.java
 create mode 100644 test/data/pbf/openinframap/17/26028/50060.pbf
 create mode 100644 test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufParserTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufRecordTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java

diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtoBufPacked.java b/src/org/openstreetmap/josm/data/protobuf/ProtoBufPacked.java
new file mode 100644
index 000000000..109f8915a
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtoBufPacked.java
@@ -0,0 +1,62 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Parse packed values (only numerical values)
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class ProtoBufPacked {
+    private final byte[] bytes;
+    private final Number[] numbers;
+    private int location;
+
+    /**
+     * Create a new ProtoBufPacked object
+     *
+     * @param bytes The packed bytes
+     */
+    public ProtoBufPacked(byte[] bytes) {
+        this.location = 0;
+        this.bytes = bytes;
+        List<Number> numbersT = new ArrayList<>();
+        while (this.location < bytes.length) {
+            numbersT.add(ProtoBufParser.convertByteArray(this.nextVarInt(), ProtoBufParser.VAR_INT_BYTE_SIZE));
+        }
+
+        this.numbers = new Number[numbersT.size()];
+        for (int i = 0; i < numbersT.size(); i++) {
+            this.numbers[i] = numbersT.get(i);
+        }
+    }
+
+    /**
+     * Get the parsed number array
+     *
+     * @return The number array
+     */
+    public Number[] getArray() {
+        return this.numbers;
+    }
+
+    private byte[] nextVarInt() {
+        List<Byte> byteList = new ArrayList<>();
+        while ((this.bytes[this.location] & ProtoBufParser.MOST_SIGNIFICANT_BYTE)
+          == ProtoBufParser.MOST_SIGNIFICANT_BYTE) {
+            // Get rid of the leading bit (shift left 1, then shift right 1 unsigned)
+            byteList.add((byte) (this.bytes[this.location++] ^ ProtoBufParser.MOST_SIGNIFICANT_BYTE));
+        }
+        // The last byte doesn't drop the most significant bit
+        byteList.add(this.bytes[this.location++]);
+        byte[] byteArray = new byte[byteList.size()];
+        for (int i = 0; i < byteList.size(); i++) {
+            byteArray[i] = byteList.get(i);
+        }
+
+        return byteArray;
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtoBufParser.java b/src/org/openstreetmap/josm/data/protobuf/ProtoBufParser.java
new file mode 100644
index 000000000..18059e339
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtoBufParser.java
@@ -0,0 +1,245 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A basic Protobuf parser
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class ProtoBufParser implements AutoCloseable {
+    /**
+     * The default byte size (see {@link #VAR_INT_BYTE_SIZE} for var ints)
+     */
+    public static final byte BYTE_SIZE = 8;
+    /**
+     * The byte size for var ints (since the first byte is just an indicator for if the var int is done)
+     */
+    public static final byte VAR_INT_BYTE_SIZE = BYTE_SIZE - 1;
+    /**
+     * Used to get the most significant byte
+     */
+    static final byte MOST_SIGNIFICANT_BYTE = (byte) (1 << 7);
+    /**
+     * Convert a byte array to a number (little endian)
+     *
+     * @param bytes    The bytes to convert
+     * @param byteSize The size of the byte. For var ints, this is 7, for other ints, this is 8.
+     * @return An appropriate {@link Number} class.
+     */
+    public static Number convertByteArray(byte[] bytes, byte byteSize) {
+        long number = 0;
+        for (int i = 0; i < bytes.length; i++) {
+            // Need to convert to uint64 in order to avoid bit operation from filling in 1's and overflow issues
+            number += Byte.toUnsignedLong(bytes[i]) << (byteSize * i);
+        }
+        return convertLong(number);
+    }
+
+    /**
+     * Convert a long to an appropriate {@link Number} class
+     *
+     * @param number The long to convert
+     * @return A {@link Number}
+     */
+    public static Number convertLong(long number) {
+        // TODO deal with booleans
+        if (number <= Byte.MAX_VALUE && number >= Byte.MIN_VALUE) {
+            return (byte) number;
+        } else if (number <= Short.MAX_VALUE && number >= Short.MIN_VALUE) {
+            return (short) number;
+        } else if (number <= Integer.MAX_VALUE && number >= Integer.MIN_VALUE) {
+            return (int) number;
+        }
+        return number;
+    }
+
+    /**
+     * Decode a zig-zag encoded value
+     *
+     * @param signed The value to decode
+     * @return The decoded value
+     */
+    public static Number decodeZigZag(Number signed) {
+        final long value = signed.longValue();
+        return convertLong((value >> 1) ^ -(value & 1));
+    }
+
+    /**
+     * Encode a number to a zig-zag encode value
+     *
+     * @param signed The number to encode
+     * @return The encoded value
+     */
+    public static Number encodeZigZag(Number signed) {
+        final long value = signed.longValue();
+        // This boundary condition could be >= or <= or both. Tests indicate that it doesn't actually matter.
+        // The only difference would be the number type returned, except it is always converted to the most basic type.
+        final int shift = (value > Integer.MAX_VALUE || value < Integer.MIN_VALUE ? Long.BYTES : Integer.BYTES) * 8 - 1;
+        return convertLong((value << 1) ^ (value >> shift));
+    }
+
+    private final InputStream inputStream;
+
+    /**
+     * Create a new parser
+     *
+     * @param bytes The bytes to parse
+     */
+    public ProtoBufParser(byte[] bytes) {
+        this(new ByteArrayInputStream(bytes));
+    }
+
+    /**
+     * Create a new parser
+     *
+     * @param inputStream The InputStream (will be fully read at this time)
+     */
+    public ProtoBufParser(InputStream inputStream) {
+        if (inputStream.markSupported()) {
+            this.inputStream = inputStream;
+        } else {
+            this.inputStream = new BufferedInputStream(inputStream);
+        }
+    }
+
+    /**
+     * Read all records
+     *
+     * @return A collection of all records
+     * @throws IOException - if an IO error occurs
+     */
+    public Collection<ProtoBufRecord> allRecords() throws IOException {
+        Collection<ProtoBufRecord> records = new ArrayList<>();
+        while (this.hasNext()) {
+            records.add(new ProtoBufRecord(this));
+        }
+        return records;
+    }
+
+    @Override
+    public void close() {
+        try {
+            this.inputStream.close();
+        } catch (IOException e) {
+            Logging.error(e);
+        }
+    }
+
+    /**
+     * Check if there is more data to read
+     *
+     * @return {@code true} if there is more data to read
+     * @throws IOException - if an IO error occurs
+     */
+    public boolean hasNext() throws IOException {
+        return this.inputStream.available() > 0;
+    }
+
+    /**
+     * Get the "next" WireType
+     *
+     * @return {@link WireType} expected
+     * @throws IOException - if an IO error occurs
+     */
+    public WireType next() throws IOException {
+        this.inputStream.mark(16);
+        try {
+            return WireType.values()[this.inputStream.read() << 3];
+        } finally {
+            this.inputStream.reset();
+        }
+    }
+
+    /**
+     * Get the next byte
+     *
+     * @return The next byte
+     * @throws IOException - if an IO error occurs
+     */
+    public int nextByte() throws IOException {
+        return this.inputStream.read();
+    }
+
+    /**
+     * Get the next 32 bits ({@link WireType#THIRTY_TWO_BIT})
+     *
+     * @return a byte array of the next 32 bits (4 bytes)
+     * @throws IOException - if an IO error occurs
+     */
+    public byte[] nextFixed32() throws IOException {
+        // 4 bytes == 32 bits
+        return readNextBytes(4);
+    }
+
+    /**
+     * Get the next 64 bits ({@link WireType#SIXTY_FOUR_BIT})
+     *
+     * @return a byte array of the next 64 bits (8 bytes)
+     * @throws IOException - if an IO error occurs
+     */
+    public byte[] nextFixed64() throws IOException {
+        // 8 bytes == 64 bits
+        return readNextBytes(8);
+    }
+
+    /**
+     * Get the next delimited message ({@link WireType#LENGTH_DELIMITED})
+     *
+     * @return The next length delimited message
+     * @throws IOException - if an IO error occurs
+     */
+    public byte[] nextLengthDelimited() throws IOException {
+        int length = convertByteArray(this.nextVarInt(), VAR_INT_BYTE_SIZE).intValue();
+        return readNextBytes(length);
+    }
+
+    /**
+     * Get the next var int ({@code WireType#VARINT})
+     *
+     * @return The next var int ({@code int32}, {@code int64}, {@code uint32}, {@code uint64}, {@code bool}, {@code enum})
+     * @throws IOException - if an IO error occurs
+     */
+    public byte[] nextVarInt() throws IOException {
+        List<Byte> byteList = new ArrayList<>();
+        int currentByte = this.nextByte();
+        while ((byte) (currentByte & MOST_SIGNIFICANT_BYTE) == MOST_SIGNIFICANT_BYTE && currentByte > 0) {
+            // Get rid of the leading bit (shift left 1, then shift right 1 unsigned)
+            byteList.add((byte) (currentByte ^ MOST_SIGNIFICANT_BYTE));
+            currentByte = this.nextByte();
+        }
+        // The last byte doesn't drop the most significant bit
+        byteList.add((byte) currentByte);
+        byte[] byteArray = new byte[byteList.size()];
+        for (int i = 0; i < byteList.size(); i++) {
+            byteArray[i] = byteList.get(i);
+        }
+
+        return byteArray;
+    }
+
+    /**
+     * Read an arbitrary number of bytes
+     *
+     * @param size The number of bytes to read
+     * @return a byte array of the specified size, filled with bytes read (unsigned)
+     * @throws IOException - if an IO error occurs
+     */
+    private byte[] readNextBytes(int size) throws IOException {
+        byte[] bytesRead = new byte[size];
+        for (int i = 0; i < bytesRead.length; i++) {
+            bytesRead[i] = (byte) this.nextByte();
+        }
+        return bytesRead;
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtoBufRecord.java b/src/org/openstreetmap/josm/data/protobuf/ProtoBufRecord.java
new file mode 100644
index 000000000..1eb5d38a6
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtoBufRecord.java
@@ -0,0 +1,152 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.stream.Stream;
+
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * A protobuf record, storing the {@link WireType}, the parsed field number, and the bytes for it.
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class ProtoBufRecord implements AutoCloseable {
+    private static final byte[] EMPTY_BYTES = {};
+    private final WireType type;
+    private final int field;
+    private byte[] bytes;
+
+    /**
+     * Create a new Protobuf record
+     *
+     * @param parser The parser to use to create the record
+     * @throws IOException - if an IO error occurs
+     */
+    public ProtoBufRecord(ProtoBufParser parser) throws IOException {
+        Number number = ProtoBufParser.convertByteArray(parser.nextVarInt(), ProtoBufParser.VAR_INT_BYTE_SIZE);
+        // I don't foresee having field numbers > {@code Integer#MAX_VALUE >> 3}
+        this.field = (int) number.longValue() >> 3;
+        // 7 is 111 (so last three bits)
+        byte wireType = (byte) (number.longValue() & 7);
+        this.type = Stream.of(WireType.values()).filter(wType -> wType.getTypeRepresentation() == wireType).findFirst()
+          .orElse(WireType.UNKNOWN);
+
+        if (this.type == WireType.VARINT) {
+            this.bytes = parser.nextVarInt();
+        } else if (this.type == WireType.SIXTY_FOUR_BIT) {
+            this.bytes = parser.nextFixed64();
+        } else if (this.type == WireType.THIRTY_TWO_BIT) {
+            this.bytes = parser.nextFixed32();
+        } else if (this.type == WireType.LENGTH_DELIMITED) {
+            this.bytes = parser.nextLengthDelimited();
+        } else {
+            this.bytes = EMPTY_BYTES;
+        }
+    }
+
+    /**
+     * Get as a double ({@link WireType#SIXTY_FOUR_BIT})
+     *
+     * @return the double
+     */
+    public double asDouble() {
+        long doubleNumber = ProtoBufParser.convertByteArray(asFixed64(), ProtoBufParser.BYTE_SIZE).longValue();
+        return Double.longBitsToDouble(doubleNumber);
+    }
+
+    /**
+     * Get as 32 bits ({@link WireType#THIRTY_TWO_BIT})
+     *
+     * @return a byte array of the 32 bits (4 bytes)
+     */
+    public byte[] asFixed32() {
+        // TODO verify, or just assume?
+        // 4 bytes == 32 bits
+        return this.bytes;
+    }
+
+    /**
+     * Get as 64 bits ({@link WireType#SIXTY_FOUR_BIT})
+     *
+     * @return a byte array of the 64 bits (8 bytes)
+     */
+    public byte[] asFixed64() {
+        // TODO verify, or just assume?
+        // 8 bytes == 64 bits
+        return this.bytes;
+    }
+
+    /**
+     * Get as a float ({@link WireType#THIRTY_TWO_BIT})
+     *
+     * @return the float
+     */
+    public float asFloat() {
+        int floatNumber = ProtoBufParser.convertByteArray(asFixed32(), ProtoBufParser.BYTE_SIZE).intValue();
+        return Float.intBitsToFloat(floatNumber);
+    }
+
+    /**
+     * Get the signed var int ({@code WireType#VARINT}).
+     * These are specially encoded so that they take up less space.
+     *
+     * @return The signed var int ({@code sint32} or {@code sint64})
+     */
+    public Number asSignedVarInt() {
+        final Number signed = this.asUnsignedVarInt();
+        return ProtoBufParser.decodeZigZag(signed);
+    }
+
+    /**
+     * Get as a string ({@link WireType#LENGTH_DELIMITED})
+     *
+     * @return The string (encoded as {@link StandardCharsets#UTF_8})
+     */
+    public String asString() {
+        return Utils.intern(new String(this.bytes, StandardCharsets.UTF_8));
+    }
+
+    /**
+     * Get the var int ({@code WireType#VARINT})
+     *
+     * @return The var int ({@code int32}, {@code int64}, {@code uint32}, {@code uint64}, {@code bool}, {@code enum})
+     */
+    public Number asUnsignedVarInt() {
+        return ProtoBufParser.convertByteArray(this.bytes, ProtoBufParser.VAR_INT_BYTE_SIZE);
+    }
+
+    @Override
+    public void close() {
+        this.bytes = null;
+    }
+
+    /**
+     * Get the raw bytes for this record
+     *
+     * @return The bytes
+     */
+    public byte[] getBytes() {
+        return this.bytes;
+    }
+
+    /**
+     * Get the field value
+     *
+     * @return The field value
+     */
+    public int getField() {
+        return this.field;
+    }
+
+    /**
+     * Get the WireType of the data
+     *
+     * @return The {@link WireType} of the data
+     */
+    public WireType getType() {
+        return this.type;
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/protobuf/WireType.java b/src/org/openstreetmap/josm/data/protobuf/WireType.java
new file mode 100644
index 000000000..41edc8e4f
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/protobuf/WireType.java
@@ -0,0 +1,61 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+/**
+ * The WireTypes
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public enum WireType {
+    /**
+     * int32, int64, uint32, uint64, sing32, sint64, bool, enum
+     */
+    VARINT(0),
+    /**
+     * fixed64, sfixed64, double
+     */
+    SIXTY_FOUR_BIT(1),
+    /**
+     * string, bytes, embedded messages, packed repeated fields
+     */
+    LENGTH_DELIMITED(2),
+    /**
+     * start groups
+     *
+     * @deprecated Unknown reason. Deprecated since at least 2012.
+     */
+    @Deprecated
+    START_GROUP(3),
+    /**
+     * end groups
+     *
+     * @deprecated Unknown reason. Deprecated since at least 2012.
+     */
+    @Deprecated
+    END_GROUP(4),
+    /**
+     * fixed32, sfixed32, float
+     */
+    THIRTY_TWO_BIT(5),
+
+    /**
+     * For unknown WireTypes
+     */
+    UNKNOWN(Byte.MAX_VALUE);
+
+    private final byte type;
+
+    WireType(int value) {
+        this.type = (byte) value;
+    }
+
+    /**
+     * Get the type representation (byte form)
+     *
+     * @return The wire type byte representation
+     */
+    public byte getTypeRepresentation() {
+        return this.type;
+    }
+}
diff --git a/test/data/pbf/openinframap/17/26028/50060.pbf b/test/data/pbf/openinframap/17/26028/50060.pbf
new file mode 100644
index 0000000000000000000000000000000000000000..358270e1b307f7373ec494adbd1ceb31c72e2956
GIT binary patch
literal 1082
zcma)5Jx>%t7@pZZ?zlPRxV;mOA(+cna=*Y(pceiCXec!6xHrHiyEB`eSx*~tu>li<
z5L>mjkXj2R#L`Y<<4386XkmznCKT$-&V9gzf+;rpem(E|JTos|LMY~Kns{NrD7IIF
z7S79*F&g60ko!aioZ(y+9P>F7GD&^ybMuYudplduwJreHLckqqY(L+R&UYHOEbO3?
z0hJos_@>Z=@rm??kQuHC%%aKRfEV+CNfW->!-_+~q%$`PoE(Gz)NOaRcsSR&Ja2s%
zUp|0)@XBknylG#5RM^UaH>I*6Gd%SDolmbUJ+wi;hAO6chHm;gRn;SJSpYy-hwe!6
z*~O2G1}#StO7O{;#c80<-qRnK2JjCcDjWP&%4k9)Pn;=*jx@Gq*>=v(I_xfNEOo^f
zJWYIxbMkgfaUl>M<1ISWvebD0@Ymr#eV|)FBZ7w{aWSfAKVH1XCBMl-Ndn)CiMq?d
zL<^{D23n&;MkMyK5~r+&QiQw1{9VL(p2|3tu3@E_8Nt>qR2yRCX;GYYs31l)r%X57
zZ5KZ`zsxuHs{owaP#4zvSp((bCj5i&w-Nw%;n$L~*Wn9@-&FwUz_%4;gO{-Ry;rhw
zpi98+5m(E&e%tuTBmj3FPwIp{m{}dDO{3!k6*7p3q%F@d!TF=gy2m6OkUW)|Mu~dw
z>BxjIvBHz6_tg6lpGPreLSMV7siH9>o@fWsUo%b@%}5$jl`K5<iBcAry~}pe<jkO2
zKc__`@qEf;uU`$F=?5bsx{EW7oS4dlhcu16IEblUVuN-TjSoU?8ipshMIZ;+Z6#!K
G9OOSgfkw9g

literal 0
HcmV?d00001

diff --git a/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufParserTest.java b/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufParserTest.java
new file mode 100644
index 000000000..bdfdf86b7
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufParserTest.java
@@ -0,0 +1,51 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link ProtoBufParser}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class ProtoBufParserTest {
+    /**
+     * Check that we are appropriately converting values to the "smallest" type
+     */
+    @Test
+    void testConvertLong() {
+        // No casting due to auto conversions
+        assertEquals(Byte.MAX_VALUE, ProtoBufParser.convertLong(Byte.MAX_VALUE));
+        assertEquals(Byte.MIN_VALUE, ProtoBufParser.convertLong(Byte.MIN_VALUE));
+        assertEquals(Short.MIN_VALUE, ProtoBufParser.convertLong(Short.MIN_VALUE));
+        assertEquals(Short.MAX_VALUE, ProtoBufParser.convertLong(Short.MAX_VALUE));
+        assertEquals(Integer.MAX_VALUE, ProtoBufParser.convertLong(Integer.MAX_VALUE));
+        assertEquals(Integer.MIN_VALUE, ProtoBufParser.convertLong(Integer.MIN_VALUE));
+        assertEquals(Long.MIN_VALUE, ProtoBufParser.convertLong(Long.MIN_VALUE));
+        assertEquals(Long.MAX_VALUE, ProtoBufParser.convertLong(Long.MAX_VALUE));
+    }
+
+    /**
+     * Check that zig zags are appropriately encoded.
+     */
+    @Test
+    void testEncodeZigZag() {
+        assertEquals(0, ProtoBufParser.encodeZigZag(0).byteValue());
+        assertEquals(1, ProtoBufParser.encodeZigZag(-1).byteValue());
+        assertEquals(2, ProtoBufParser.encodeZigZag(1).byteValue());
+        assertEquals(3, ProtoBufParser.encodeZigZag(-2).byteValue());
+        assertEquals(254, ProtoBufParser.encodeZigZag(Byte.MAX_VALUE).shortValue());
+        assertEquals(255, ProtoBufParser.encodeZigZag(Byte.MIN_VALUE).shortValue());
+        assertEquals(65_534, ProtoBufParser.encodeZigZag(Short.MAX_VALUE).intValue());
+        assertEquals(65_535, ProtoBufParser.encodeZigZag(Short.MIN_VALUE).intValue());
+        // These integers check a possible boundary condition (the boundary between using the 32/64 bit encoding methods)
+        assertEquals(4_294_967_292L, ProtoBufParser.encodeZigZag(Integer.MAX_VALUE - 1).longValue());
+        assertEquals(4_294_967_293L, ProtoBufParser.encodeZigZag(Integer.MIN_VALUE + 1).longValue());
+        assertEquals(4_294_967_294L, ProtoBufParser.encodeZigZag(Integer.MAX_VALUE).longValue());
+        assertEquals(4_294_967_295L, ProtoBufParser.encodeZigZag(Integer.MIN_VALUE).longValue());
+        assertEquals(4_294_967_296L, ProtoBufParser.encodeZigZag(Integer.MAX_VALUE + 1L).longValue());
+        assertEquals(4_294_967_297L, ProtoBufParser.encodeZigZag(Integer.MIN_VALUE - 1L).longValue());
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufRecordTest.java b/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufRecordTest.java
new file mode 100644
index 000000000..d0e204c6a
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufRecordTest.java
@@ -0,0 +1,30 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for specific {@link ProtoBufRecord} functionality
+ */
+class ProtoBufRecordTest {
+    @Test
+    void testFixed32() throws IOException {
+        ProtoBufParser parser = new ProtoBufParser(ProtoBufTest.toByteArray(new int[] {0x0d, 0x00, 0x00, 0x80, 0x3f}));
+        ProtoBufRecord thirtyTwoBit = new ProtoBufRecord(parser);
+        assertEquals(WireType.THIRTY_TWO_BIT, thirtyTwoBit.getType());
+        assertEquals(1f, thirtyTwoBit.asFloat());
+    }
+
+    @Test
+    void testUnknown() throws IOException {
+        ProtoBufParser parser = new ProtoBufParser(ProtoBufTest.toByteArray(new int[] {0x0f, 0x00, 0x00, 0x80, 0x3f}));
+        ProtoBufRecord unknown = new ProtoBufRecord(parser);
+        assertEquals(WireType.UNKNOWN, unknown.getType());
+        assertEquals(0, unknown.getBytes().length);
+    }
+}
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
index 000000000..043481efe
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java
@@ -0,0 +1,211 @@
+// 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.geom.Ellipse2D;
+import java.io.ByteArrayInputStream;
+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.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Feature;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorTileSource;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.data.vector.VectorDataSet;
+import org.openstreetmap.josm.data.vector.VectorNode;
+import org.openstreetmap.josm.data.vector.VectorWay;
+import org.openstreetmap.josm.io.Compression;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * Test class for {@link ProtoBufParser} and {@link ProtoBufRecord}
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+class ProtoBufTest {
+    /**
+     * Convert an int array into a byte array
+     * @param intArray The int array to convert (NOTE: numbers must be below 255)
+     * @return A byte array that can be used
+     */
+    static byte[] toByteArray(int[] intArray) {
+        byte[] byteArray = new byte[intArray.length];
+        for (int i = 0; i < intArray.length; i++) {
+            if (intArray[i] > Byte.MAX_VALUE - Byte.MIN_VALUE) {
+                throw new IllegalArgumentException();
+            }
+            byteArray[i] = Integer.valueOf(intArray[i]).byteValue();
+        }
+        return byteArray;
+    }
+
+    @RegisterExtension
+    JOSMTestRules josmTestRules = new JOSMTestRules().preferences();
+
+    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);
+    }
+
+    /**
+     * Test reading tile from Mapillary ( 14/3248/6258 )
+     *
+     * @throws IOException if there is a problem reading the file
+     */
+    @Test
+    void testRead_14_3248_6258() throws IOException {
+        File vectorTile = Paths.get(TestUtils.getTestDataRoot(), "pbf", "mapillary", "14", "3248", "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() == 233760500).count());
+        Feature testSequence = mapillarySequences.getFeatures().stream().filter(feature -> feature.getId() == 233760500)
+                .findAny().orElse(null);
+        assertEquals("dpudn262yz6aitu33zh7bl", testSequence.getTags().get("key"));
+        assertEquals("clnaw3kpokIAe_CsN5Qmiw", testSequence.getTags().get("ikey"));
+        assertEquals("B1iNjH4Ohn25cRAGPhetfw", testSequence.getTags().get("userkey"));
+        assertEquals(Long.valueOf(1557535457401L), Long.valueOf(testSequence.getTags().get("captured_at")));
+        assertEquals(0, Integer.parseInt(testSequence.getTags().get("pano")));
+    }
+
+    @Test
+    void testRead_17_26028_50060() throws IOException {
+        File vectorTile = Paths.get(TestUtils.getTestDataRoot(), "pbf", "openinframap", "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
+        VectorDataSet vectorDataSet = new VectorDataSet();
+        MVTTile vectorTile1 = new MVTTile(new MapboxVectorTileSource(new ImageryInfo("Test info", "example.org")),
+                26028, 50060, 17);
+        vectorTile1.loadImage(Compression.getUncompressedFileInputStream(vectorTile));
+        vectorDataSet.addTileData(vectorTile1);
+        vectorDataSet.setZoom(17);
+        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.00001);
+        final Collection<VectorNode> searchedNodes = vectorDataSet.searchNodes(searchBBox);
+        final Collection<VectorWay> searchedWays = vectorDataSet.searchWays(searchBBox);
+        assertEquals(4, searchedNodes.size());
+    }
+
+    @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 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
+    void testSingletonMultiPoint() throws IOException {
+        Collection<ProtoBufRecord> records = new ProtoBufParser(new ByteArrayInputStream(toByteArray(
+                new int[] {0x1a, 0x2c, 0x78, 0x02, 0x0a, 0x03, 0x74, 0x6d, 0x70, 0x28, 0x80, 0x20, 0x1a, 0x04, 0x6e,
+                        0x61, 0x6d, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x54, 0x65, 0x73, 0x74, 0x20, 0x6e, 0x61, 0x6d, 0x65,
+                        0x12, 0x0d, 0x18, 0x01, 0x12, 0x02, 0x00, 0x00, 0x22, 0x05, 0x09, 0xe0, 0x3e, 0x84, 0x27})))
+                                .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(4016, shape.getCenterX());
+        assertEquals(2498, shape.getCenterY());
+    }
+
+    @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());
+    }
+}
-- 
GitLab


From f38f09e1036905ea7e7499c66b5a9fb691cd38c8 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 8 Apr 2021 14:24:54 -0600
Subject: [PATCH 02/50] Initial Mapbox Vector Tile implementation

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../data/imagery/vectortile/VectorTile.java   |  25 ++
 .../imagery/vectortile/mapbox/Command.java    |  48 ++++
 .../vectortile/mapbox/CommandInteger.java     |  62 +++++
 .../imagery/vectortile/mapbox/Feature.java    | 175 +++++++++++++
 .../imagery/vectortile/mapbox/Geometry.java   | 102 ++++++++
 .../vectortile/mapbox/GeometryTypes.java      |  31 +++
 .../InvalidMapboxVectorTileException.java     |  25 ++
 .../data/imagery/vectortile/mapbox/Layer.java | 245 ++++++++++++++++++
 .../imagery/vectortile/mapbox/MVTFile.java    |  34 +++
 .../imagery/vectortile/mapbox/MVTTile.java    | 119 +++++++++
 .../mapbox/MapBoxVectorCachedTileLoader.java  |  79 ++++++
 .../MapBoxVectorCachedTileLoaderJob.java      |  26 ++
 .../mapbox/MapboxVectorTileSource.java        |  92 +++++++
 test/data/mapillary.json                      | 111 ++++++++
 .../vectortile/mapbox/FeatureTest.java        | 122 +++++++++
 .../vectortile/mapbox/GeometryTest.java       | 169 ++++++++++++
 .../vectortile/mapbox/GeometryTypesTest.java  |  47 ++++
 .../imagery/vectortile/mapbox/LayerTest.java  | 135 ++++++++++
 .../vectortile/mapbox/MVTTileTest.java        |  82 ++++++
 .../mapbox/MapboxVectorTileSourceTest.java    |  77 ++++++
 20 files changed, 1806 insertions(+)
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/VectorTile.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Command.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/CommandInteger.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoader.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoaderJob.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java
 create mode 100644 test/data/mapillary.json
 create mode 100644 test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/FeatureTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypesTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSourceTest.java

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
index 000000000..692f3ea8c
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/VectorTile.java
@@ -0,0 +1,25 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile;
+
+import java.util.Collection;
+
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
+
+/**
+ * An interface that is used to draw vector tiles, instead of using images
+ * @author Taylor Smock
+ * @since xxx
+ */
+public interface VectorTile {
+    /**
+     * Get the layers for this vector tile
+     * @return A collection of layers
+     */
+    Collection<Layer> getLayers();
+
+    /**
+     * Get the extent of the tile (in pixels)
+     * @return The tile extent (pixels)
+     */
+    int getExtent();
+}
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
index 000000000..05ffcf945
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Command.java
@@ -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;
+    }
+}
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
index 000000000..5213bf0e8
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/CommandInteger.java
@@ -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;
+    }
+}
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
index 000000000..df194cc00
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java
@@ -0,0 +1,175 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.io.IOException;
+import java.text.NumberFormat;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.openstreetmap.josm.data.osm.TagMap;
+import org.openstreetmap.josm.data.protobuf.ProtoBufPacked;
+import org.openstreetmap.josm.data.protobuf.ProtoBufParser;
+import org.openstreetmap.josm.data.protobuf.ProtoBufRecord;
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * A Feature for a {@link Layer}
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class Feature {
+    private static final byte ID_FIELD = 1;
+    private static final byte TAG_FIELD = 2;
+    private static final byte GEOMETRY_TYPE_FIELD = 3;
+    private static final byte GEOMETRY_FIELD = 4;
+    /**
+     * The geometry of the feature. Required.
+     */
+    private final List<CommandInteger> geometry = new ArrayList<>();
+
+    /**
+     * The geometry type of the feature. Required.
+     */
+    private final GeometryTypes geometryType;
+    /**
+     * The id of the feature. Optional.
+     */
+    // Technically, uint64
+    private final long id;
+    /**
+     * The tags of the feature. Optional.
+     */
+    private TagMap tags;
+    private Geometry geometryObject;
+
+    /**
+     * Create a new Feature
+     *
+     * @param layer  The layer the feature is part of (required for tags)
+     * @param record The record to create the feature from
+     * @throws IOException - if an IO error occurs
+     */
+    public Feature(Layer layer, ProtoBufRecord record) throws IOException {
+        long tId = 0;
+        GeometryTypes geometryTypeTemp = GeometryTypes.UNKNOWN;
+        String key = null;
+        try (ProtoBufParser parser = new ProtoBufParser(record.getBytes())) {
+            while (parser.hasNext()) {
+                try (ProtoBufRecord next = new ProtoBufRecord(parser)) {
+                    if (next.getField() == TAG_FIELD) {
+                        if (tags == null) {
+                            tags = new TagMap();
+                        }
+                        // This is packed in v1 and v2
+                        ProtoBufPacked packed = new ProtoBufPacked(next.getBytes());
+                        for (Number number : packed.getArray()) {
+                            key = parseTagValue(key, layer, number);
+                        }
+                    } else if (next.getField() == GEOMETRY_FIELD) {
+                        // This is packed in v1 and v2
+                        ProtoBufPacked packed = new ProtoBufPacked(next.getBytes());
+                        CommandInteger currentCommand = null;
+                        for (Number number : packed.getArray()) {
+                            if (currentCommand != null && currentCommand.hasAllExpectedParameters()) {
+                                currentCommand = null;
+                            }
+                            if (currentCommand == null) {
+                                currentCommand = new CommandInteger(number.intValue());
+                                this.geometry.add(currentCommand);
+                            } else {
+                                currentCommand.addParameter(ProtoBufParser.decodeZigZag(number));
+                            }
+                        }
+                        // TODO fallback to non-packed
+                    } else if (next.getField() == GEOMETRY_TYPE_FIELD) {
+                        geometryTypeTemp = GeometryTypes.values()[next.asUnsignedVarInt().intValue()];
+                    } else if (next.getField() == ID_FIELD) {
+                        tId = next.asUnsignedVarInt().longValue();
+                    }
+                }
+            }
+        }
+        this.id = tId;
+        this.geometryType = geometryTypeTemp;
+        record.close();
+    }
+
+    /**
+     * Parse a tag value
+     *
+     * @param key    The current key (or {@code null}, if {@code null}, the returned value will be the new key)
+     * @param layer  The layer with key/value information
+     * @param number The number to get the value from
+     * @return The new key (if {@code null}, then a value was parsed and added to tags)
+     */
+    private String parseTagValue(String key, Layer layer, Number number) {
+        if (key == null) {
+            key = layer.getKey(number.intValue());
+        } else {
+            Object value = layer.getValue(number.intValue());
+            if (value instanceof Double || value instanceof Float) {
+                // reset grouping if the instance is a singleton
+                final NumberFormat numberFormat = NumberFormat.getNumberInstance();
+                final boolean grouping = numberFormat.isGroupingUsed();
+                try {
+                    numberFormat.setGroupingUsed(false);
+                    this.tags.put(key, numberFormat.format(value));
+                } finally {
+                    numberFormat.setGroupingUsed(grouping);
+                }
+            } else {
+                this.tags.put(key, Utils.intern(value.toString()));
+            }
+            key = null;
+        }
+        return key;
+    }
+
+    /**
+     * Get the geometry instructions
+     *
+     * @return The geometry
+     */
+    public List<CommandInteger> getGeometry() {
+        return this.geometry;
+    }
+
+    /**
+     * Get the geometry type
+     *
+     * @return The {@link GeometryTypes}
+     */
+    public GeometryTypes getGeometryType() {
+        return this.geometryType;
+    }
+
+    /**
+     * Get the id of the object
+     *
+     * @return The unique id in the layer, or 0.
+     */
+    public long getId() {
+        return this.id;
+    }
+
+    /**
+     * Get the tags
+     *
+     * @return A tag map
+     */
+    public TagMap getTags() {
+        return this.tags;
+    }
+
+    /**
+     * Get the an object with shapes for the geometry
+     * @return An object with usable geometry information
+     */
+    public Geometry getGeometryObject() {
+        if (this.geometryObject == null) {
+            this.geometryObject = new Geometry(this.getGeometryType(), this.getGeometry());
+        }
+        return this.geometryObject;
+    }
+}
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
index 000000000..c612c7e83
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Geometry.java
@@ -0,0 +1,102 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Shape;
+import java.awt.geom.Area;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Path2D;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A class to generate geometry for a vector tile
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class Geometry {
+    final Collection<Shape> shapes = new ArrayList<>();
+
+    /**
+     * Create a {@link Geometry} for a {@link Feature}
+     * @param geometryType The type of geometry
+     * @param commands The commands used to create the geometry
+     */
+    public Geometry(GeometryTypes geometryType, List<CommandInteger> commands) {
+        if (geometryType == GeometryTypes.POINT) {
+            for (CommandInteger command : commands) {
+                final short[] operations = command.getOperations();
+                // Each MoveTo command is a new point
+                if (command.getType() == Command.MoveTo && operations.length % 2 == 0 && operations.length > 0) {
+                    for (int i = 0; i < operations.length / 2; i++) {
+                        // Just using Ellipse2D since it extends Shape
+                        shapes.add(new Ellipse2D.Float(operations[2 * i],
+                                operations[2 * i + 1], 0, 0));
+                    }
+                } else {
+                    throw new IllegalArgumentException(tr("{0} with {1} arguments is not understood", geometryType, operations.length));
+                }
+            }
+        } else if (geometryType == GeometryTypes.LINESTRING || geometryType == GeometryTypes.POLYGON) {
+            Path2D.Float line = null;
+            Area area = null;
+            // MVT uses delta encoding. Each feature starts at (0, 0).
+            double x = 0;
+            double y = 0;
+            // Area is used to determine the inner/outer of a polygon
+            double areaAreaSq = 0;
+            for (CommandInteger command : commands) {
+                final short[] operations = command.getOperations();
+                // Technically, there is no reason why there can be multiple MoveTo operations in one command, but that is undefined behavior
+                if (command.getType() == Command.MoveTo && operations.length == 2) {
+                    areaAreaSq = 0;
+                    x += operations[0];
+                    y += operations[1];
+                    line = new Path2D.Float();
+                    line.moveTo(x, y);
+                    shapes.add(line);
+                } else if (command.getType() == Command.LineTo && operations.length % 2 == 0 && line != null) {
+                    for (int i = 0; i < operations.length / 2; i++) {
+                        final double lx = x;
+                        final double ly = y;
+                        x += operations[2 * i];
+                        y += operations[2 * i + 1];
+                        areaAreaSq += lx * y - x * ly;
+                        line.lineTo(x, y);
+                    }
+                // ClosePath should only be used with Polygon geometry
+                } else if (geometryType == GeometryTypes.POLYGON && command.getType() == Command.ClosePath && line != null) {
+                    shapes.remove(line);
+                    // new Area() closes the line if it isn't already closed
+                    if (area == null) {
+                        area = new Area();
+                        shapes.add(area);
+                    }
+
+                    Area nArea = new Area(line);
+                    // SonarLint thinks that this is never > 0. It can be.
+                    if (areaAreaSq > 0) {
+                        area.add(nArea);
+                    } else if (areaAreaSq < 0) {
+                        area.exclusiveOr(nArea);
+                    } else {
+                        throw new IllegalArgumentException(tr("{0} cannot have zero area", geometryType));
+                    }
+                } else {
+                    throw new IllegalArgumentException(tr("{0} with {1} arguments is not understood", geometryType, operations.length));
+                }
+            }
+        }
+    }
+
+    /**
+     * Get the shapes to draw this geometry with
+     * @return A collection of shapes
+     */
+    public Collection<Shape> getShapes() {
+        return Collections.unmodifiableCollection(this.shapes);
+    }
+}
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
index 000000000..0dc29c6a6
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypes.java
@@ -0,0 +1,31 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+/**
+ * Geometry types used by Mapbox Vector Tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public enum GeometryTypes {
+    /** May be ignored */
+    UNKNOWN,
+    /** May be a point or a multipoint geometry. Uses <i>only</i> {@link Command#MoveTo}. Multiple {@link Command#MoveTo}
+     * indicates that it is a multi-point object. */
+    POINT,
+    /** May be a line or a multiline geometry. Each line {@link Command#MoveTo} and one or more {@link Command#LineTo}. */
+    LINESTRING,
+    /** May be a polygon or a multipolygon. Each ring uses a {@link Command#MoveTo}, one or more {@link Command#LineTo},
+     * and one {@link Command#ClosePath} command. See {@link Ring}s. */
+    POLYGON;
+
+    /**
+     * Rings used by {@link GeometryTypes#POLYGON}
+     * @author Taylor Smock
+     */
+    public enum Ring {
+        /** A ring that goes in the clockwise direction */
+        ExteriorRing,
+        /** A ring that goes in the anti-clockwise direction */
+        InteriorRing
+    }
+}
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
index 000000000..d1186ad3f
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java
@@ -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);
+    }
+}
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
index 000000000..09851e8c7
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
@@ -0,0 +1,245 @@
+// 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 final 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);
+
+        /**
+         * A collection of methods to map a record to a type
+         */
+        public static final Collection<ValueFields<?>> MAPPERS =
+          Collections.unmodifiableList(Arrays.asList(STRING, FLOAT, DOUBLE, INT64, UINT64, SINT64, BOOL));
+
+        private final byte field;
+        private final Function<ProtoBufRecord, T> conversion;
+        private ValueFields(int field, Function<ProtoBufRecord, T> conversion) {
+            this.field = (byte) field;
+            this.conversion = conversion;
+        }
+
+        /**
+         * Get the field identifier for the value
+         * @return The identifier
+         */
+        public byte getField() {
+            return this.field;
+        }
+
+        /**
+         * Convert a protobuf record to a value
+         * @param protobufRecord The record to convert
+         * @return the converted value
+         */
+        public T convertValue(ProtoBufRecord protobufRecord) {
+            return this.conversion.apply(protobufRecord);
+        }
+    }
+
+    /** The field value for a layer (in {@link ProtoBufRecord#getField}) */
+    public static final byte LAYER_FIELD = 3;
+    private static final byte VERSION_FIELD = 15;
+    private static final byte NAME_FIELD = 1;
+    private static final byte FEATURE_FIELD = 2;
+    private static final byte KEY_FIELD = 3;
+    private static final byte VALUE_FIELD = 4;
+    private static final byte EXTENT_FIELD = 5;
+    /** The default extent for a vector tile */
+    static final int DEFAULT_EXTENT = 4096;
+    private static final byte DEFAULT_VERSION = 1;
+    /** This is <i>technically</i> an integer, but there are currently only two major versions (1, 2). Required. */
+    private final byte version;
+    /** A unique name for the layer. This <i>must</i> be unique on a per-tile basis. Required. */
+    private final String name;
+
+    /** The extent of the tile, typically 4096. Required. */
+    private final int extent;
+
+    /** A list of unique keys. Order is important. Optional. */
+    private final List<String> keyList = new ArrayList<>();
+    /** A list of unique values. Order is important. Optional. */
+    private final List<Object> valueList = new ArrayList<>();
+    /** The actual features of this layer in this tile */
+    private final List<Feature> featureCollection;
+
+    /**
+     * Create a layer from a collection of records
+     * @param records The records to convert to a layer
+     * @throws IOException - if an IO error occurs
+     */
+    public Layer(Collection<ProtoBufRecord> records) throws IOException {
+        // Do the unique required fields first
+        Map<Integer, List<ProtoBufRecord>> sorted = records.stream().collect(Collectors.groupingBy(ProtoBufRecord::getField));
+        this.version = sorted.getOrDefault((int) VERSION_FIELD, Collections.emptyList()).parallelStream()
+          .map(ProtoBufRecord::asUnsignedVarInt).map(Number::byteValue).findFirst().orElse(DEFAULT_VERSION);
+        // Per spec, we cannot continue past this until we have checked the version number
+        if (this.version != 1 && this.version != 2) {
+            throw new IllegalArgumentException(tr("We do not understand version {0} of the vector tile specification", this.version));
+        }
+        this.name = sorted.getOrDefault((int) NAME_FIELD, Collections.emptyList()).parallelStream().map(ProtoBufRecord::asString).findFirst()
+                .orElseThrow(() -> new IllegalArgumentException(tr("Vector tile layers must have a layer name")));
+        this.extent = sorted.getOrDefault((int) EXTENT_FIELD, Collections.emptyList()).parallelStream().map(ProtoBufRecord::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) {
+                        Logging.error(e);
+                        return null;
+                    }
+                })
+                .filter(Objects::nonNull)
+                .map(value -> ValueFields.MAPPERS.parallelStream()
+                        .filter(v -> v.getField() == value.getField())
+                        .map(v -> v.convertValue(value)).findFirst()
+                        .orElseThrow(() -> new IllegalArgumentException(tr("Unknown field in vector tile layer value ({0})", value.getField()))))
+                .forEachOrdered(this.valueList::add);
+        Collection<IOException> exceptions = new HashSet<>(0);
+        this.featureCollection = sorted.getOrDefault((int) FEATURE_FIELD, Collections.emptyList()).parallelStream().map(feature -> {
+            try {
+                return new Feature(this, feature);
+            } catch (IOException e) {
+                exceptions.add(e);
+            }
+            return null;
+        }).collect(Collectors.toList());
+        if (!exceptions.isEmpty()) {
+            throw exceptions.iterator().next();
+        }
+        // Cleanup bytes (for memory)
+        for (ProtoBufRecord record : records) {
+            record.close();
+        }
+    }
+
+    /**
+     * Get all the records from a array of bytes
+     * @param bytes The byte information
+     * @return All the protobuf records
+     * @throws IOException If there was an error reading the bytes (unlikely)
+     */
+    private static Collection<ProtoBufRecord> getAllRecords(byte[] bytes) throws IOException {
+        try (ProtoBufParser parser = new ProtoBufParser(bytes)) {
+            return parser.allRecords();
+        }
+    }
+
+    /**
+     * Create a new layer
+     * @param bytes The bytes that the layer comes from
+     * @throws IOException - if an IO error occurs
+     */
+    public Layer(byte[] bytes) throws IOException {
+        this(getAllRecords(bytes));
+    }
+
+    /**
+     * Get the extent of the tile
+     * @return The layer extent
+     */
+    public int getExtent() {
+        return this.extent;
+    }
+
+    /**
+     * Get the feature on this layer
+     * @return the features
+     */
+    public Collection<Feature> getFeatures() {
+        return Collections.unmodifiableCollection(this.featureCollection);
+    }
+
+    /**
+     * Get the geometry for this layer
+     * @return The geometry
+     */
+    public Collection<Geometry> getGeometry() {
+        return getFeatures().stream().map(Feature::getGeometryObject).collect(Collectors.toList());
+    }
+
+    /**
+     * Get a specified key
+     * @param index The index in the key list
+     * @return The actual key
+     */
+    public String getKey(int index) {
+        return this.keyList.get(index);
+    }
+
+    /**
+     * Get the name of the layer
+     * @return The layer name
+     */
+    public String getName() {
+        return this.name;
+    }
+
+    /**
+     * Get a specified value
+     * @param index The index in the value list
+     * @return The actual value. This can be a {@link String}, {@link Boolean}, {@link Integer}, or {@link Float} value.
+     */
+    public Object getValue(int index) {
+        return this.valueList.get(index);
+    }
+
+    /**
+     * Get the MapBox Vector Tile version specification for this layer
+     * @return The version of the MapBox Vector Tile specification
+     */
+    public byte getVersion() {
+        return this.version;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other instanceof Layer) {
+            Layer o = (Layer) other;
+            return this.extent == o.extent
+              && this.version == o.version
+              && Objects.equals(this.name, o.name)
+              && Objects.equals(this.featureCollection, o.featureCollection)
+              && Objects.equals(this.keyList, o.keyList)
+              && Objects.equals(this.valueList, o.valueList);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.name, this.version, this.extent, this.featureCollection, this.keyList, this.valueList);
+    }
+}
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
index 000000000..84ac8ae89
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java
@@ -0,0 +1,34 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Items that MAY be used to figure out if a file or server response MAY BE a Mapbox Vector Tile
+ * @author Taylor Smock
+ * @since xxx
+ */
+public final class MVTFile {
+    /**
+     * Extensions for Mapbox Vector Tiles.
+     * This is a SHOULD, <i>not</i> a MUST.
+     */
+    public static final List<String> EXTENSION = Collections.unmodifiableList(Arrays.asList("mvt"));
+
+    /**
+     * mimetypes for Mapbox Vector Tiles
+     * This is a SHOULD, <i>not</i> a MUST.
+     */
+    public static final List<String> MIMETYPE = Collections.unmodifiableList(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
+    }
+}
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
index 000000000..5d1d781dd
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
@@ -0,0 +1,119 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+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.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 final ListenerList<TileListener> listenerList = ListenerList.create();
+    private Collection<Layer> layers;
+    private int extent = Layer.DEFAULT_EXTENT;
+    static final BufferedImage CLEAR_LOADED = new BufferedImage(1, 1, BufferedImage.TYPE_4BYTE_ABGR);
+
+    /**
+     * Create a new Tile
+     * @param source The source of the tile
+     * @param xtile The x coordinate for the tile
+     * @param ytile The y coordinate for the tile
+     * @param zoom The zoom for the tile
+     */
+    public MVTTile(TileSource source, int xtile, int ytile, int zoom) {
+        super(source, xtile, ytile, zoom);
+    }
+
+    @Override
+    public void loadImage(final InputStream inputStream) throws IOException {
+        if (this.image == null || this.image == Tile.LOADING_IMAGE || this.image == Tile.ERROR_IMAGE) {
+            this.initLoading();
+            ProtoBufParser parser = new ProtoBufParser(inputStream);
+            Collection<ProtoBufRecord> protoBufRecords = parser.allRecords();
+            this.layers = new HashSet<>();
+            this.layers = protoBufRecords.stream().map(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);
+            this.finishLoading();
+            this.listenerList.fireEvent(event -> event.finishedLoading(this));
+            // Ensure that we don't keep the loading image around
+            this.image = CLEAR_LOADED;
+        }
+    }
+
+    @Override
+    public Collection<Layer> getLayers() {
+        return this.layers;
+    }
+
+    @Override
+    public int getExtent() {
+        return this.extent;
+    }
+
+    /**
+     * Add a tile loader finisher listener
+     *
+     * @param listener The listener to add
+     */
+    public void addTileLoaderFinisher(TileListener listener) {
+        // Add as weak listeners since we don't want to keep unnecessary references.
+        this.listenerList.addWeakListener(listener);
+    }
+
+    /**
+     * A class that can be notified that a tile has finished loading
+     *
+     * @author Taylor Smock
+     */
+    public interface TileListener {
+        /**
+         * Called when the MVTTile is finished loading
+         *
+         * @param tile The tile that finished loading
+         */
+        void finishedLoading(MVTTile tile);
+    }
+
+    /**
+     * A class used to set the layers that an MVTTile will show.
+     *
+     * @author Taylor Smock
+     */
+    public interface LayerShower {
+        /**
+         * Get a list of layers to show
+         *
+         * @return A list of layer names
+         */
+        List<String> layersToShow();
+    }
+}
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
index 000000000..bf1b368d9
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoader.java
@@ -0,0 +1,79 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.CachedTileLoader;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
+import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
+import org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob;
+import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
+import org.openstreetmap.josm.data.imagery.TileJobOptions;
+import org.openstreetmap.josm.data.preferences.IntegerProperty;
+import org.openstreetmap.josm.tools.CheckParameterUtil;
+
+import org.apache.commons.jcs3.access.behavior.ICacheAccess;
+
+/**
+ * A TileLoader class for MVT tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MapBoxVectorCachedTileLoader implements TileLoader, CachedTileLoader {
+    protected final ICacheAccess<String, BufferedImageCacheEntry> cache;
+    protected final TileLoaderListener listener;
+    protected final TileJobOptions options;
+    private static final IntegerProperty THREAD_LIMIT =
+            new IntegerProperty("imagery.vector.mvtloader.maxjobs", TMSCachedTileLoader.THREAD_LIMIT.getDefaultValue());
+    private static final ThreadPoolExecutor DEFAULT_DOWNLOAD_JOB_DISPATCHER =
+            TMSCachedTileLoader.getNewThreadPoolExecutor("MVT-downloader-%d", THREAD_LIMIT.get());
+
+    /**
+     * Constructor
+     * @param listener          called when tile loading has finished
+     * @param cache             of the cache
+     * @param options           tile job options
+     */
+    public MapBoxVectorCachedTileLoader(TileLoaderListener listener, ICacheAccess<String, BufferedImageCacheEntry> cache,
+           TileJobOptions options) {
+        CheckParameterUtil.ensureParameterNotNull(cache, "cache");
+        this.cache = cache;
+        this.options = options;
+        this.listener = listener;
+    }
+
+    @Override
+    public void clearCache(TileSource source) {
+        this.cache.remove(source.getName() + ':');
+    }
+
+    @Override
+    public TileJob createTileLoaderJob(Tile tile) {
+        return new MapBoxVectorCachedTileLoaderJob(
+                listener,
+                tile,
+                cache,
+                options,
+                getDownloadExecutor());
+    }
+
+    @Override
+    public void cancelOutstandingTasks() {
+        final ThreadPoolExecutor executor = getDownloadExecutor();
+        executor.getQueue().stream().filter(executor::remove).filter(MapBoxVectorCachedTileLoaderJob.class::isInstance)
+                .map(MapBoxVectorCachedTileLoaderJob.class::cast).forEach(JCSCachedTileLoaderJob::handleJobCancellation);
+    }
+
+    @Override
+    public boolean hasOutstandingTasks() {
+        return getDownloadExecutor().getTaskCount() > getDownloadExecutor().getCompletedTaskCount();
+    }
+
+    private static ThreadPoolExecutor getDownloadExecutor() {
+        return DEFAULT_DOWNLOAD_JOB_DISPATCHER;
+    }
+}
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
index 000000000..748172f5f
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoaderJob.java
@@ -0,0 +1,26 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
+import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
+import org.openstreetmap.josm.data.imagery.TMSCachedTileLoaderJob;
+import org.openstreetmap.josm.data.imagery.TileJobOptions;
+
+import org.apache.commons.jcs3.access.behavior.ICacheAccess;
+
+/**
+ * Bridge to JCS cache for MVT tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MapBoxVectorCachedTileLoaderJob extends TMSCachedTileLoaderJob {
+
+    public MapBoxVectorCachedTileLoaderJob(TileLoaderListener listener, Tile tile,
+            ICacheAccess<String, BufferedImageCacheEntry> cache, TileJobOptions options,
+            ThreadPoolExecutor downloadExecutor) {
+        super(listener, tile, cache, options, downloadExecutor);
+    }
+}
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
index 000000000..413c7b32b
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java
@@ -0,0 +1,92 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import javax.json.Json;
+import javax.json.JsonException;
+import javax.json.JsonReader;
+
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.JosmTemplatedTMSTileSource;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.MapBoxVectorStyle;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.Source;
+import org.openstreetmap.josm.gui.ExtendedDialog;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.gui.widgets.JosmComboBox;
+import org.openstreetmap.josm.io.CachedFile;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * Tile Source handling for Mapbox Vector Tile sources
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MapboxVectorTileSource extends JosmTemplatedTMSTileSource {
+    private final MapBoxVectorStyle styleSource;
+
+    /**
+     * Create a new {@link MapboxVectorTileSource} from an {@link ImageryInfo}
+     * @param info The info to create the source from
+     */
+    public MapboxVectorTileSource(ImageryInfo info) {
+        super(info);
+        MapBoxVectorStyle mapBoxVectorStyle = null;
+        try (CachedFile style = new CachedFile(info.getUrl());
+          InputStream inputStream = style.getInputStream();
+          JsonReader reader = Json.createReader(inputStream)) {
+            reader.readObject();
+            // OK, we have a stylesheet
+            mapBoxVectorStyle = MapBoxVectorStyle.getMapBoxVectorStyle(info.getUrl());
+        } catch (IOException | JsonException e) {
+            Logging.trace(e);
+        }
+        this.styleSource = mapBoxVectorStyle;
+        if (this.styleSource != null) {
+            final Source source;
+            List<Source> sources = this.styleSource.getSources().keySet().stream().filter(Objects::nonNull)
+              .collect(Collectors.toList());
+            if (sources.size() == 1) {
+                source = sources.get(0);
+            } else if (!sources.isEmpty()) {
+                // Ask user what source they want.
+                source = GuiHelper.runInEDTAndWaitAndReturn(() -> {
+                    ExtendedDialog dialog = new ExtendedDialog(MainApplication.getMainFrame(),
+                      tr("Select Vector Tile Layers"), tr("Add layers"));
+                    JosmComboBox<Source> comboBox = new JosmComboBox<>(sources.toArray(new Source[0]));
+                    comboBox.setSelectedIndex(0);
+                    dialog.setContent(comboBox);
+                    dialog.showDialog();
+                    return (Source) comboBox.getSelectedItem();
+                });
+            } else {
+                // Umm. What happened? We probably have an invalid style source.
+                throw new InvalidMapboxVectorTileException(tr("Cannot understand style source: {0}", info.getUrl()));
+            }
+            if (source != null) {
+                this.name = name + ": " + source.getName();
+                // There can technically be multiple URL's for this field; unfortunately, JOSM can only handle one right now.
+                this.baseUrl = source.getUrls().get(0);
+                this.minZoom = source.getMinZoom();
+                this.maxZoom = source.getMaxZoom();
+                if (source.getAttributionText() != null) {
+                    this.setAttributionText(source.getAttributionText());
+                }
+            }
+        }
+    }
+
+    /**
+     * Get the style source for this Vector Tile source
+     * @return The source to use for styling
+     */
+    public MapBoxVectorStyle getStyleSource() {
+        return this.styleSource;
+    }
+}
diff --git a/test/data/mapillary.json b/test/data/mapillary.json
new file mode 100644
index 000000000..0f6f9483d
--- /dev/null
+++ b/test/data/mapillary.json
@@ -0,0 +1,111 @@
+{
+  "version":8,
+  "name":"Mapillary",
+  "owner":"Mapillary",
+  "id":"mapillary",
+  "sources":{
+      "mapillary-source":{
+        "type":"vector",
+        "tiles":[
+            "https://tiles3.mapillary.com/v0.1/{z}/{x}/{y}.mvt"
+        ],
+        "maxzoom":14
+      },
+      "mapillary-features-source": {
+        "maxzoom": 20,
+        "minzoom": 14,
+        "tiles": [ "https://a.mapillary.com/v3/map_features?tile={z}/{x}/{y}&client_id=_apiKey_&layers=points&per_page=1000" ],
+        "type": "vector"
+      },
+      "mapillary-traffic-signs-source": {
+        "maxzoom": 20,
+        "minzoom": 14,
+        "tiles": [ "https://a.mapillary.com/v3/map_features?tile={z}/{x}/{y}&client_id=_apiKey_&layers=trafficsigns&per_page=1000" ],
+        "type": "vector"
+      }
+  },
+  "layers":[
+    {
+      "filter": [ "==", "pano", 1 ],
+      "id": "mapillary-panos",
+      "type": "circle",
+      "source": "mapillary-source",
+      "source-layer": "mapillary-images",
+      "minzoom": 17,
+      "paint": {
+        "circle-color": "#05CB63",
+        "circle-opacity": 0.5,
+        "circle-radius": 18
+      }
+    },
+    {
+      "id": "mapillary-dots",
+      "type": "circle",
+      "source": "mapillary-source",
+      "source-layer": "mapillary-images",
+      "interactive": true,
+      "minzoom": 14,
+      "paint": {
+        "circle-color": "#05CB63",
+        "circle-radius": 6
+      }
+    },
+    {
+      "id": "mapillary-lines",
+      "type": "line",
+      "source": "mapillary-source",
+      "source-layer": "mapillary-sequences",
+      "minzoom": 6,
+      "paint": {
+        "line-color": "#05CB63",
+        "line-width": 2
+      }
+    },
+    {
+      "id": "mapillary-overview",
+      "type": "circle",
+      "source": "mapillary-source",
+      "source-layer": "mapillary-sequence-overview",
+      "maxzoom": 6,
+      "paint": {
+        "circle-radius": 4,
+        "circle-opacity": 0.6,
+        "circle-color": "#05CB63"
+      }
+    },
+    {
+      "id": "mapillary-features",
+      "type": "symbol",
+      "source": "mapillary-features-source",
+      "source-layer": "mapillary-map-features",
+      "interactive": true,
+      "minzoom": 14,
+      "layout": {
+        "icon-image": "{value}",
+        "icon-allow-overlap": true,
+        "symbol-avoid-edges": true
+      },
+      "paint": {
+        "text-color": "#fff",
+        "text-halo-color": "#000"
+      }
+    },
+    {
+      "id": "mapillary-traffic-signs",
+      "type": "symbol",
+      "source": "mapillary-traffic-signs-source",
+      "source-layer": "mapillary-map-features",
+      "interactive": true,
+      "minzoom": 14,
+      "layout": {
+        "icon-image": "{value}",
+        "icon-allow-overlap": true,
+        "symbol-avoid-edges": true
+      },
+      "paint": {
+        "text-color": "#fff",
+        "text-halo-color": "#000"
+      }
+    }
+  ]
+}
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/FeatureTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/FeatureTest.java
new file mode 100644
index 000000000..5468fe649
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/FeatureTest.java
@@ -0,0 +1,122 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+import static org.openstreetmap.josm.data.imagery.vectortile.mapbox.LayerTest.getSimpleFeatureLayerBytes;
+import static org.openstreetmap.josm.data.imagery.vectortile.mapbox.LayerTest.getLayer;
+
+import java.text.NumberFormat;
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link Feature}
+ */
+class FeatureTest {
+    /**
+     * This can be used to replace bytes 11-14 (inclusive) in {@link LayerTest#simpleFeatureLayerBytes}.
+     */
+    private final byte[] nonPackedTags = new byte[] {0x10, 0x00, 0x10, 0x00};
+
+    @Test
+    void testCreation() {
+        testCreation(getSimpleFeatureLayerBytes());
+    }
+
+    @Test
+    void testCreationUnpacked() {
+        byte[] copyBytes = getSimpleFeatureLayerBytes();
+        System.arraycopy(nonPackedTags, 0, copyBytes, 13, nonPackedTags.length);
+        testCreation(copyBytes);
+    }
+
+    @Test
+    void testCreationTrueToFalse() {
+        byte[] copyBytes = getSimpleFeatureLayerBytes();
+        copyBytes[copyBytes.length - 1] = 0x00; // set value=false
+        Layer layer = assertDoesNotThrow(() -> getLayer(copyBytes));
+        assertSame(Boolean.FALSE, layer.getValue(0));
+    }
+
+    @Test
+    void testNumberGrouping() {
+        // This is the float we are adding
+        // 49 74 24 00 == 1_000_000f
+        // 3f 80 00 00 == 1f
+        byte[] newBytes = new byte[] {0x22, 0x09, 0x15, 0x00, 0x24, 0x74, 0x49};
+        byte[] copyBytes = Arrays.copyOf(getSimpleFeatureLayerBytes(), getSimpleFeatureLayerBytes().length + newBytes.length - 4);
+        // Change last few bytes
+        System.arraycopy(newBytes, 0, copyBytes, 25, newBytes.length);
+        // Update the length of the record
+        copyBytes[1] = (byte) (copyBytes[1] + newBytes.length - 4);
+        final NumberFormat numberFormat = NumberFormat.getNumberInstance();
+        final boolean numberFormatGroupingUsed = numberFormat.isGroupingUsed();
+        // Sanity check
+        Layer layer;
+        try {
+            numberFormat.setGroupingUsed(true);
+            layer = assertDoesNotThrow(() -> getLayer(copyBytes));
+            assertTrue(numberFormat.isGroupingUsed());
+        } finally {
+            numberFormat.setGroupingUsed(numberFormatGroupingUsed);
+        }
+        assertEquals(1, layer.getFeatures().size());
+        assertEquals("t", layer.getName());
+        assertEquals(2, layer.getVersion());
+        assertEquals("a", layer.getKey(0));
+        assertEquals(1_000_000f, ((Number) layer.getValue(0)).floatValue(), 0.00001);
+        
+        // Feature check
+        Feature feature = layer.getFeatures().iterator().next();
+        checkDefaultGeometry(feature);
+        assertEquals("1000000", feature.getTags().get("a"));
+    }
+
+    private void testCreation(byte[] bytes) {
+        Layer layer = assertDoesNotThrow(() -> getLayer(bytes));
+        // Sanity check the layer
+        assertEquals(1, layer.getFeatures().size());
+        assertEquals("t", layer.getName());
+        assertEquals(2, layer.getVersion());
+        assertEquals("a", layer.getKey(0));
+        assertSame(Boolean.TRUE, layer.getValue(0));
+
+        // OK. Get the feature.
+        Feature feature = layer.getFeatures().iterator().next();
+
+        checkDefaultTags(feature);
+
+        // Check id (should be the default of 0)
+        assertEquals(1, feature.getId());
+
+        checkDefaultGeometry(feature);
+    }
+
+    private void checkDefaultTags(Feature feature) {
+        // Check tags
+        assertEquals(1, feature.getTags().size());
+        assertTrue(feature.getTags().containsKey("a"));
+        // We are converting to a tag map (Map<String, String>), so "true"
+        assertEquals("true", feature.getTags().get("a"));
+    }
+
+    private void checkDefaultGeometry(Feature feature) {
+        // Check the geometry
+        assertEquals(GeometryTypes.POINT, feature.getGeometryType());
+        assertEquals(1, feature.getGeometry().size());
+        CommandInteger geometry = feature.getGeometry().get(0);
+        assertEquals(Command.MoveTo, geometry.getType());
+        assertEquals(2, geometry.getOperations().length);
+        assertEquals(25, geometry.getOperations()[0]);
+        assertEquals(17, geometry.getOperations()[1]);
+        assertNotNull(feature.getGeometryObject());
+        assertEquals(feature.getGeometryObject(), feature.getGeometryObject());
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTest.java
new file mode 100644
index 000000000..175d64cd7
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTest.java
@@ -0,0 +1,169 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+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.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link Geometry}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class GeometryTest {
+    /**
+     * Create a command integer fairly easily
+     * @param command The command type (see {@link Command})
+     * @param parameters The parameters for the command
+     * @return A command integer
+     */
+    private static CommandInteger createCommandInteger(int command, int... parameters) {
+        CommandInteger commandInteger = new CommandInteger(command);
+        if (parameters != null) {
+            for (int parameter : parameters) {
+                commandInteger.addParameter(parameter);
+            }
+        }
+        return commandInteger;
+    }
+
+    /**
+     * Check the current
+     * @param pathIterator The path to check
+     * @param expected The expected coords
+     */
+    private static void checkCurrentSegmentAndIncrement(PathIterator pathIterator, float... expected) {
+        float[] coords = new float[6];
+        int type = pathIterator.currentSegment(coords);
+        pathIterator.next();
+        for (int i = 0; i < expected.length; i++) {
+            assertEquals(expected[i], coords[i]);
+        }
+        if (Arrays.asList(PathIterator.SEG_MOVETO, PathIterator.SEG_LINETO).contains(type)) {
+            assertEquals(2, expected.length, "You should check both x and y coordinates");
+        } else if (PathIterator.SEG_QUADTO == type) {
+            assertEquals(4, expected.length, "You should check all x and y coordinates");
+        } else if (PathIterator.SEG_CUBICTO == type) {
+            assertEquals(6, expected.length, "You should check all x and y coordinates");
+        } else if (PathIterator.SEG_CLOSE == type) {
+            assertEquals(0, expected.length, "CloseTo has no expected coordinates to check");
+        }
+    }
+
+    @Test
+    void testBadGeometry() {
+        IllegalArgumentException badPointException = assertThrows(IllegalArgumentException.class,
+          () -> new Geometry(GeometryTypes.POINT, Collections.singletonList(createCommandInteger(1))));
+        assertEquals("POINT with 0 arguments is not understood", badPointException.getMessage());
+        IllegalArgumentException badLineException = assertThrows(IllegalArgumentException.class,
+          () -> new Geometry(GeometryTypes.LINESTRING, Collections.singletonList(createCommandInteger(15))));
+        assertEquals("LINESTRING with 0 arguments is not understood", badLineException.getMessage());
+    }
+
+    @Test
+    void testPoint() {
+        CommandInteger moveTo = createCommandInteger(9, 17, 34);
+        Geometry geometry = new Geometry(GeometryTypes.POINT, Collections.singletonList(moveTo));
+        assertEquals(1, geometry.getShapes().size());
+        Ellipse2D shape = (Ellipse2D) geometry.getShapes().iterator().next();
+        assertEquals(17, shape.getCenterX());
+        assertEquals(34, shape.getCenterY());
+    }
+
+    @Test
+    void testLine() {
+        CommandInteger moveTo = createCommandInteger(9, 2, 2);
+        CommandInteger lineTo = createCommandInteger(18, 0, 8, 8, 0);
+        Geometry geometry = new Geometry(GeometryTypes.LINESTRING, Arrays.asList(moveTo, lineTo));
+        assertEquals(1, geometry.getShapes().size());
+        Path2D path = (Path2D) geometry.getShapes().iterator().next();
+        PathIterator pathIterator = path.getPathIterator(null);
+        checkCurrentSegmentAndIncrement(pathIterator, 2, 2);
+        checkCurrentSegmentAndIncrement(pathIterator, 2, 10);
+        checkCurrentSegmentAndIncrement(pathIterator, 10, 10);
+        assertTrue(pathIterator.isDone());
+    }
+
+    @Test
+    void testPolygon() {
+        List<CommandInteger> commands = new ArrayList<>(3);
+        commands.add(createCommandInteger(9, 3, 6));
+        commands.add(createCommandInteger(18, 5, 6, 12, 22));
+        commands.add(createCommandInteger(15));
+
+        Geometry geometry = new Geometry(GeometryTypes.POLYGON, commands);
+        assertEquals(1, geometry.getShapes().size());
+
+        Area area = (Area) geometry.getShapes().iterator().next();
+        PathIterator pathIterator = area.getPathIterator(null);
+        checkCurrentSegmentAndIncrement(pathIterator, 3, 6);
+        // This is somewhat unexpected, and may change based off of JVM implementations
+        // But for whatever reason, Java flips the inner coordinates in this case.
+        checkCurrentSegmentAndIncrement(pathIterator, 20, 34);
+        checkCurrentSegmentAndIncrement(pathIterator, 8, 12);
+        checkCurrentSegmentAndIncrement(pathIterator, 3, 6);
+        checkCurrentSegmentAndIncrement(pathIterator);
+        assertTrue(pathIterator.isDone());
+    }
+
+    @Test
+    void testBadPolygon() {
+        /*
+         * "Linear rings MUST be geometric objects that have no anomalous geometric points,
+         * such as self-intersection or self-tangency. The position of the cursor before
+         * calling the ClosePath command of a linear ring SHALL NOT repeat the same position
+         * as the first point in the linear ring as this would create a zero-length line
+         * segment. A linear ring SHOULD NOT have an area calculated by the surveyor's
+         * formula equal to zero, as this would signify a ring with anomalous geometric points."
+         */
+        List<CommandInteger> commands = new ArrayList<>(3);
+        commands.add(createCommandInteger(9, 0, 0));
+        commands.add(createCommandInteger(18, 0, 0));
+        commands.add(createCommandInteger(15));
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> new Geometry(GeometryTypes.POLYGON, commands));
+        assertEquals("POLYGON cannot have zero area", exception.getMessage());
+    }
+
+    @Test
+    void testMultiPolygon() {
+        List<CommandInteger> commands = new ArrayList<>(10);
+        // Polygon 1
+        commands.add(createCommandInteger(9, 0, 0));
+        commands.add(createCommandInteger(26, 10, 0, 0, 10, -10, 0));
+        commands.add(createCommandInteger(15));
+        // Polygon 2 outer
+        commands.add(createCommandInteger(9, 11, 1));
+        commands.add(createCommandInteger(26, 9, 0, 0, 9, -9, 0));
+        commands.add(createCommandInteger(15));
+        // Polygon 2 inner
+        commands.add(createCommandInteger(9, 2, -7));
+        commands.add(createCommandInteger(26, 0, 4, 4, 0, 0, -4));
+        commands.add(createCommandInteger(15));
+
+        Geometry geometry = new Geometry(GeometryTypes.POLYGON, commands);
+        assertEquals(1, geometry.getShapes().size());
+        Area area = (Area) geometry.getShapes().iterator().next();
+        assertFalse(area.isSingular());
+        PathIterator pathIterator = area.getPathIterator(null);
+        assertEquals(PathIterator.WIND_NON_ZERO, pathIterator.getWindingRule());
+        assertTrue(area.contains(new Point2D.Float(5, 5)));
+        assertTrue(area.contains(new Point2D.Float(12, 12)));
+        assertFalse(area.contains(new Point2D.Float(15, 15)));
+        assertFalse(area.contains(new Point2D.Float(10, 11)));
+        assertFalse(area.contains(new Point2D.Float(-1, -1)));
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypesTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypesTest.java
new file mode 100644
index 000000000..da0f9b3c7
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/GeometryTypesTest.java
@@ -0,0 +1,47 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+
+import org.openstreetmap.josm.TestUtils;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/**
+ * Test class for {@link GeometryTypes}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class GeometryTypesTest {
+    @Test
+    void testNaiveEnumTest() {
+        TestUtils.superficialEnumCodeCoverage(GeometryTypes.class);
+        TestUtils.superficialEnumCodeCoverage(GeometryTypes.Ring.class);
+    }
+
+    @ParameterizedTest
+    @EnumSource(GeometryTypes.class)
+    void testExpectedIds(GeometryTypes type) {
+        // Ensure that users can get the type from the ordinal
+        // See https://github.com/mapbox/vector-tile-spec/blob/master/2.1/vector_tile.proto#L8
+        // for the expected values
+        final int expectedId;
+        if (type == GeometryTypes.UNKNOWN) {
+            expectedId = 0;
+        } else if (type == GeometryTypes.POINT) {
+            expectedId = 1;
+        } else if (type == GeometryTypes.LINESTRING) {
+            expectedId = 2;
+        } else if (type == GeometryTypes.POLYGON) {
+            expectedId = 3;
+        } else {
+            fail("Unknown geometry type, see vector tile spec");
+            expectedId = Integer.MIN_VALUE;
+        }
+        assertEquals(expectedId, type.ordinal());
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java
new file mode 100644
index 000000000..fc3ba9c27
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java
@@ -0,0 +1,135 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.protobuf.ProtoBufParser;
+import org.openstreetmap.josm.data.protobuf.ProtoBufRecord;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link Layer}
+ */
+public class LayerTest {
+    /**
+     * This looks something like this (if it were json). Note that some keys could be repeated,
+     * and so could be better represented as an array. Specifically, "features", "key", and "value".
+     * "layer": {
+     *     "name": "t",
+     *     "version": 2,
+     *     "features": {
+     *         "type": "POINT",
+     *         "tags": [0, 0],
+     *         "geometry": [9, 50, 34]
+     *     },
+     *     "key": "a",
+     *     "value": true
+     * }
+     *
+     * WARNING: DO NOT MODIFY THIS ARRAY DIRECTLY -- it could contaminate other tests
+     */
+    private static final byte[] simpleFeatureLayerBytes = new byte[] {
+      0x1a, 0x1b, // layer, 27 bytes for the rest
+      0x0a, 0x01, 0x74, // name=t
+      0x78, 0x02, // version=2
+      0x12, 0x0d, // features, 11 bytes
+      0x08, 0x01, // id=1
+      0x18, 0x01, // type=POINT
+      0x12, 0x02, 0x00, 0x00, // tags=[0, 0] (packed). Non-packed would be [0x10, 0x00, 0x10, 0x00]
+      0x22, 0x03, 0x09, 0x32, 0x22, // geometry=[9, 50, 34]
+      0x1a, 0x01, 0x61, // key=a
+      0x22, 0x02, 0x38, 0x01, // value=true (boolean)
+    };
+
+    /**
+     * Gets a copy of {@link #simpleFeatureLayerBytes} so that a test doesn't accidentally change the bytes
+     * @return An array that can be modified.
+     */
+    static byte[] getSimpleFeatureLayerBytes() {
+        return Arrays.copyOf(simpleFeatureLayerBytes, simpleFeatureLayerBytes.length);
+    }
+
+    /**
+     * Create a layer from bytes
+     * @param bytes The bytes that make up the layer
+     * @return The generated layer
+     * @throws IOException If something happened (should never trigger)
+     */
+    static Layer getLayer(byte[] bytes) throws IOException {
+        List<ProtoBufRecord> records = (List<ProtoBufRecord>) new ProtoBufParser(bytes).allRecords();
+        assertEquals(1, records.size());
+        return new Layer(new ProtoBufParser(records.get(0).getBytes()).allRecords());
+    }
+
+    @Test
+    void testLayerCreation() throws IOException {
+        List<ProtoBufRecord> layers = (List<ProtoBufRecord>) new ProtoBufParser(new FileInputStream(TestUtils.getTestDataRoot()
+          + "pbf/mapillary/14/3249/6258.mvt")).allRecords();
+        Layer sequenceLayer = new Layer(layers.get(0).getBytes());
+        assertEquals("mapillary-sequences", sequenceLayer.getName());
+        assertEquals(1, sequenceLayer.getFeatures().size());
+        assertEquals(1, sequenceLayer.getGeometry().size());
+        assertEquals(2048, sequenceLayer.getExtent());
+        assertEquals(1, sequenceLayer.getVersion());
+
+        Layer imageLayer = new Layer(layers.get(1).getBytes());
+        assertEquals("mapillary-images", imageLayer.getName());
+        assertEquals(116, imageLayer.getFeatures().size());
+        assertEquals(116, imageLayer.getGeometry().size());
+        assertEquals(2048, imageLayer.getExtent());
+        assertEquals(1, imageLayer.getVersion());
+    }
+
+    @Test
+    void testLayerEqualsHashCode() throws IOException {
+        List<ProtoBufRecord> layers = (List<ProtoBufRecord>) new ProtoBufParser(new FileInputStream(TestUtils.getTestDataRoot()
+          + "pbf/mapillary/14/3249/6258.mvt")).allRecords();
+        EqualsVerifier.forClass(Layer.class).withPrefabValues(byte[].class, layers.get(0).getBytes(), layers.get(1).getBytes())
+          .verify();
+    }
+
+    @Test
+    void testVersionsNumbers() {
+        byte[] copyByte = getSimpleFeatureLayerBytes();
+        assertEquals(2, assertDoesNotThrow(() -> getLayer(copyByte)).getVersion());
+        copyByte[6] = 1;
+        assertEquals(1, assertDoesNotThrow(() -> getLayer(copyByte)).getVersion());
+        copyByte[6] = 0;
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> getLayer(copyByte));
+        assertEquals("We do not understand version 0 of the vector tile specification", exception.getMessage());
+        copyByte[6] = 3;
+        exception = assertThrows(IllegalArgumentException.class, () -> getLayer(copyByte));
+        assertEquals("We do not understand version 3 of the vector tile specification", exception.getMessage());
+        // Remove version number (AKA change it to some unknown field). Default is version=1.
+        copyByte[5] = 0x18;
+        assertEquals(1, assertDoesNotThrow(() -> getLayer(copyByte)).getVersion());
+    }
+
+    @Test
+    void testLayerName() throws IOException {
+        byte[] copyByte = getSimpleFeatureLayerBytes();
+        Layer layer = getLayer(copyByte);
+        assertEquals("t", layer.getName());
+        copyByte[2] = 0x1a; // name=t -> ?
+        Exception noNameException = assertThrows(IllegalArgumentException.class, () -> getLayer(copyByte));
+        assertEquals("Vector tile layers must have a layer name", noNameException.getMessage());
+    }
+
+    @Test
+    void testUnknownField() {
+        byte[] copyByte = getSimpleFeatureLayerBytes();
+        copyByte[27] = 0x78;
+        Exception unknownField = assertThrows(IllegalArgumentException.class, () -> getLayer(copyByte));
+        assertEquals("Unknown field in vector tile layer value (15)", unknownField.getMessage());
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java
new file mode 100644
index 000000000..66e4ea781
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java
@@ -0,0 +1,82 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.awt.image.BufferedImage;
+import java.util.Collections;
+import java.util.stream.Stream;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.cache.JCSCacheManager;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.TileJobOptions;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+import org.awaitility.Awaitility;
+import org.awaitility.Durations;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Test class for {@link MVTTile}
+ */
+public class MVTTileTest {
+    private MapboxVectorTileSource tileSource;
+    private MapBoxVectorCachedTileLoader loader;
+    @RegisterExtension
+    JOSMTestRules rule = new JOSMTestRules();
+    @BeforeEach
+    void setup() {
+        tileSource = new MapboxVectorTileSource(new ImageryInfo("Test Mapillary", "file:/" + TestUtils.getTestDataRoot()
+          + "pbf/mapillary/{z}/{x}/{y}.mvt"));
+        loader = new MapBoxVectorCachedTileLoader(null,
+          JCSCacheManager.getCache("testMapillaryCache"), new TileJobOptions(1, 1, Collections
+          .emptyMap(), 3600));
+    }
+
+    /**
+     * Provide arguments for {@link #testMVTTile(BufferedImage, Boolean)}
+     * @return The arguments to use
+     */
+    private static Stream<Arguments> testMVTTile() {
+        return Stream.of(
+          Arguments.of(null, Boolean.TRUE),
+          Arguments.of(Tile.LOADING_IMAGE, Boolean.TRUE),
+          Arguments.of(Tile.ERROR_IMAGE, Boolean.TRUE),
+          Arguments.of(new BufferedImage(1, 1, BufferedImage.TYPE_BYTE_GRAY), Boolean.FALSE)
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("testMVTTile")
+    void testMVTTile(BufferedImage image, Boolean isLoaded) {
+        MVTTile tile = new MVTTile(tileSource, 3249, 6258, 14);
+        tile.setImage(image);
+        assertEquals(image, tile.getImage());
+
+        TileJob job = loader.createTileLoaderJob(tile);
+        job.submit();
+        Awaitility.await().atMost(Durations.ONE_SECOND).until(tile::isLoaded);
+        if (isLoaded) {
+            Awaitility.await().atMost(Durations.ONE_SECOND).until(() -> tile.getLayers() != null && tile.getLayers().size() > 1);
+            assertEquals(2, tile.getLayers().size());
+            // The test Mapillary tiles have 2048 instead of 4096 for their extent. This *may* change
+            // in future Mapillary tiles, so if the test PBF files are updated, beware.
+            assertEquals(2048, tile.getExtent());
+            // Ensure that we have the clear image set, such that the tile doesn't add to the dataset again
+            // and we don't have a loading image
+            assertEquals(MVTTile.CLEAR_LOADED, tile.getImage());
+        } else {
+            assertNull(tile.getLayers());
+            assertEquals(image, tile.getImage());
+        }
+    }
+
+}
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSourceTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSourceTest.java
new file mode 100644
index 000000000..5b9f16842
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSourceTest.java
@@ -0,0 +1,77 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.MapBoxVectorStyle;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.Source;
+import org.openstreetmap.josm.gui.ExtendedDialog;
+import org.openstreetmap.josm.gui.widgets.JosmComboBox;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+import org.openstreetmap.josm.testutils.mockers.ExtendedDialogMocker;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Test class for {@link MapboxVectorTileSource}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class MapboxVectorTileSourceTest {
+    @RegisterExtension
+    JOSMTestRules rule = new JOSMTestRules();
+    private static class SelectLayerDialogMocker extends ExtendedDialogMocker {
+        int index;
+        @Override
+        protected void act(final ExtendedDialog instance) {
+            ((JosmComboBox<?>) this.getContent(instance)).setSelectedIndex(index);
+        }
+
+        @Override
+        protected String getString(final ExtendedDialog instance) {
+            return String.join(";", ((Source) ((JosmComboBox<?>) this.getContent(instance)).getSelectedItem()).getUrls());
+        }
+    }
+
+    @Test
+    void testNoStyle() {
+        MapboxVectorTileSource tileSource = new MapboxVectorTileSource(
+          new ImageryInfo("Test Mapillary", "file:/" + TestUtils.getTestDataRoot() + "pbf/mapillary/{z}/{x}/{y}.mvt"));
+        assertNull(tileSource.getStyleSource());
+    }
+
+    private static Stream<Arguments> testMapillaryStyle() {
+        return Stream.of(Arguments.of(0, "Test Mapillary: mapillary-source", "https://tiles3.mapillary.com/v0.1/{z}/{x}/{y}.mvt"),
+          Arguments.of(1, "Test Mapillary: mapillary-features-source",
+            "https://a.mapillary.com/v3/map_features?tile={z}/{x}/{y}&client_id=_apiKey_"
+              + "&layers=points&per_page=1000"),
+          Arguments.of(2, "Test Mapillary: mapillary-traffic-signs-source",
+            "https://a.mapillary.com/v3/map_features?tile={z}/{x}/{y}&client_id=_apiKey_"
+              + "&layers=trafficsigns&per_page=1000"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("testMapillaryStyle")
+    void testMapillaryStyle(Integer index, String expected, String dialogMockerText) {
+        TestUtils.assumeWorkingJMockit();
+        SelectLayerDialogMocker extendedDialogMocker = new SelectLayerDialogMocker();
+        extendedDialogMocker.index = index;
+        extendedDialogMocker.getMockResultMap().put(dialogMockerText, "Add layers");
+        MapboxVectorTileSource tileSource = new MapboxVectorTileSource(
+          new ImageryInfo("Test Mapillary", "file:/" + TestUtils.getTestDataRoot() + "mapillary.json"));
+        MapBoxVectorStyle styleSource = tileSource.getStyleSource();
+        assertNotNull(styleSource);
+        assertEquals(expected, tileSource.toString());
+    }
+}
-- 
GitLab


From 391b4caff31a6e8b87be6d4c256ac2303315e36a Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Mon, 19 Apr 2021 16:09:36 -0600
Subject: [PATCH 03/50] Layer: FIXUP: extent is uint not sint

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/imagery/vectortile/mapbox/Layer.java              | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
index 09851e8c7..1c496d55d 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
@@ -107,7 +107,7 @@ public final class Layer {
         }
         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)
+        this.extent = sorted.getOrDefault((int) EXTENT_FIELD, Collections.emptyList()).parallelStream().map(ProtoBufRecord::asUnsignedVarInt)
                 .map(Number::intValue).findAny().orElse(DEFAULT_EXTENT);
 
         sorted.getOrDefault((int) KEY_FIELD, Collections.emptyList()).parallelStream().map(ProtoBufRecord::asString)
-- 
GitLab


From cd40f9014f178fd97f2679656cc328e73998f26f Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 8 Apr 2021 14:25:43 -0600
Subject: [PATCH 04/50] Initial Mapbox Vector Style implementation

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../vectortile/mapbox/style/Expression.java   |  99 +++
 .../vectortile/mapbox/style/Layers.java       | 519 +++++++++++++++
 .../mapbox/style/MapBoxVectorStyle.java       | 266 ++++++++
 .../vectortile/mapbox/style/Scheme.java       |  12 +
 .../vectortile/mapbox/style/Source.java       | 254 ++++++++
 .../vectortile/mapbox/style/SourceType.java   |  17 +
 .../josm/data/osm/IPrimitive.java             |   9 +
 .../josm/gui/mappaint/ElemStyles.java         | 118 ++--
 .../mapbox/style/ExpressionTest.java          |  53 ++
 .../vectortile/mapbox/style/LayersTest.java   | 601 ++++++++++++++++++
 .../mapbox/style/MapBoxVectorStyleTest.java   | 300 +++++++++
 .../vectortile/mapbox/style/SourceTest.java   | 188 ++++++
 12 files changed, 2383 insertions(+), 53 deletions(-)
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Expression.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Scheme.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java
 create mode 100644 src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceType.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/ExpressionTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyleTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceTest.java

diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Expression.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Expression.java
new file mode 100644
index 000000000..a7f677755
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Expression.java
@@ -0,0 +1,99 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import javax.json.JsonArray;
+import javax.json.JsonObject;
+import javax.json.JsonString;
+import javax.json.JsonValue;
+
+/**
+ * A MapBox vector style expression (immutable)
+ * @author Taylor Smock
+ * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/">https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/</a>
+ * @since xxx
+ */
+public final class Expression {
+    /** An empty expression to use */
+    public static final Expression EMPTY_EXPRESSION = new Expression(JsonValue.NULL);
+    private static final String EMPTY_STRING = "";
+
+    private final String mapcssFilterExpression;
+
+    /**
+     * Create a new filter expression. <i>Please note that this currently only supports basic comparators!</i>
+     * @param value The value to parse
+     */
+    public Expression(JsonValue value) {
+        if (value.getValueType() == JsonValue.ValueType.ARRAY) {
+            final JsonArray array = value.asJsonArray();
+            if (!array.isEmpty() && array.get(0).getValueType() == JsonValue.ValueType.STRING) {
+                if ("==".equals(array.getString(0))) {
+                    // The mapcss equivalent of == is = (for the most part)
+                    this.mapcssFilterExpression = convertToString(array.get(1)) + "=" + convertToString(array.get(2));
+                } else if (Arrays.asList("<=", ">=", ">", "<", "!=").contains(array.getString(0))) {
+                    this.mapcssFilterExpression = convertToString(array.get(1)) + array.getString(0) + convertToString(array.get(2));
+                } else {
+                    this.mapcssFilterExpression = EMPTY_STRING;
+                }
+            } else {
+                this.mapcssFilterExpression = EMPTY_STRING;
+            }
+        } else {
+            this.mapcssFilterExpression = EMPTY_STRING;
+        }
+    }
+
+    /**
+     * Convert a value to a string
+     * @param value The value to convert
+     * @return A string
+     */
+    private static String convertToString(JsonValue value) {
+        switch (value.getValueType()) {
+        case STRING:
+            return ((JsonString) value).getString();
+        case FALSE:
+            return Boolean.FALSE.toString();
+        case TRUE:
+            return Boolean.TRUE.toString();
+        case NUMBER:
+            return value.toString();
+        case ARRAY:
+            return '['
+              + ((JsonArray) value).stream().map(Expression::convertToString).collect(Collectors.joining(","))
+              + ']';
+        case OBJECT:
+            return '{'
+              + ((JsonObject) value).entrySet().stream()
+              .map(entry -> entry.getKey() + ":" + convertToString(entry.getValue())).collect(
+                Collectors.joining(","))
+              + '}';
+        case NULL:
+        default:
+            return EMPTY_STRING;
+        }
+    }
+
+    @Override
+    public String toString() {
+        return !EMPTY_STRING.equals(this.mapcssFilterExpression) ? '[' + this.mapcssFilterExpression + ']' : EMPTY_STRING;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other instanceof Expression) {
+            Expression o = (Expression) other;
+            return Objects.equals(this.mapcssFilterExpression, o.mapcssFilterExpression);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.mapcssFilterExpression);
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
new file mode 100644
index 000000000..9488c3d19
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
@@ -0,0 +1,519 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import java.awt.Font;
+import java.awt.GraphicsEnvironment;
+import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.json.JsonArray;
+import javax.json.JsonNumber;
+import javax.json.JsonObject;
+import javax.json.JsonString;
+import javax.json.JsonValue;
+
+/**
+ * MapBox style layers
+ * @author Taylor Smock
+ * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/">https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/</a>
+ * @since xxx
+ */
+public class Layers {
+    /**
+     * The layer type. This affects the rendering.
+     * @author Taylor Smock
+     * @since xxx
+     */
+    enum Type {
+        /** Filled polygon with an (optional) border */
+        FILL,
+        /** A line */
+        LINE,
+        /** A symbol */
+        SYMBOL,
+        /** A circle */
+        CIRCLE,
+        /** A heatmap */
+        HEATMAP,
+        /** A 3D polygon extrusion */
+        FILL_EXTRUSION,
+        /** Raster */
+        RASTER,
+        /** Hillshade data */
+        HILLSHADE,
+        /** A background color or pattern */
+        BACKGROUND,
+        /** The fallback layer */
+        SKY
+    }
+
+    private static final String EMPTY_STRING = "";
+    private static final char SEMI_COLON = ';';
+    private static final Pattern CURLY_BRACES = Pattern.compile("(\\{(.*?)})");
+
+    /** A required unique layer name */
+    private final String id;
+    /** The required type */
+    private final Type type;
+    /** An optional expression */
+    private final Expression filter;
+    /** The max zoom for the layer */
+    private final int maxZoom;
+    /** The min zoom for the layer */
+    private final int minZoom;
+
+    /** Default paint properties for this layer */
+    private final String paint;
+
+    /** A source description to be used with this layer. Required for everything <i>but</i> {@link Type#BACKGROUND} */
+    private final String source;
+    /** Layer to use from the vector tile source. Only allowed with {@link SourceType#VECTOR}. */
+    private final String sourceLayer;
+    /** The id for the style -- used for image paths */
+    private final String styleId;
+    /**
+     * Create a layer object
+     * @param layerInfo The info to use to create the layer
+     */
+    public Layers(final JsonObject layerInfo) {
+        this (null, layerInfo);
+    }
+
+    /**
+     * Create a layer object
+     * @param styleId The id for the style (image paths require this)
+     * @param layerInfo The info to use to create the layer
+     */
+    public Layers(final String styleId, final JsonObject layerInfo) {
+        this.id = layerInfo.getString("id");
+        this.styleId = styleId;
+        this.type = Type.valueOf(layerInfo.getString("type").replace("-", "_").toUpperCase(Locale.ROOT));
+        if (layerInfo.containsKey("filter")) {
+            this.filter = new Expression(layerInfo.get("filter"));
+        } else {
+            this.filter = Expression.EMPTY_EXPRESSION;
+        }
+        this.maxZoom = layerInfo.getInt("maxzoom", Integer.MAX_VALUE);
+        this.minZoom = layerInfo.getInt("minzoom", Integer.MIN_VALUE);
+        // There is a metadata field (I don't *think* I need it?)
+        // source is only optional with {@link Type#BACKGROUND}.
+        if (this.type == Type.BACKGROUND) {
+            this.source = layerInfo.getString("source", null);
+        } else {
+            this.source = layerInfo.getString("source");
+        }
+        if (layerInfo.containsKey("paint") && layerInfo.get("paint").getValueType() == JsonValue.ValueType.OBJECT) {
+            final JsonObject paintObject = layerInfo.getJsonObject("paint");
+            final JsonObject layoutObject = layerInfo.getOrDefault("layout", JsonValue.EMPTY_JSON_OBJECT).asJsonObject();
+            // Don't throw exceptions here, since we may just point at the styling
+            if ("visible".equalsIgnoreCase(layoutObject.getString("visibility", "visible"))) {
+                switch (type) {
+                case FILL:
+                    // area
+                    this.paint = parsePaintFill(paintObject);
+                    break;
+                case LINE:
+                    // way
+                    this.paint = parsePaintLine(layoutObject, paintObject);
+                    break;
+                case CIRCLE:
+                    // point
+                    this.paint = parsePaintCircle(paintObject);
+                    break;
+                case SYMBOL:
+                    // point
+                    this.paint = parsePaintSymbol(layoutObject, paintObject);
+                    break;
+                case BACKGROUND:
+                    // canvas only
+                    this.paint = parsePaintBackground(paintObject);
+                    break;
+                default:
+                    this.paint = EMPTY_STRING;
+                }
+            } else {
+                this.paint = EMPTY_STRING;
+            }
+        } else {
+            this.paint = EMPTY_STRING;
+        }
+        this.sourceLayer = layerInfo.getString("source-layer", null);
+    }
+
+    /**
+     * Get the filter for this layer
+     * @return The filter
+     */
+    public Expression getFilter() {
+        return this.filter;
+    }
+
+    /**
+     * Get the unique id for this layer
+     * @return The unique id
+     */
+    public String getId() {
+        return this.id;
+    }
+
+    /**
+     * Get the type of this layer
+     * @return The layer type
+     */
+    public Type getType() {
+        return this.type;
+    }
+
+    private static String parsePaintLine(final JsonObject layoutObject, final JsonObject paintObject) {
+        final StringBuilder sb = new StringBuilder(36);
+        // line-blur, default 0 (px)
+        // line-color, default #000000, disabled by line-pattern
+        final String color = paintObject.getString("line-color", "#000000");
+        sb.append("color:").append(color).append(SEMI_COLON);
+        // line-opacity, default 1 (0-1)
+        final JsonNumber opacity = paintObject.getJsonNumber("line-opacity");
+        if (opacity != null) {
+            sb.append("opacity:").append(opacity.numberValue().doubleValue()).append(SEMI_COLON);
+        }
+        // line-cap, default butt (butt|round|square)
+        final String cap = layoutObject.getString("line-cap", "butt");
+        sb.append("linecap:");
+        switch (cap) {
+        case "round":
+        case "square":
+            sb.append(cap);
+            break;
+        case "butt":
+        default:
+            sb.append("none");
+        }
+
+        sb.append(SEMI_COLON);
+        // line-dasharray, array of number >= 0, units in line widths, disabled by line-pattern
+        if (paintObject.containsKey("line-dasharray")) {
+            final JsonArray dashArray = paintObject.getJsonArray("line-dasharray");
+            sb.append("dashes:");
+            sb.append(dashArray.stream().filter(JsonNumber.class::isInstance).map(JsonNumber.class::cast)
+              .map(JsonNumber::toString).collect(Collectors.joining(",")));
+            sb.append(SEMI_COLON);
+        }
+        // line-gap-width
+        // line-gradient
+        // line-join
+        // line-miter-limit
+        // line-offset
+        // line-pattern TODO this first, since it disables stuff
+        // line-round-limit
+        // line-sort-key
+        // line-translate
+        // line-translate-anchor
+        // line-width
+        final JsonNumber width = paintObject.getJsonNumber("line-width");
+        sb.append("width:").append(width == null ? 1 : width.toString()).append(SEMI_COLON);
+        return sb.toString();
+    }
+
+    private static String parsePaintCircle(final JsonObject paintObject) {
+        final StringBuilder sb = new StringBuilder(150).append("symbol-shape:circle;")
+          // circle-blur
+          // circle-color
+          .append("symbol-fill-color:").append(paintObject.getString("circle-color", "#000000")).append(SEMI_COLON);
+        // circle-opacity
+        final JsonNumber fillOpacity = paintObject.getJsonNumber("circle-opacity");
+        sb.append("symbol-fill-opacity:").append(fillOpacity != null ? fillOpacity.numberValue().toString() : "1").append(SEMI_COLON);
+        // circle-pitch-alignment // not 3D
+        // circle-pitch-scale // not 3D
+        // circle-radius
+        final JsonNumber radius = paintObject.getJsonNumber("circle-radius");
+        sb.append("symbol-size:").append(radius != null ? (2 * radius.numberValue().doubleValue()) : "10").append(SEMI_COLON)
+          // circle-sort-key
+          // circle-stroke-color
+          .append("symbol-stroke-color:").append(paintObject.getString("circle-stroke-color", "#000000")).append(SEMI_COLON);
+        // circle-stroke-opacity
+        final JsonNumber strokeOpacity = paintObject.getJsonNumber("circle-stroke-opacity");
+        sb.append("symbol-stroke-opacity:").append(strokeOpacity != null ? strokeOpacity.numberValue().toString() : "1").append(SEMI_COLON);
+        // circle-stroke-width
+        final JsonNumber strokeWidth = paintObject.getJsonNumber("circle-stroke-width");
+        sb.append("symbol-stroke-width:").append(strokeWidth != null ? strokeWidth.numberValue().toString() : "0").append(SEMI_COLON);
+        // circle-translate
+        // circle-translate-anchor
+        return sb.toString();
+    }
+
+    private String parsePaintSymbol(
+      final JsonObject layoutObject,
+      final JsonObject paintObject) {
+        final StringBuilder sb = new StringBuilder();
+        // icon-allow-overlap
+        // icon-anchor
+        // icon-color
+        // icon-halo-blur
+        // icon-halo-color
+        // icon-halo-width
+        // icon-ignore-placement
+        // icon-image
+        boolean iconImage = false;
+        if (layoutObject.containsKey("icon-image")) {
+            sb.append("icon-image:concat(");
+            if (this.styleId != null && !this.styleId.trim().isEmpty()) {
+                sb.append('"').append(this.styleId).append('/').append("\",");
+            }
+            Matcher matcher = CURLY_BRACES.matcher(layoutObject.getString("icon-image"));
+            StringBuffer stringBuffer = new StringBuffer();
+            int previousMatch;
+            if (matcher.lookingAt()) {
+                matcher.appendReplacement(stringBuffer, "tag(\"$2\"),\"");
+                previousMatch = matcher.end();
+            } else {
+                previousMatch = 0;
+                stringBuffer.append('"');
+            }
+            while (matcher.find()) {
+                if (matcher.start() == previousMatch) {
+                    matcher.appendReplacement(stringBuffer, ",tag(\"$2\")");
+                } else {
+                    matcher.appendReplacement(stringBuffer, "\",tag(\"$2\"),\"");
+                }
+                previousMatch = matcher.end();
+            }
+            if (matcher.hitEnd() && stringBuffer.toString().endsWith(",\"")) {
+                stringBuffer.delete(stringBuffer.length() - ",\"".length(), stringBuffer.length());
+            } else if (!matcher.hitEnd()) {
+                stringBuffer.append('"');
+            }
+            StringBuffer tail = new StringBuffer();
+            matcher.appendTail(tail);
+            if (tail.length() > 0) {
+                String current = stringBuffer.toString();
+                if (!"\"".equals(current) && !current.endsWith(",\"")) {
+                    stringBuffer.append(",\"");
+                }
+                stringBuffer.append(tail);
+                stringBuffer.append('"');
+            }
+
+            sb.append(stringBuffer).append(')').append(SEMI_COLON);
+            iconImage = true;
+        }
+        // icon-keep-upright
+        // icon-offset
+        if (iconImage && layoutObject.containsKey("icon-offset")) {
+            // default [0, 0], right,down == positive, left,up == negative
+            final List<JsonNumber> offset = layoutObject.getJsonArray("icon-offset").getValuesAs(JsonNumber.class);
+            // Assume that the offset must be size 2. Probably not necessary, but docs aren't necessary clear.
+            if (offset.size() == 2) {
+                sb.append("icon-offset-x:").append(offset.get(0).doubleValue()).append(SEMI_COLON)
+                  .append("icon-offset-y:").append(offset.get(1).doubleValue()).append(SEMI_COLON);
+            }
+        }
+        // icon-opacity
+        if (iconImage && paintObject.containsKey("icon-opacity")) {
+            final double opacity = paintObject.getJsonNumber("icon-opacity").doubleValue();
+            sb.append("icon-opacity:").append(opacity).append(SEMI_COLON);
+        }
+        // icon-optional
+        // icon-padding
+        // icon-pitch-alignment
+        // icon-rotate
+        if (iconImage && layoutObject.containsKey("icon-rotate")) {
+            final double rotation = layoutObject.getJsonNumber("icon-rotate").doubleValue();
+            sb.append("icon-rotation:").append(rotation).append(SEMI_COLON);
+        }
+        // icon-rotation-alignment
+        // icon-size
+        // icon-text-fit
+        // icon-text-fit-padding
+        // icon-translate
+        // icon-translate-anchor
+        // symbol-avoid-edges
+        // symbol-placement
+        // symbol-sort-key
+        // symbol-spacing
+        // symbol-z-order
+        // text-allow-overlap
+        // text-anchor
+        // text-color
+        if (paintObject.containsKey("text-color")) {
+            sb.append("text-color:").append(paintObject.getString("text-color")).append(SEMI_COLON);
+        }
+        // text-field
+        if (layoutObject.containsKey("text-field")) {
+            sb.append("text:")
+              .append(layoutObject.getString("text-field").replace("}", EMPTY_STRING).replace("{", EMPTY_STRING))
+              .append(SEMI_COLON);
+        }
+        // text-font
+        if (layoutObject.containsKey("text-font")) {
+            List<String> fonts = layoutObject.getJsonArray("text-font").stream().filter(JsonString.class::isInstance)
+              .map(JsonString.class::cast).map(JsonString::getString).collect(Collectors.toList());
+            Font[] systemFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().getAllFonts();
+            for (String fontString : fonts) {
+                Collection<Font> fontMatches = Stream.of(systemFonts)
+                  .filter(font -> Arrays.asList(font.getName(), font.getFontName(), font.getFamily(), font.getPSName()).contains(fontString))
+                  .collect(Collectors.toList());
+                if (!fontMatches.isEmpty()) {
+                    final Font setFont = fontMatches.stream().filter(font -> font.getName().equals(fontString)).findAny()
+                      .orElseGet(() -> fontMatches.stream().filter(font -> font.getFontName().equals(fontString)).findAny()
+                        .orElseGet(() -> fontMatches.stream().filter(font -> font.getPSName().equals(fontString)).findAny()
+                        .orElseGet(() -> fontMatches.stream().filter(font -> font.getFamily().equals(fontString)).findAny().orElse(null))));
+                    if (setFont != null) {
+                        sb.append("font-family:\"").append(setFont.getFamily()).append('"').append(SEMI_COLON);
+                        sb.append("font-weight:").append(setFont.isBold() ? "bold" : "normal").append(SEMI_COLON);
+                        sb.append("font-style:").append(setFont.isItalic() ? "italic" : "normal").append(SEMI_COLON);
+                        break;
+                    }
+                }
+            }
+        }
+        // text-halo-blur
+        // text-halo-color
+        if (paintObject.containsKey("text-halo-color")) {
+            sb.append("text-halo-color:").append(paintObject.getString("text-halo-color")).append(SEMI_COLON);
+        }
+        // text-halo-width
+        if (paintObject.containsKey("text-halo-width")) {
+            sb.append("text-halo-radius:").append(paintObject.getJsonNumber("text-halo-width").intValue()).append(SEMI_COLON);
+        }
+        // text-ignore-placement
+        // text-justify
+        // text-keep-upright
+        // text-letter-spacing
+        // text-line-height
+        // text-max-angle
+        // text-max-width
+        // text-offset
+        // text-opacity
+        if (paintObject.containsKey("text-opacity")) {
+            sb.append("text-opacity:").append(paintObject.getJsonNumber("text-opacity").doubleValue()).append(SEMI_COLON);
+        }
+        // text-optional
+        // text-padding
+        // text-pitch-alignment
+        // text-radial-offset
+        // text-rotate
+        // text-rotation-alignment
+        // text-size
+        final JsonNumber textSize = layoutObject.getJsonNumber("text-size");
+        sb.append("font-size:").append(textSize != null ? textSize.numberValue().toString() : "16").append(SEMI_COLON);
+        // text-transform
+        // text-translate
+        // text-translate-anchor
+        // text-variable-anchor
+        // text-writing-mode
+        return sb.toString();
+    }
+
+    private static String parsePaintBackground(final JsonObject paintObject) {
+        final StringBuilder sb = new StringBuilder(20);
+        // background-color
+        final String bgColor = paintObject.getString("background-color", null);
+        if (bgColor != null) {
+            sb.append("fill-color:").append(bgColor).append(SEMI_COLON);
+        }
+        // background-opacity
+        // background-pattern
+        return sb.toString();
+    }
+
+    private static String parsePaintFill(final JsonObject paintObject) {
+        StringBuilder sb = new StringBuilder(50)
+          // fill-antialias
+          // fill-color
+          .append("fill-color:").append(paintObject.getString("fill-color", "#000000")).append(SEMI_COLON);
+        // fill-opacity
+        final JsonNumber opacity = paintObject.getJsonNumber("fill-opacity");
+        sb.append("fill-opacity:").append(opacity != null ? opacity.numberValue().toString() : "1").append(SEMI_COLON)
+          // fill-outline-color
+          .append("color:").append(paintObject.getString("fill-outline-color",
+          paintObject.getString("fill-color", "#000000"))).append(SEMI_COLON);
+        // fill-pattern
+        // fill-sort-key
+        // fill-translate
+        // fill-translate-anchor
+        return sb.toString();
+    }
+
+    /**
+     * Converts this layer object to a mapcss entry string (to be parsed later)
+     * @return The mapcss entry (string form)
+     */
+    @Override
+    public String toString() {
+        if (this.filter.toString().isEmpty() && this.paint.isEmpty()) {
+            return EMPTY_STRING;
+        } else if (this.type == Type.BACKGROUND) {
+            // AFAIK, paint has no zoom levels, and doesn't accept a layer
+            return "canvas{" + this.paint + "}";
+        }
+
+        final String zoomSelector;
+        if (this.minZoom == this.maxZoom) {
+            zoomSelector = "|z" + this.minZoom;
+        } else if (this.minZoom > Integer.MIN_VALUE && this.maxZoom == Integer.MAX_VALUE) {
+            zoomSelector = "|z" + this.minZoom + "-";
+        } else if (this.minZoom == Integer.MIN_VALUE && this.maxZoom < Integer.MAX_VALUE) {
+            zoomSelector = "|z-" + this.maxZoom;
+        } else if (this.minZoom > Integer.MIN_VALUE) {
+            zoomSelector = MessageFormat.format("|z{0}-{1}", this.minZoom, this.maxZoom);
+        } else {
+            zoomSelector = EMPTY_STRING;
+        }
+        final String commonData = zoomSelector + this.filter.toString() + "::" + this.id + "{" + this.paint + "}";
+
+        if (this.type == Type.CIRCLE || this.type == Type.SYMBOL) {
+            return "node" + commonData;
+        } else if (this.type == Type.FILL) {
+            return "area" + commonData;
+        } else if (this.type == Type.LINE) {
+            return "way" + commonData;
+        }
+        return super.toString();
+    }
+
+    /**
+     * Get the source that this applies to
+     * @return The source name
+     */
+    public String getSource() {
+        return this.source;
+    }
+
+    /**
+     * Get the layer that this applies to
+     * @return The layer name
+     */
+    public String getSourceLayer() {
+        return this.sourceLayer;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other != null && this.getClass() == other.getClass()) {
+            Layers o = (Layers) other;
+            return this.type == o.type
+              && this.minZoom == o.minZoom
+              && this.maxZoom == o.maxZoom
+              && Objects.equals(this.id, o.id)
+              && Objects.equals(this.styleId, o.styleId)
+              && Objects.equals(this.sourceLayer, o.sourceLayer)
+              && Objects.equals(this.source, o.source)
+              && Objects.equals(this.filter, o.filter)
+              && Objects.equals(this.paint, o.paint);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.type, this.minZoom, this.maxZoom, this.id, this.styleId, this.sourceLayer, this.source,
+          this.filter, this.paint);
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java
new file mode 100644
index 000000000..746913042
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java
@@ -0,0 +1,266 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Image;
+import java.awt.image.BufferedImage;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+import javax.imageio.ImageIO;
+import javax.json.Json;
+import javax.json.JsonArray;
+import javax.json.JsonObject;
+import javax.json.JsonReader;
+import javax.json.JsonStructure;
+import javax.json.JsonValue;
+
+import org.openstreetmap.josm.data.preferences.JosmBaseDirectories;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.mappaint.ElemStyles;
+import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource;
+import org.openstreetmap.josm.io.CachedFile;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * Create a mapping for a Mapbox Vector Style
+ *
+ * @author Taylor Smock
+ * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/">https://docs.mapbox.com/mapbox-gl-js/style-spec/</a>
+ * @since xxx
+ */
+public class MapBoxVectorStyle {
+
+    private static final ConcurrentHashMap<String, MapBoxVectorStyle> STYLE_MAPPING = new ConcurrentHashMap<>();
+
+    /**
+     * Get a MapBoxVector style for a URL
+     * @param url The url to get
+     * @return The MapBox Vector Style. May be {@code null} if there was an error.
+     */
+    public static MapBoxVectorStyle getMapBoxVectorStyle(String url) {
+        return STYLE_MAPPING.computeIfAbsent(url, key -> {
+            try (CachedFile style = new CachedFile(url);
+                    BufferedReader reader = style.getContentReader();
+                    JsonReader jsonReader = Json.createReader(reader)) {
+                JsonStructure structure = jsonReader.read();
+                return new MapBoxVectorStyle(structure.asJsonObject());
+            } catch (IOException e) {
+                Logging.error(e);
+            }
+            // Documentation indicates that this will <i>not</i> be entered into the map, which means that this will be
+            // retried if something goes wrong.
+            return null;
+        });
+    }
+
+    /** The version for the style specification */
+    private final int version;
+    /** The optional name for the vector style */
+    private final String name;
+    /** The optional URL for sprites. This mush be absolute (so it must contain the scheme, authority, and path). */
+    private final String spriteUrl;
+    /** The optional URL for glyphs. This may have replaceable values in it. */
+    private final String glyphUrl;
+    /** The required collection of sources with a list of layers that are applicable for that source*/
+    private final Map<Source, ElemStyles> sources;
+
+    /**
+     * Create a new MapBoxVector style. You should prefer {@link #getMapBoxVectorStyle(String)}
+     * for deduplication purposes.
+     *
+     * @param jsonObject The object to create the style from
+     * @see #getMapBoxVectorStyle(String)
+     */
+    public MapBoxVectorStyle(JsonObject jsonObject) {
+        // There should be a version specifier. We currently only support version 8.
+        // This can throw an NPE when there is no version number.
+        this.version = jsonObject.getInt("version");
+        if (this.version == 8) {
+            this.name = jsonObject.getString("name", null);
+            String id = jsonObject.getString("id", this.name);
+            this.spriteUrl = jsonObject.getString("sprite", null);
+            this.glyphUrl = jsonObject.getString("glyphs", null);
+            final List<Source> sourceList;
+            if (jsonObject.containsKey("sources") && jsonObject.get("sources").getValueType() == JsonValue.ValueType.OBJECT) {
+                final JsonObject sourceObj = jsonObject.getJsonObject("sources");
+                sourceList = sourceObj.entrySet().stream().filter(entry -> entry.getValue().getValueType() == JsonValue.ValueType.OBJECT)
+                  .map(entry -> new Source(entry.getKey(), entry.getValue().asJsonObject())).collect(Collectors.toList());
+            } else {
+                sourceList = Collections.emptyList();
+            }
+            final List<Layers> layers;
+            if (jsonObject.containsKey("layers") && jsonObject.get("layers").getValueType() == JsonValue.ValueType.ARRAY) {
+                JsonArray lArray = jsonObject.getJsonArray("layers");
+                layers = lArray.stream().filter(JsonObject.class::isInstance).map(JsonObject.class::cast).map(obj -> new Layers(id, obj))
+                  .collect(Collectors.toList());
+            } else {
+                layers = Collections.emptyList();
+            }
+            final Map<Optional<Source>, List<Layers>> sourceLayer = layers.stream().collect(
+              Collectors.groupingBy(layer -> sourceList.stream().filter(source -> source.getName().equals(layer.getSource()))
+                .findFirst(), LinkedHashMap::new, Collectors.toList()));
+            // Abuse HashMap null (null == default)
+            this.sources = new LinkedHashMap<>();
+            for (Map.Entry<Optional<Source>, List<Layers>> entry : sourceLayer.entrySet()) {
+                final Source source = entry.getKey().orElse(null);
+                final String data = entry.getValue().stream().map(Layers::toString).collect(Collectors.joining());
+                final String metaData = "meta{title:" + (source == null ? "Generated Style" :
+                  source.getName()) + ";version:\"autogenerated\";description:\"auto generated style\";}";
+
+                // This is the default canvas
+                final String canvas = "canvas{default-points:false;default-lines:false;}";
+                final MapCSSStyleSource style = new MapCSSStyleSource(metaData + canvas + data);
+                // Save to directory
+                MainApplication.worker.execute(() -> this.save((source == null ? data.hashCode() : source.getName()) + ".mapcss", style));
+                this.sources.put(source, new ElemStyles(Collections.singleton(style)));
+            }
+            if (this.spriteUrl != null && !this.spriteUrl.trim().isEmpty()) {
+                MainApplication.worker.execute(this::fetchSprites);
+            }
+        } else {
+            throw new IllegalArgumentException(tr("Vector Tile Style Version not understood: version {0} (json: {1})",
+              this.version, jsonObject));
+        }
+    }
+
+    /**
+     * Fetch sprites. Please note that this is (literally) only png. Unfortunately.
+     * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/sprite/">https://docs.mapbox.com/mapbox-gl-js/style-spec/sprite/</a>
+     */
+    private void fetchSprites() {
+        // HiDPI images first -- if this succeeds, don't bother with the lower resolution (JOSM has no method to switch)
+        try (CachedFile spriteJson = new CachedFile(this.spriteUrl + "@2x.json");
+          CachedFile spritePng = new CachedFile(this.spriteUrl + "@2x.png")) {
+            if (parseSprites(spriteJson, spritePng)) {
+                return;
+            }
+        }
+        try (CachedFile spriteJson = new CachedFile(this.spriteUrl + ".json");
+        CachedFile spritePng = new CachedFile(this.spriteUrl + ".png")) {
+            parseSprites(spriteJson, spritePng);
+        }
+    }
+
+    private boolean parseSprites(CachedFile spriteJson, CachedFile spritePng) {
+        /* JSON looks like this:
+         * { "image-name": {"width": width, "height": height, "x": x, "y": y, "pixelRatio": 1 }}
+         * width/height are the dimensions of the image
+         * x -- distance right from top left
+         * y -- distance down from top left
+         * pixelRatio -- this <i>appears</i> to be from the "@2x" (default 1)
+         * content -- [left, top corner, right, bottom corner]
+         * stretchX -- [[from, to], [from, to], ...]
+         * stretchY -- [[from, to], [from, to], ...]
+         */
+        final JsonObject spriteObject;
+        final BufferedImage spritePngImage;
+        try (BufferedReader spriteJsonBufferedReader = spriteJson.getContentReader();
+          JsonReader spriteJsonReader = Json.createReader(spriteJsonBufferedReader);
+          InputStream spritePngBufferedReader = spritePng.getInputStream()
+        ) {
+            spriteObject = spriteJsonReader.read().asJsonObject();
+            spritePngImage = ImageIO.read(spritePngBufferedReader);
+        } catch (IOException e) {
+            Logging.error(e);
+            return false;
+        }
+        for (Map.Entry<String, JsonValue> entry : spriteObject.entrySet()) {
+            final JsonObject info = entry.getValue().asJsonObject();
+            int width = info.getInt("width");
+            int height = info.getInt("height");
+            int x = info.getInt("x");
+            int y = info.getInt("y");
+            save(entry.getKey() + ".png", spritePngImage.getSubimage(x, y, width, height));
+        }
+        return true;
+    }
+
+    private void save(String name, Object object) {
+        final File cache;
+        if (object instanceof Image) {
+            // Images have a specific location where they are looked for
+            cache = new File(Config.getDirs().getUserDataDirectory(true), "images");
+        } else {
+            cache = JosmBaseDirectories.getInstance().getCacheDirectory(true);
+        }
+        final File location = new File(cache, this.name != null ? this.name : Integer.toString(this.hashCode()));
+        if ((!location.exists() && !location.mkdirs()) || (location.exists() && !location.isDirectory())) {
+            // Don't try to save if the file exists and is not a directory or we couldn't create it
+            return;
+        }
+        final File toSave = new File(location, name);
+        try (OutputStream fileOutputStream = Files.newOutputStream(toSave.toPath())) {
+            if (object instanceof String) {
+                fileOutputStream.write(((String) object).getBytes(StandardCharsets.UTF_8));
+            } else if (object instanceof MapCSSStyleSource) {
+                MapCSSStyleSource source = (MapCSSStyleSource) object;
+                try (InputStream inputStream = source.getSourceInputStream()) {
+                    int byteVal = inputStream.read();
+                    do {
+                        fileOutputStream.write(byteVal);
+                        byteVal = inputStream.read();
+                    } while (byteVal > -1);
+                    source.url = "file:/" + toSave.getAbsolutePath().replace('\\', '/');
+                    if (source.isLoaded()) {
+                        source.loadStyleSource();
+                    }
+                }
+            } else if (object instanceof BufferedImage) {
+                // This directory is checked first when getting images
+                ImageIO.write((BufferedImage) object, "png", toSave);
+            }
+        } catch (IOException e) {
+            Logging.info(e);
+        }
+    }
+
+    /**
+     * Get the generated layer->style mapping
+     * @return The mapping (use to enable/disable a paint style)
+     */
+    public Map<Source, ElemStyles> getSources() {
+        return this.sources;
+    }
+
+    /**
+     * Get the sprite url for the style
+     * @return The base sprite url
+     */
+    public String getSpriteUrl() {
+        return this.spriteUrl;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other != null && other.getClass() == this.getClass()) {
+            MapBoxVectorStyle o = (MapBoxVectorStyle) other;
+            return this.version == o.version
+              && Objects.equals(this.name, o.name)
+              && Objects.equals(this.glyphUrl, o.glyphUrl)
+              && Objects.equals(this.spriteUrl, o.spriteUrl)
+              && Objects.equals(this.sources, o.sources);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.name, this.version, this.glyphUrl, this.spriteUrl, this.sources);
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Scheme.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Scheme.java
new file mode 100644
index 000000000..e8583b940
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Scheme.java
@@ -0,0 +1,12 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+/**
+ * The scheme used for tiles
+ */
+public enum Scheme {
+    /** Standard slippy map scheme */
+    XYZ,
+    /** OSGeo specification scheme */
+    TMS
+}
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java
new file mode 100644
index 000000000..dc7c62d62
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java
@@ -0,0 +1,254 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.function.IntFunction;
+
+import javax.json.JsonArray;
+import javax.json.JsonObject;
+import javax.json.JsonString;
+import javax.json.JsonValue;
+
+import org.openstreetmap.josm.data.Bounds;
+
+/**
+ * A source from a MapBox Vector Style
+ *
+ * @author Taylor Smock
+ * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/">https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/</a>
+ * @since xxx
+ */
+public class Source {
+    /**
+     * A common function for zoom constraints
+     */
+    private static class ZoomBoundFunction implements IntFunction<Integer> {
+        private final int min;
+        private final int max;
+        /**
+         * Create a new bound for zooms
+         * @param min The min zoom
+         * @param max The max zoom
+         */
+        ZoomBoundFunction(int min, int max) {
+            this.min = min;
+            this.max = max;
+        }
+
+        @Override public Integer apply(int value) {
+            return Math.max(min, Math.min(value, max));
+        }
+    }
+
+    /**
+     * WMS servers should contain a "{bbox-epsg-3857}" parameter for the bbox
+     */
+    private static final String WMS_BBOX = "bbox-epsg-3857";
+
+    private static final String[] NO_URLS = new String[0];
+
+    /**
+     * Constrain the min/max zooms to be between 0 and 30, as per tilejson spec
+     */
+    private static final IntFunction<Integer> ZOOM_BOUND_FUNCTION = new ZoomBoundFunction(0, 30);
+
+    /* Common items */
+    /**
+     * The name of the source
+     */
+    private final String name;
+    /**
+     * The type of the source
+     */
+    private final SourceType sourceType;
+
+    /* Common tiled data */
+    /**
+     * The minimum zoom supported
+     */
+    private final int minZoom;
+    /**
+     * The maximum zoom supported
+     */
+    private final int maxZoom;
+    /**
+     * The tile urls. These usually have replaceable fields.
+     */
+    private final String[] tileUrls;
+
+    /* Vector and raster data */
+    /**
+     * The attribution to display for the user
+     */
+    private final String attribution;
+    /**
+     * The bounds of the data. We should not request data outside of the bounds
+     */
+    private final Bounds bounds;
+    /**
+     * The property to use as a feature id. Can be parameterized
+     */
+    private final String promoteId;
+    /**
+     * The tile scheme
+     */
+    private final Scheme scheme;
+    /**
+     * {@code true} if the tiles should not be cached
+     */
+    private final boolean volatileCache;
+
+    /* Raster data */
+    /**
+     * The tile size
+     */
+    private final int tileSize;
+
+    /**
+     * Create a new Source object
+     *
+     * @param name The name of the source object
+     * @param data The data to set the source information with
+     */
+    public Source(final String name, final JsonObject data) {
+        Objects.requireNonNull(name, "Name cannot be null");
+        Objects.requireNonNull(data, "Data cannot be null");
+        this.name = name;
+        // "type" is required (so throw an NPE if it doesn't exist)
+        final String type = data.getString("type");
+        this.sourceType = SourceType.valueOf(type.replace("-", "_").toUpperCase(Locale.ROOT));
+        // This can also contain SourceType.RASTER_DEM (only needs encoding)
+        if (SourceType.VECTOR == this.sourceType || SourceType.RASTER == this.sourceType) {
+            if (data.containsKey("url")) {
+                // TODO implement https://github.com/mapbox/tilejson-spec
+                throw new UnsupportedOperationException();
+            } else {
+                this.minZoom = ZOOM_BOUND_FUNCTION.apply(data.getInt("minzoom", 0));
+                this.maxZoom = ZOOM_BOUND_FUNCTION.apply(data.getInt("maxzoom", 22));
+                this.attribution = data.getString("attribution", null);
+                if (data.containsKey("bounds") && data.get("bounds").getValueType() == JsonValue.ValueType.ARRAY) {
+                    final JsonArray bJsonArray = data.getJsonArray("bounds");
+                    if (bJsonArray.size() != 4) {
+                        throw new IllegalArgumentException(MessageFormat.format("bounds must have four values, but has {0}", bJsonArray.size()));
+                    }
+                    final double[] bArray = new double[bJsonArray.size()];
+                    for (int i = 0; i < bJsonArray.size(); i++) {
+                        bArray[i] = bJsonArray.getJsonNumber(i).doubleValue();
+                    }
+                    // The order in the response is
+                    // [south-west longitude, south-west latitude, north-east longitude, north-east latitude]
+                    this.bounds = new Bounds(bArray[1], bArray[0], bArray[3], bArray[2]);
+                } else {
+                    // Don't use a static instance for bounds, as it is not a immutable class
+                    this.bounds = new Bounds(-85.051129, -180, 85.051129, 180);
+                }
+                this.promoteId = data.getString("promoteId", null);
+                this.scheme = Scheme.valueOf(data.getString("scheme", "xyz").toUpperCase(Locale.ROOT));
+                if (data.containsKey("tiles") && data.get("tiles").getValueType() == JsonValue.ValueType.ARRAY) {
+                    this.tileUrls = data.getJsonArray("tiles").stream().filter(JsonString.class::isInstance)
+                      .map(JsonString.class::cast).map(JsonString::getString)
+                      // Replace bbox-epsg-3857 with bbox (already encased with {})
+                      .map(url -> url.replace(WMS_BBOX, "bbox")).toArray(String[]::new);
+                } else {
+                    this.tileUrls = NO_URLS;
+                }
+                this.volatileCache = data.getBoolean("volatile", false);
+                this.tileSize = data.getInt("tileSize", 512);
+            }
+        } else {
+            throw new UnsupportedOperationException();
+        }
+    }
+
+    /**
+     * Get the bounds for this source
+     * @return The bounds where this source can be used
+     */
+    public Bounds getBounds() {
+        return this.bounds;
+    }
+
+    /**
+     * Get the source name
+     * @return the name
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Get the URLs that can be used to get vector data
+     *
+     * @return The urls
+     */
+    public List<String> getUrls() {
+        return Collections.unmodifiableList(Arrays.asList(this.tileUrls));
+    }
+
+    /**
+     * Get the minimum zoom
+     *
+     * @return The min zoom (default {@code 0})
+     */
+    public int getMinZoom() {
+        return this.minZoom;
+    }
+
+    /**
+     * Get the max zoom
+     *
+     * @return The max zoom (default {@code 22})
+     */
+    public int getMaxZoom() {
+        return this.maxZoom;
+    }
+
+    /**
+     * Get the attribution for this source
+     *
+     * @return The attribution text. May be {@code null}.
+     */
+    public String getAttributionText() {
+        return this.attribution;
+    }
+
+    @Override
+    public String toString() {
+        Collection<String> parts = new ArrayList<>(1 + this.getUrls().size());
+        parts.add(this.getName());
+        parts.addAll(this.getUrls());
+        return String.join(" ", parts);
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other != null && this.getClass() == other.getClass()) {
+            Source o = (Source) other;
+            return Objects.equals(this.name, o.name)
+              && this.sourceType == o.sourceType
+              && this.minZoom == o.minZoom
+              && this.maxZoom == o.maxZoom
+              && Objects.equals(this.attribution, o.attribution)
+              && Objects.equals(this.promoteId, o.promoteId)
+              && this.scheme == o.scheme
+              && this.volatileCache == o.volatileCache
+              && this.tileSize == o.tileSize
+              && Objects.equals(this.bounds, o.bounds)
+              && Objects.deepEquals(this.tileUrls, o.tileUrls);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.name, this.sourceType, this.minZoom, this.maxZoom, this.attribution, this.promoteId,
+          this.scheme, this.volatileCache, this.tileSize, this.bounds, Arrays.hashCode(this.tileUrls));
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceType.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceType.java
new file mode 100644
index 000000000..a086289d6
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceType.java
@@ -0,0 +1,17 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+/**
+ * The "source type" for the data (MapBox Vector Style specification)
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public enum SourceType {
+    VECTOR,
+    RASTER,
+    RASTER_DEM,
+    GEOJSON,
+    IMAGE,
+    VIDEO
+}
diff --git a/src/org/openstreetmap/josm/data/osm/IPrimitive.java b/src/org/openstreetmap/josm/data/osm/IPrimitive.java
index cdabcd1b6..34e2ca0be 100644
--- a/src/org/openstreetmap/josm/data/osm/IPrimitive.java
+++ b/src/org/openstreetmap/josm/data/osm/IPrimitive.java
@@ -391,6 +391,15 @@ public interface IPrimitive extends IQuadBucketType, Tagged, PrimitiveId, Stylab
         return getName();
     }
 
+    /**
+     * Get an object to synchronize the style cache on. This <i>should</i> be a field that does not change during paint.
+     * By default, it returns the current object, but should be overriden to avoid some performance issues.
+     * @return A non-{@code null} object to synchronize on when painting
+     */
+    default Object getStyleCacheSyncObject() {
+        return this;
+    }
+
     /**
      * Replies the display name of a primitive formatted by <code>formatter</code>
      * @param formatter formatter to use
diff --git a/src/org/openstreetmap/josm/gui/mappaint/ElemStyles.java b/src/org/openstreetmap/josm/gui/mappaint/ElemStyles.java
index fef095ea1..970635d96 100644
--- a/src/org/openstreetmap/josm/gui/mappaint/ElemStyles.java
+++ b/src/org/openstreetmap/josm/gui/mappaint/ElemStyles.java
@@ -85,6 +85,17 @@ public class ElemStyles implements PreferenceChangedListener {
         Config.getPref().addPreferenceChangeListener(this);
     }
 
+    /**
+     * Constructs a new {@code ElemStyles} with specific style sources. This does not listen to preference changes,
+     * and therefore should only be used with layers that have specific drawing requirements.
+     *
+     * @param sources The style sources (these cannot be added to, or removed from)
+     * @since xxx
+     */
+    public ElemStyles(Collection<StyleSource> sources) {
+        this.styleSources.addAll(sources);
+    }
+
     /**
      * Clear the style cache for all primitives of all DataSets.
      */
@@ -151,69 +162,71 @@ public class ElemStyles implements PreferenceChangedListener {
      * @since 13810 (signature)
      */
     public Pair<StyleElementList, Range> getStyleCacheWithRange(IPrimitive osm, double scale, NavigatableComponent nc) {
-        if (!osm.isCachedStyleUpToDate() || scale <= 0) {
-            osm.setCachedStyle(StyleCache.EMPTY_STYLECACHE);
-        } else {
-            Pair<StyleElementList, Range> lst = osm.getCachedStyle().getWithRange(scale, osm.isSelected());
-            if (lst.a != null)
-                return lst;
-        }
-        Pair<StyleElementList, Range> p = getImpl(osm, scale, nc);
-        if (osm instanceof INode && isDefaultNodes()) {
-            if (p.a.isEmpty()) {
-                if (TextLabel.AUTO_LABEL_COMPOSITION_STRATEGY.compose(osm) != null) {
-                    p.a = DefaultStyles.DEFAULT_NODE_STYLELIST_TEXT;
-                } else {
-                    p.a = DefaultStyles.DEFAULT_NODE_STYLELIST;
-                }
+        synchronized (osm.getStyleCacheSyncObject()) {
+            if (!osm.isCachedStyleUpToDate() || scale <= 0) {
+                osm.setCachedStyle(StyleCache.EMPTY_STYLECACHE);
             } else {
-                boolean hasNonModifier = false;
-                boolean hasText = false;
-                for (StyleElement s : p.a) {
-                    if (s instanceof BoxTextElement) {
-                        hasText = true;
+                Pair<StyleElementList, Range> lst = osm.getCachedStyle().getWithRange(scale, osm.isSelected());
+                if (lst.a != null)
+                    return lst;
+            }
+            Pair<StyleElementList, Range> p = getImpl(osm, scale, nc);
+            if (osm instanceof INode && isDefaultNodes()) {
+                if (p.a.isEmpty()) {
+                    if (TextLabel.AUTO_LABEL_COMPOSITION_STRATEGY.compose(osm) != null) {
+                        p.a = DefaultStyles.DEFAULT_NODE_STYLELIST_TEXT;
                     } else {
-                        if (!s.isModifier) {
-                            hasNonModifier = true;
+                        p.a = DefaultStyles.DEFAULT_NODE_STYLELIST;
+                    }
+                } else {
+                    boolean hasNonModifier = false;
+                    boolean hasText = false;
+                    for (StyleElement s : p.a) {
+                        if (s instanceof BoxTextElement) {
+                            hasText = true;
+                        } else {
+                            if (!s.isModifier) {
+                                hasNonModifier = true;
+                            }
                         }
                     }
-                }
-                if (!hasNonModifier) {
-                    p.a = new StyleElementList(p.a, DefaultStyles.SIMPLE_NODE_ELEMSTYLE);
-                    if (!hasText && TextLabel.AUTO_LABEL_COMPOSITION_STRATEGY.compose(osm) != null) {
-                        p.a = new StyleElementList(p.a, DefaultStyles.SIMPLE_NODE_TEXT_ELEMSTYLE);
+                    if (!hasNonModifier) {
+                        p.a = new StyleElementList(p.a, DefaultStyles.SIMPLE_NODE_ELEMSTYLE);
+                        if (!hasText && TextLabel.AUTO_LABEL_COMPOSITION_STRATEGY.compose(osm) != null) {
+                            p.a = new StyleElementList(p.a, DefaultStyles.SIMPLE_NODE_TEXT_ELEMSTYLE);
+                        }
                     }
                 }
-            }
-        } else if (osm instanceof IWay && isDefaultLines()) {
-            boolean hasProperLineStyle = false;
-            for (StyleElement s : p.a) {
-                if (s.isProperLineStyle()) {
-                    hasProperLineStyle = true;
-                    break;
-                }
-            }
-            if (!hasProperLineStyle) {
-                LineElement line = LineElement.UNTAGGED_WAY;
-                for (StyleElement element : p.a) {
-                    if (element instanceof AreaElement) {
-                        line = LineElement.createSimpleLineStyle(((AreaElement) element).color, true);
+            } else if (osm instanceof IWay && isDefaultLines()) {
+                boolean hasProperLineStyle = false;
+                for (StyleElement s : p.a) {
+                    if (s.isProperLineStyle()) {
+                        hasProperLineStyle = true;
                         break;
                     }
                 }
-                p.a = new StyleElementList(p.a, line);
+                if (!hasProperLineStyle) {
+                    LineElement line = LineElement.UNTAGGED_WAY;
+                    for (StyleElement element : p.a) {
+                        if (element instanceof AreaElement) {
+                            line = LineElement.createSimpleLineStyle(((AreaElement) element).color, true);
+                            break;
+                        }
+                    }
+                    p.a = new StyleElementList(p.a, line);
+                }
             }
+            StyleCache style = osm.getCachedStyle() != null ? osm.getCachedStyle() : StyleCache.EMPTY_STYLECACHE;
+            try {
+                osm.setCachedStyle(style.put(p.a, p.b, osm.isSelected()));
+            } catch (RangeViolatedError e) {
+                throw new AssertionError("Range violated: " + e.getMessage()
+                  + " (object: " + osm.getPrimitiveId() + ", current style: " + osm.getCachedStyle()
+                  + ", scale: " + scale + ", new stylelist: " + p.a + ", new range: " + p.b + ')', e);
+            }
+            osm.declareCachedStyleUpToDate();
+            return p;
         }
-        StyleCache style = osm.getCachedStyle() != null ? osm.getCachedStyle() : StyleCache.EMPTY_STYLECACHE;
-        try {
-            osm.setCachedStyle(style.put(p.a, p.b, osm.isSelected()));
-        } catch (RangeViolatedError e) {
-            throw new AssertionError("Range violated: " + e.getMessage()
-                    + " (object: " + osm.getPrimitiveId() + ", current style: "+osm.getCachedStyle()
-                    + ", scale: " + scale + ", new stylelist: " + p.a + ", new range: " + p.b + ')', e);
-        }
-        osm.declareCachedStyleUpToDate();
-        return p;
     }
 
     /**
@@ -376,7 +389,6 @@ public class ElemStyles implements PreferenceChangedListener {
      * @since 13810 (signature)
      */
     public Pair<StyleElementList, Range> generateStyles(IPrimitive osm, double scale, boolean pretendWayIsClosed) {
-
         List<StyleElement> sl = new ArrayList<>();
         MultiCascade mc = new MultiCascade();
         Environment env = new Environment(osm, mc, null, null);
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/ExpressionTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/ExpressionTest.java
new file mode 100644
index 000000000..0130da35e
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/ExpressionTest.java
@@ -0,0 +1,53 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+import javax.json.Json;
+import javax.json.JsonValue;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link Expression}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class ExpressionTest {
+    @Test
+    void testInvalidJson() {
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(JsonValue.NULL));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(JsonValue.FALSE));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(JsonValue.TRUE));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(JsonValue.EMPTY_JSON_OBJECT));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(JsonValue.EMPTY_JSON_ARRAY));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(Json.createObjectBuilder().add("bad", "value").build()));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(Json.createValue(1)));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(Json.createValue(1.0)));
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(Json.createValue("bad string")));
+    }
+
+    @Test
+    void testBasicExpressions() {
+        // "filter": [ "==|>=|<=|<|>", "key", "value" ]
+        assertEquals("[key=value]", new Expression(Json.createArrayBuilder().add("==").add("key").add("value").build()).toString());
+        assertEquals("[key>=true]", new Expression(Json.createArrayBuilder().add(">=").add("key").add(true).build()).toString());
+        assertEquals("[key<=false]", new Expression(Json.createArrayBuilder().add("<=").add("key").add(false).build()).toString());
+        assertEquals("[key<1]", new Expression(Json.createArrayBuilder().add("<").add("key").add(1).build()).toString());
+        assertEquals("[key>2.5]", new Expression(Json.createArrayBuilder().add(">").add("key").add(2.5).build()).toString());
+        // Test bad expression
+        assertEquals(Expression.EMPTY_EXPRESSION, new Expression(Json.createArrayBuilder().add(">>").add("key").add("value").build()));
+
+        // Test expressions with a subarray and object. This is expected to fail when properly supported, so it should be fixed.
+        assertEquals("[key=[{bad:value}]]", new Expression(Json.createArrayBuilder().add("==").add("key").add(
+          Json.createArrayBuilder().add(Json.createObjectBuilder().add("bad", "value"))).build()).toString());
+        assertEquals("[key=]", new Expression(Json.createArrayBuilder().add("==").add("key").add(JsonValue.NULL).build()).toString());
+    }
+
+    @Test
+    void testEquals() {
+        EqualsVerifier.forClass(Expression.class).verify();
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java
new file mode 100644
index 000000000..28b09b950
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java
@@ -0,0 +1,601 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.text.MessageFormat;
+import java.util.Locale;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+import javax.json.JsonValue;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link Layers}.
+ * @implNote Tests will fail when support is added for new styling information.
+ * All current (2021-03-31) properties are checked for in some form or another.
+ * @author Taylor Smock
+ * @since xxx
+ */
+class LayersTest {
+    @Test
+    void testBackground() {
+        // Test an empty background layer
+        Layers emptyBackgroundLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.BACKGROUND.name())
+          .add("id", "Empty Background").build());
+        assertEquals("Empty Background", emptyBackgroundLayer.getId());
+        assertEquals(Layers.Type.BACKGROUND, emptyBackgroundLayer.getType());
+        assertNull(emptyBackgroundLayer.getSource());
+        assertSame(Expression.EMPTY_EXPRESSION, emptyBackgroundLayer.getFilter());
+        assertEquals("", emptyBackgroundLayer.toString());
+
+        // Test a background layer with some styling information
+        JsonObject allProperties = Json.createObjectBuilder()
+          .add("background-color", "#fff000") // fill-color:#fff000;
+          .add("background-opacity", 0.5) // No good mapping for JOSM yet
+          .add("background-pattern", "null") // This should be an image, not implemented
+          .build();
+        Layers backgroundLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Background layer")
+          .add("type", Layers.Type.BACKGROUND.name().toLowerCase(Locale.ROOT))
+          .add("paint", allProperties)
+        .build());
+        assertEquals("canvas{fill-color:#fff000;}", backgroundLayer.toString());
+
+        // Test a background layer with some styling information, but invisible
+        Layers invisibleBackgroundLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Background layer")
+          .add("type", Layers.Type.BACKGROUND.name().toLowerCase(Locale.ROOT))
+          .add("layout", Json.createObjectBuilder().add("visibility", "none").build())
+          .add("paint", allProperties).build());
+        assertEquals("", invisibleBackgroundLayer.toString());
+    }
+
+    @Test
+    void testFill() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.FILL.name())
+          .add("id", "Empty Fill").build()));
+
+        // Test an empty fill layer
+        Layers emptyFillLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.FILL.name())
+          .add("id", "Empty Fill")
+          .add("source", "Random source").build());
+        assertEquals("Empty Fill", emptyFillLayer.getId());
+        assertEquals("Random source", emptyFillLayer.getSource());
+        assertEquals("", emptyFillLayer.toString());
+
+        // Test a fully implemented fill layer
+        JsonObject allLayoutProperties = Json.createObjectBuilder()
+          .add("fill-sort-key", 5)
+          .add("visibility", "visible")
+          .build();
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("fill-antialias", false)
+          .add("fill-color", "#fff000") // fill-color:#fff000
+          .add("fill-opacity", 0.5) // fill-opacity:0.5
+          .add("fill-outline-color", "#ffff00") // fill-color:#ffff00 (defaults to fill-color)
+          .add("fill-pattern", JsonValue.NULL) // disables fill-outline-color and fill-color
+          .add("fill-translate", Json.createArrayBuilder().add(5).add(5))
+          .add("fill-translate-anchor", "viewport") // requires fill-translate
+          .build();
+
+        Layers fullFillLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.FILL.toString())
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullFillLayer.getId());
+        assertEquals(Layers.Type.FILL, fullFillLayer.getType());
+        assertEquals("area::random-layer-id{fill-color:#fff000;fill-opacity:0.5;color:#ffff00;}", fullFillLayer.toString());
+
+        // Test a fully implemented fill layer (invisible)
+        Layers fullFillInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.FILL.toString())
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties)
+            .add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullFillInvisibleLayer.getId());
+        assertEquals(Layers.Type.FILL, fullFillInvisibleLayer.getType());
+        assertEquals("", fullFillInvisibleLayer.toString());
+    }
+
+    @Test
+    void testLine() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.LINE.name())
+          .add("id", "Empty Line").build()));
+
+        JsonObject allLayoutProperties = Json.createObjectBuilder()
+          .add("line-cap", "round") // linecap:round;
+          .add("line-join", "bevel")
+          .add("line-miter-limit", 65)
+          .add("line-round-limit", 1.5)
+          .add("line-sort-key", 3)
+          .add("visibility", "visible")
+          .build();
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("line-blur", 5)
+          .add("line-color", "#fff000") // color:#fff000;
+          .add("line-dasharray", Json.createArrayBuilder().add(1).add(5).add(1)) // dashes:1,5,1;
+          .add("line-gap-width", 6)
+          .add("line-gradient", "#ffff00") // disabled by line-dasharray/line-pattern, source must be "geojson"
+          .add("line-offset", 12)
+          .add("line-opacity", 0.5) // opacity:0.5;
+          .add("line-pattern", JsonValue.NULL)
+          .add("line-translate", Json.createArrayBuilder().add(-1).add(-2))
+          .add("line-translate-anchor", "viewport")
+          .add("line-width", 22) // width:22;
+          .build();
+
+        // Test fully defined line
+        Layers fullLineLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.LINE.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullLineLayer.getId());
+        assertEquals(Layers.Type.LINE, fullLineLayer.getType());
+        assertEquals("way::random-layer-id{color:#fff000;opacity:0.5;linecap:round;dashes:1,5,1;width:22;}", fullLineLayer.toString());
+
+        // Test invisible line
+        Layers fullLineInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.LINE.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties)
+            .add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullLineInvisibleLayer.getId());
+        assertEquals(Layers.Type.LINE, fullLineInvisibleLayer.getType());
+        assertEquals("", fullLineInvisibleLayer.toString());
+    }
+
+    @Test
+    void testSymbol() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name())
+          .add("id", "Empty Symbol").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("icon-color", "#fff000") // also requires sdf icons
+          .add("icon-halo-blur", 5)
+          .add("icon-halo-color", "#ffff00")
+          .add("icon-halo-width", 6)
+          .add("icon-opacity", 0.5) // icon-opacity:0.5;
+          .add("icon-translate", Json.createArrayBuilder().add(11).add(12))
+          .add("icon-translate-anchor", "viewport") // also requires icon-translate
+          .add("text-color", "#fffff0") // text-color:#fffff0;
+          .add("text-halo-blur", 15)
+          .add("text-halo-color", "#ffffff") // text-halo-color:#ffffff;
+          .add("text-halo-width", 16) // text-halo-radius:16;
+          .add("text-opacity", 0.6) // text-opacity:0.6;
+          .add("text-translate", Json.createArrayBuilder().add(26).add(27))
+          .add("text-translate-anchor", "viewport")
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder()
+          .add("icon-allow-overlap", true)
+          .add("icon-anchor", "left")
+          .add("icon-ignore-placement", true)
+          .add("icon-image", "random-image") // icon-image:concat(\"random-image\");
+          .add("icon-keep-upright", true) // also requires icon-rotation-alignment=map and symbol-placement=line|line-center
+          .add("icon-offset", Json.createArrayBuilder().add(2).add(3)) // icon-offset-x:2.0;icon-offset-y:3.0;
+          .add("icon-optional", true) // also requires text-field
+          .add("icon-padding", 4)
+          .add("icon-pitch-alignment", "viewport")
+          .add("icon-rotate", 30) // icon-rotation:30.0;
+          .add("icon-rotation-alignment", "map")
+          .add("icon-size", 2)
+          .add("icon-text-fit", "width") // also requires text-field
+          .add("icon-text-fit-padding", Json.createArrayBuilder().add(7).add(8).add(9).add(10))
+          .add("symbol-avoid-edges", true)
+          .add("symbol-placement", "line")
+          .add("symbol-sort-key", 13)
+          .add("symbol-spacing", 14) // requires symbol-placement=line
+          .add("symbol-z-order", "source")
+          .add("text-allow-overlap", true) // requires text-field
+          .add("text-anchor", "left") // requires text-field, disabled by text-variable-anchor
+          .add("text-field", "something") // text:something;
+          .add("text-font", Json.createArrayBuilder().add("SansSerif")) // DroidSans isn't always available in an IDE
+          .add("text-ignore-placement", true)
+          .add("text-justify", "left")
+          .add("text-keep-upright", false)
+          .add("text-letter-spacing", 17)
+          .add("text-line-height", 1.3)
+          .add("text-max-angle", 18)
+          .add("text-max-width", 19)
+          .add("text-offset", Json.createArrayBuilder().add(20).add(21))
+          .add("text-optional", true)
+          .add("text-padding", 22)
+          .add("text-pitch-alignment", "viewport")
+          .add("text-radial-offset", 23)
+          .add("text-rotate", 24)
+          .add("text-rotation-alignment", "viewport")
+          .add("text-size", 25) // font-size:25;
+          .add("text-transform", "uppercase")
+          .add("text-variable-anchor", "left")
+          .add("text-writing-mode", "vertical")
+          .add("visibility", "visible").build();
+
+        // Test fully defined symbol
+        Layers fullLineLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullLineLayer.getId());
+        assertEquals(Layers.Type.SYMBOL, fullLineLayer.getType());
+        assertEquals("node::random-layer-id{icon-image:concat(\"random-image\");icon-offset-x:2.0;icon-offset-y:3.0;"
+          + "icon-opacity:0.5;icon-rotation:30.0;text-color:#fffff0;text:something;font-family:\"SansSerif\";font-weight:normal;"
+          + "font-style:normal;text-halo-color:#ffffff;text-halo-radius:16;text-opacity:0.6;font-size:25;}", fullLineLayer.toString());
+
+        // Test an invisible symbol
+        Layers fullLineInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("random-layer-id", fullLineInvisibleLayer.getId());
+        assertEquals(Layers.Type.SYMBOL, fullLineInvisibleLayer.getType());
+        assertEquals("", fullLineInvisibleLayer.toString());
+
+        // Test with placeholders in icon-image
+        Layers fullOneIconImagePlaceholderLineLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("icon-image", "{value}"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("node::random-layer-id{icon-image:concat(tag(\"value\"));icon-offset-x:2.0;icon-offset-y:3.0;"
+          + "icon-opacity:0.5;icon-rotation:30.0;text-color:#fffff0;text:something;font-family:\"SansSerif\";font-weight:normal;"
+          + "font-style:normal;text-halo-color:#ffffff;text-halo-radius:16;text-opacity:0.6;font-size:25;}",
+          fullOneIconImagePlaceholderLineLayer.toString());
+
+        // Test with placeholders in icon-image
+        Layers fullOneIconImagePlaceholderExtraLineLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("icon-image", "something/{value}/random"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("node::random-layer-id{icon-image:concat(\"something/\",tag(\"value\"),\"/random\");icon-offset-x:2.0;"
+          + "icon-offset-y:3.0;icon-opacity:0.5;icon-rotation:30.0;text-color:#fffff0;text:something;font-family:\"SansSerif\";"
+          + "font-weight:normal;font-style:normal;text-halo-color:#ffffff;text-halo-radius:16;text-opacity:0.6;font-size:25;}",
+          fullOneIconImagePlaceholderExtraLineLayer.toString());
+
+        // Test with placeholders in icon-image
+        Layers fullTwoIconImagePlaceholderExtraLineLayer = new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SYMBOL.name().toLowerCase(Locale.ROOT))
+          .add("id", "random-layer-id")
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("icon-image", "something/{value}/random/{value2}"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("node::random-layer-id{icon-image:concat(\"something/\",tag(\"value\"),\"/random/\",tag(\"value2\"));"
+          + "icon-offset-x:2.0;icon-offset-y:3.0;icon-opacity:0.5;icon-rotation:30.0;text-color:#fffff0;text:something;"
+          + "font-family:\"SansSerif\";font-weight:normal;font-style:normal;text-halo-color:#ffffff;text-halo-radius:16;"
+          + "text-opacity:0.6;font-size:25;}", fullTwoIconImagePlaceholderExtraLineLayer.toString());
+    }
+
+    @Test
+    void testRaster() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.RASTER.name())
+          .add("id", "Empty Raster").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("raster-brightness-max", 0.5)
+          .add("raster-brightness-min", 0.6)
+          .add("raster-contrast", 0.7)
+          .add("raster-fade-duration", 1)
+          .add("raster-hue-rotate", 2)
+          .add("raster-opacity", 0.7)
+          .add("raster-resampling", "nearest")
+          .add("raster-saturation", 0.8)
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder().add("visibility", "visible").build();
+
+        // Test fully defined raster
+        Layers fullRaster = new Layers(Json.createObjectBuilder()
+          .add("id", "test-raster")
+          .add("type", Layers.Type.RASTER.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals(Layers.Type.RASTER, fullRaster.getType());
+        assertEquals("test-raster", fullRaster.getId());
+        assertEquals("Random source", fullRaster.getSource());
+        assertEquals("", fullRaster.toString());
+
+        // Test fully defined invisible raster
+        Layers fullInvisibleRaster = new Layers(Json.createObjectBuilder()
+          .add("id", "test-raster")
+          .add("type", Layers.Type.RASTER.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("", fullInvisibleRaster.toString());
+    }
+
+    @Test
+    void testCircle() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.CIRCLE.name())
+          .add("id", "Empty Circle").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("circle-blur", 1)
+          .add("circle-color", "#fff000") // symbol-fill-color:#fff000;
+          .add("circle-opacity", 0.5) // symbol-fill-opacity:0.5;
+          .add("circle-pitch-alignment", "map")
+          .add("circle-pitch-scale", "viewport")
+          .add("circle-radius", 2) // symbol-size:4.0; (we use width)
+          .add("circle-stroke-color", "#ffff00") // symbol-stroke-color:#ffff00;
+          .add("circle-stroke-opacity", 0.6) // symbol-stroke-opacity:0.6;
+          .add("circle-stroke-width", 5) // symbol-stroke-width:5.0;
+          .add("circle-translate", Json.createArrayBuilder().add(3).add(4))
+          .add("circle-translate-anchor", "viewport")
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder()
+          .add("circle-sort-key", 3)
+          .add("visibility", "visible")
+          .build();
+
+        Layers fullCircleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Full circle layer")
+          .add("type", Layers.Type.CIRCLE.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals(Layers.Type.CIRCLE, fullCircleLayer.getType());
+        assertEquals("Full circle layer", fullCircleLayer.getId());
+        assertEquals("Random source", fullCircleLayer.getSource());
+        assertEquals("node::Full circle layer{symbol-shape:circle;symbol-fill-color:#fff000;symbol-fill-opacity:0.5;"
+          + "symbol-size:4.0;symbol-stroke-color:#ffff00;symbol-stroke-opacity:0.6;symbol-stroke-width:5;}", fullCircleLayer.toString());
+
+        Layers fullCircleInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Full circle layer")
+          .add("type", Layers.Type.CIRCLE.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals(Layers.Type.CIRCLE, fullCircleInvisibleLayer.getType());
+        assertEquals("Full circle layer", fullCircleInvisibleLayer.getId());
+        assertEquals("Random source", fullCircleInvisibleLayer.getSource());
+        assertEquals("", fullCircleInvisibleLayer.toString());
+    }
+
+    @Test
+    void testFillExtrusion() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.FILL_EXTRUSION.name())
+          .add("id", "Empty Fill Extrusion").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("fill-extrusion-base", 1)
+          .add("fill-extrusion-color", "#fff000")
+          .add("fill-extrusion-height", 2)
+          .add("fill-extrusion-opacity", 0.5)
+          .add("fill-extrusion-pattern", "something-random")
+          .add("fill-extrusion-translate", Json.createArrayBuilder().add(3).add(4))
+          .add("fill-extrusion-translate-anchor", "viewport")
+          .add("fill-extrusion-vertical-gradient", false)
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder().add("visibility", "visible").build();
+
+        Layers fullFillLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Fill Extrusion")
+          .add("type", Layers.Type.FILL_EXTRUSION.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", allLayoutProperties)
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("", fullFillLayer.toString());
+        assertEquals(Layers.Type.FILL_EXTRUSION, fullFillLayer.getType());
+        Layers fullFillInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Fill Extrusion")
+          .add("type", Layers.Type.FILL_EXTRUSION.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .add("paint", allPaintProperties)
+          .build());
+        assertEquals("", fullFillInvisibleLayer.toString());
+        assertEquals(Layers.Type.FILL_EXTRUSION, fullFillInvisibleLayer.getType());
+    }
+
+    @Test
+    void testHeatmap() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.HEATMAP.name())
+          .add("id", "Empty Heatmap").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("heatmap-color", "#fff000") // This will probably be a gradient of some type
+          .add("heatmap-intensity", 0.5)
+          .add("heatmap-opacity", 0.6)
+          .add("heatmap-radius", 1) // This is in pixels
+          .add("heatmap-weight", 0.7)
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder().add("visibility", "visible").build();
+
+        Layers fullHeatmapLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Full heatmap")
+          .add("type", Layers.Type.HEATMAP.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", allLayoutProperties)
+          .build());
+        assertEquals(Layers.Type.HEATMAP, fullHeatmapLayer.getType());
+        assertEquals("", fullHeatmapLayer.toString());
+
+        Layers fullHeatmapInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Full heatmap")
+          .add("type", Layers.Type.HEATMAP.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .build());
+        assertEquals(Layers.Type.HEATMAP, fullHeatmapInvisibleLayer.getType());
+        assertEquals("", fullHeatmapInvisibleLayer.toString());
+    }
+
+    @Test
+    void testHillshade() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.HILLSHADE.name())
+          .add("id", "Empty Hillshade").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("hillshade-accent-color", "#fff000")
+          .add("hillshade-exaggeration", 0.6)
+          .add("hillshade-highlight-color", "#ffff00")
+          .add("hillshade-illumination-anchor", "map")
+          .add("hillshade-illumination-direction", 90)
+          .add("hillshade-shadow-color", "#fffff0")
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder()
+          .add("visibility", "visible")
+          .build();
+
+        Layers fullHillshadeLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Hillshade")
+          .add("type", Layers.Type.HILLSHADE.toString().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", allLayoutProperties)
+          .build());
+        assertEquals(Layers.Type.HILLSHADE, fullHillshadeLayer.getType());
+        assertEquals("", fullHillshadeLayer.toString());
+
+        Layers fullHillshadeInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Hillshade")
+          .add("type", Layers.Type.HILLSHADE.toString().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .build());
+        assertEquals(Layers.Type.HILLSHADE, fullHillshadeInvisibleLayer.getType());
+        assertEquals("", fullHillshadeInvisibleLayer.toString());
+    }
+
+    @Test
+    void testSky() {
+        // Test a layer without a source (should fail)
+        assertThrows(NullPointerException.class, () -> new Layers(Json.createObjectBuilder()
+          .add("type", Layers.Type.SKY.name())
+          .add("id", "Empty Sky").build()));
+
+        JsonObject allPaintProperties = Json.createObjectBuilder()
+          .add("sky-atmosphere-color", "red")
+          .add("sky-atmosphere-halo-color", "yellow")
+          // 360180 is apparently included in this? Or it might be a formatting issue in the docs.
+          .add("sky-atmosphere-sun", Json.createArrayBuilder().add(0, 360180))
+          .add("sky-atmosphere-sun-intensity", 99)
+          .add("sky-gradient", "#fff000")
+          .add("sky-gradient-center", Json.createArrayBuilder().add(0).add(360180)) // see note on 360180 above
+          .add("sky-gradient-radius", 1)
+          .add("sky-opacity", 0.5)
+          .add("sky-type", "gradient")
+          .build();
+        JsonObject allLayoutProperties = Json.createObjectBuilder().add("visibility", "visible").build();
+
+        Layers fullSkyLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Sky")
+          .add("type", Layers.Type.SKY.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", allLayoutProperties)
+          .build());
+        assertEquals(Layers.Type.SKY, fullSkyLayer.getType());
+        assertEquals("", fullSkyLayer.toString());
+
+        Layers fullSkyInvisibleLayer = new Layers(Json.createObjectBuilder()
+          .add("id", "Sky")
+          .add("type", Layers.Type.SKY.name().toLowerCase(Locale.ROOT))
+          .add("source", "Random source")
+          .add("paint", allPaintProperties)
+          .add("layout", Json.createObjectBuilder(allLayoutProperties).add("visibility", "none"))
+          .build());
+        assertEquals(Layers.Type.SKY, fullSkyInvisibleLayer.getType());
+        assertEquals("", fullSkyInvisibleLayer.toString());
+    }
+
+    @Test
+    void testZoomLevels() {
+        JsonObject baseInformation = Json.createObjectBuilder()
+          .add("id", "dots")
+          .add("type", "CiRcLe")
+          .add("source", "osm-source")
+          .add("source-layer", "osm-images")
+          .add("paint", Json.createObjectBuilder()
+            .add("circle-color", "#fff000")
+            .add("circle-radius", 6)
+          ).build();
+        Layers noZoomLayer = new Layers(baseInformation);
+        String baseString = "node{0}::dots'{symbol-shape:circle;symbol-fill-color:#fff000;symbol-fill-opacity:1;"
+          + "symbol-size:12.0;symbol-stroke-color:#000000;symbol-stroke-opacity:1;symbol-stroke-width:0;}'";
+        assertEquals("osm-images", noZoomLayer.getSourceLayer());
+        assertEquals(MessageFormat.format(baseString, ""), noZoomLayer.toString());
+
+        Layers minZoomLayer = new Layers(Json.createObjectBuilder(baseInformation)
+          .add("minzoom", 0)
+          .build());
+        assertEquals(MessageFormat.format(baseString, "|z0-"), minZoomLayer.toString());
+
+        Layers maxZoomLayer = new Layers(Json.createObjectBuilder(baseInformation)
+          .add("maxzoom", 24)
+          .build());
+        assertEquals(MessageFormat.format(baseString, "|z-24"), maxZoomLayer.toString());
+
+        Layers minMaxZoomLayer = new Layers(Json.createObjectBuilder(baseInformation)
+          .add("minzoom", 1)
+          .add("maxzoom", 2)
+          .build());
+        assertEquals(MessageFormat.format(baseString, "|z1-2"), minMaxZoomLayer.toString());
+
+        Layers sameMinMaxZoomLayer = new Layers(Json.createObjectBuilder(baseInformation)
+          .add("minzoom", 2)
+          .add("maxzoom", 2)
+          .build());
+        assertEquals(MessageFormat.format(baseString, "|z2"), sameMinMaxZoomLayer.toString());
+    }
+
+    @Test
+    void testEquals() {
+        EqualsVerifier.forClass(Layers.class).usingGetClass().verify();
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyleTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyleTest.java
new file mode 100644
index 000000000..1fcb7bfe8
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyleTest.java
@@ -0,0 +1,300 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Paths;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
+
+import javax.imageio.ImageIO;
+import javax.json.Json;
+import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
+import javax.json.JsonReader;
+import javax.json.JsonStructure;
+import javax.json.JsonValue;
+
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.mappaint.ElemStyles;
+import org.openstreetmap.josm.gui.mappaint.Keyword;
+import org.openstreetmap.josm.gui.mappaint.StyleSource;
+import org.openstreetmap.josm.gui.mappaint.mapcss.Instruction;
+import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSRule;
+import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+import org.openstreetmap.josm.tools.ColorHelper;
+import org.openstreetmap.josm.tools.ImageProvider;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.awaitility.Awaitility;
+import org.awaitility.Durations;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Test class for {@link MapBoxVectorStyle}
+ * @author Taylor Smock
+ */
+public class MapBoxVectorStyleTest {
+    /** Used to store sprite files (specifically, sprite{,@2x}.{png,json}) */
+    @TempDir
+    File spritesDirectory;
+
+    // Needed for osm primitives (we really just need to initialize the config)
+    // OSM primitives are called when we load style sources
+    @RegisterExtension
+    JOSMTestRules rules = new JOSMTestRules();
+
+    /** The base information */
+    private static final String BASE_STYLE = "'{'\"version\":8,\"name\":\"test style\",\"owner\":\"josm test\",\"id\":\"{0}\",{1}'}'";
+    /** Source 1 */
+    private static final String SOURCE1 = "\"source1\":{\"type\":\"vector\",\"tiles\":[\"https://example.org/{z}/{x}/{y}.mvt\"]}";
+    /** Layer 1 */
+    private static final String LAYER1 = "{\"id\":\"layer1\",\"type\":\"circle\",\"source\":\"source1\",\"source-layer\":\"nodes\"}";
+    /** Source 2 */
+    private static final String SOURCE2 = "\"source2\":{\"type\":\"vector\",\"tiles\":[\"https://example.org/{z}2/{x}/{y}.mvt\"]}";
+    /** Layer 2 */
+    private static final String LAYER2 = "{\"id\":\"layer2\",\"type\":\"circle\",\"source\":\"source2\",\"source-layer\":\"nodes\"}";
+
+    /**
+     * Check that the version matches the supported style version(s). Currently, only version 8 exists and is (partially)
+     * supported.
+     */
+    @Test
+    void testVersionChecks() {
+        assertThrows(NullPointerException.class, () -> new MapBoxVectorStyle(JsonValue.EMPTY_JSON_OBJECT));
+        IllegalArgumentException badVersion = assertThrows(IllegalArgumentException.class,
+          () -> new MapBoxVectorStyle(Json.createObjectBuilder().add("version", 7).build()));
+        assertEquals("Vector Tile Style Version not understood: version 7 (json: {\"version\":7})", badVersion.getMessage());
+        badVersion = assertThrows(IllegalArgumentException.class,
+          () -> new MapBoxVectorStyle(Json.createObjectBuilder().add("version", 9).build()));
+        assertEquals("Vector Tile Style Version not understood: version 9 (json: {\"version\":9})", badVersion.getMessage());
+        assertDoesNotThrow(() -> new MapBoxVectorStyle(Json.createObjectBuilder().add("version", 8).build()));
+    }
+
+    @Test
+    void testSources() {
+        // Check with an invalid sources list
+        assertTrue(new MapBoxVectorStyle(getJson(JsonObject.class, "{\"version\":8,\"sources\":[\"s1\",\"s2\"]}")).getSources().isEmpty());
+        Map<Source, ElemStyles> sources = new MapBoxVectorStyle(getJson(JsonObject.class, MessageFormat.format(BASE_STYLE, "test",
+          MessageFormat.format("\"sources\":'{'{0},{1},\"source3\":[\"bad source\"]'}',\"layers\":[{2},{3},{4}]",
+            SOURCE1, SOURCE2, LAYER1, LAYER2, LAYER2.replace('2', '3'))))).getSources();
+        assertEquals(3, sources.size());
+        assertTrue(sources.containsKey(null)); // This is due to there being no source3 layer
+        sources.remove(null); // Avoid null checks later
+        assertTrue(sources.keySet().stream().map(Source::getName).anyMatch("source1"::equals));
+        assertTrue(sources.keySet().stream().map(Source::getName).anyMatch("source2"::equals));
+        assertTrue(sources.keySet().stream().map(Source::getName).noneMatch("source3"::equals));
+    }
+
+    @Test
+    void testSavedFiles() {
+        assertTrue(new MapBoxVectorStyle(getJson(JsonObject.class, "{\"version\":8,\"sources\":[\"s1\",\"s2\"]}")).getSources().isEmpty());
+        Map<Source, ElemStyles> sources = new MapBoxVectorStyle(getJson(JsonObject.class, MessageFormat.format(BASE_STYLE, "test",
+          MessageFormat.format("\"sources\":'{'{0},{1}'}',\"layers\":[{2},{3}]", SOURCE1, SOURCE2, LAYER1, LAYER2)))).getSources();
+        assertEquals(2, sources.size());
+        // For various reasons, the map _must_ be reliably ordered in the order of encounter
+        Source source1 = sources.keySet().iterator().next();
+        Source source2 = sources.keySet().stream().skip(1).findFirst().orElseGet(() -> fail("No second source"));
+        assertEquals("source1", source1.getName());
+        assertEquals("source2", source2.getName());
+
+        // Check that the files have been saved. Ideally, we would check that they haven't been
+        // saved earlier, since this is in a different thread. Unfortunately, that is a _race condition_.
+        MapCSSStyleSource styleSource1 = (MapCSSStyleSource) sources.get(source1).getStyleSources().get(0);
+        MapCSSStyleSource styleSource2 = (MapCSSStyleSource) sources.get(source2).getStyleSources().get(0);
+
+        AtomicBoolean saveFinished = new AtomicBoolean();
+        MainApplication.worker.execute(() -> saveFinished.set(true));
+        Awaitility.await().atMost(Durations.ONE_SECOND).until(saveFinished::get);
+
+        assertTrue(styleSource1.url.endsWith("source1.mapcss"));
+        assertTrue(styleSource2.url.endsWith("source2.mapcss"));
+
+        MapCSSStyleSource mapCSSStyleSource1 = new MapCSSStyleSource(styleSource1.url, styleSource1.name, styleSource1.title);
+        MapCSSStyleSource mapCSSStyleSource2 = new MapCSSStyleSource(styleSource2.url, styleSource2.name, styleSource2.title);
+
+        assertEquals(styleSource1, mapCSSStyleSource1);
+        assertEquals(styleSource2, mapCSSStyleSource2);
+    }
+
+    @Test
+    void testSprites() throws IOException {
+        generateSprites(false);
+        // Ensure that we fall back to 1x sprites
+        assertTrue(new File(this.spritesDirectory, "sprite.png").exists());
+        assertFalse(new File(this.spritesDirectory, "sprite@2x.png").exists());
+        assertTrue(new File(this.spritesDirectory, "sprite.json").exists());
+        assertFalse(new File(this.spritesDirectory, "sprite@2x.json").exists());
+
+        checkImages(false);
+
+        generateSprites(true);
+        checkImages(true);
+    }
+
+    private void checkImages(boolean hiDpi) {
+        // Ensure that we don't have images saved in the ImageProvider cache
+        ImageProvider.clearCache();
+        int hiDpiScalar = hiDpi ? 2 : 1;
+        String spritePath = new File(this.spritesDirectory, "sprite").getPath();
+        MapBoxVectorStyle style = new MapBoxVectorStyle(getJson(JsonObject.class,
+          MessageFormat.format(BASE_STYLE, "sprite_test", "\"sprite\":\"file:/" + spritePath + "\"")));
+        assertEquals("file:/" + spritePath, style.getSpriteUrl());
+
+        AtomicBoolean saveFinished = new AtomicBoolean();
+        MainApplication.worker.execute(() -> saveFinished.set(true));
+        Awaitility.await().atMost(Durations.ONE_SECOND).until(saveFinished::get);
+
+        int scalar = 28; // 255 / 9 (could be 4, but this was a nicer number)
+        for (int x = 0; x < 3; x++) {
+            for (int y = 0; y < 3; y++) {
+                // Expected color
+                Color color = new Color(scalar * x, scalar * y, scalar * x * y);
+                int finalX = x;
+                int finalY = y;
+                BufferedImage image = (BufferedImage) assertDoesNotThrow(
+                  () -> ImageProvider.get(new File("test style", MessageFormat.format("({0},{1})", finalX, finalY)).getPath()))
+                  .getImage();
+                assertEquals(3 * hiDpiScalar, image.getWidth(null));
+                assertEquals(3 * hiDpiScalar, image.getHeight(null));
+                for (int x2 = 0; x2 < image.getWidth(null); x2++) {
+                    for (int y2 = 0; y2 < image.getHeight(null); y2++) {
+                        assertEquals(color.getRGB(), image.getRGB(x2, y2));
+                    }
+                }
+            }
+        }
+    }
+
+    private void generateSprites(boolean hiDpi) throws IOException {
+        // Create a 3x3 grid of 3x3 or 6x6 pixel squares (depends upon the dpi setting)
+        int hiDpiScale = hiDpi ? 2 : 1;
+        BufferedImage nineByNine = new BufferedImage(hiDpiScale * 9, hiDpiScale * 9, BufferedImage.TYPE_4BYTE_ABGR);
+        int scalar = 28; // 255 / 9 (could be 4, but this was a nicer number)
+        Graphics2D g = nineByNine.createGraphics();
+        JsonObjectBuilder json = Json.createObjectBuilder();
+        for (int x = 0; x < 3; x++) {
+            for (int y = 0; y < 3; y++) {
+                Color color = new Color(scalar * x, scalar * y, scalar * x * y);
+                g.setColor(color);
+                g.drawRect(3 * hiDpiScale * x, 3 * hiDpiScale * y, 3 * hiDpiScale, 3 * hiDpiScale);
+                g.fillRect(3 * hiDpiScale * x, 3 * hiDpiScale * y, 3 * hiDpiScale, 3 * hiDpiScale);
+
+                JsonObjectBuilder sprite = Json.createObjectBuilder();
+                sprite.add("height", hiDpiScale * 3);
+                sprite.add("pixelRatio", hiDpiScale);
+                sprite.add("width", hiDpiScale * 3);
+                sprite.add("x", 3 * hiDpiScale * x);
+                sprite.add("y", 3 * hiDpiScale * y);
+
+                json.add(MessageFormat.format("({0},{1})", x, y), sprite);
+            }
+        }
+        String imageName = hiDpi ? "sprite@2x.png" : "sprite.png";
+        ImageIO.write(nineByNine, "png", new File(this.spritesDirectory, imageName));
+        String jsonName = hiDpi ? "sprite@2x.json" : "sprite.json";
+        File jsonFile = new File(this.spritesDirectory, jsonName);
+        try (FileOutputStream fileOutputStream = new FileOutputStream(jsonFile)) {
+            fileOutputStream.write(json.build().toString().getBytes(StandardCharsets.UTF_8));
+        }
+    }
+
+    private static <T extends JsonStructure> T getJson(Class<T> clazz, String json) {
+        try (JsonReader reader = Json.createReader(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)))) {
+            JsonStructure structure = reader.read();
+            if (clazz.isAssignableFrom(structure.getClass())) {
+                return clazz.cast(structure);
+            }
+        }
+        fail("Could not cast to expected class");
+        throw new IllegalArgumentException();
+    }
+
+    @Test
+    void testMapillaryStyle() {
+        final String file = Paths.get("file:", TestUtils.getTestDataRoot(), "mapillary.json").toString();
+        final MapBoxVectorStyle style = MapBoxVectorStyle.getMapBoxVectorStyle(file);
+        assertNotNull(style);
+        // There are three "sources" in the mapillary.json file
+        assertEquals(3, style.getSources().size());
+        final ElemStyles mapillarySource = style.getSources().entrySet().stream()
+          .filter(source -> "mapillary-source".equals(source.getKey().getName())).map(
+            Map.Entry::getValue).findAny().orElse(null);
+        assertNotNull(mapillarySource);
+        mapillarySource.getStyleSources().forEach(StyleSource::loadStyleSource);
+        assertEquals(1, mapillarySource.getStyleSources().size());
+        final MapCSSStyleSource mapillaryCssSource = (MapCSSStyleSource) mapillarySource.getStyleSources().get(0);
+        assertTrue(mapillaryCssSource.getErrors().isEmpty());
+        final MapCSSRule mapillaryOverview = getRule(mapillaryCssSource, "node", "mapillary-overview");
+        assertNotNull(mapillaryOverview);
+        assertInInstructions(mapillaryOverview.declaration.instructions, "symbol-shape", new Keyword("circle"));
+        assertInInstructions(mapillaryOverview.declaration.instructions, "symbol-fill-color", ColorHelper.html2color("#05CB63"));
+        assertInInstructions(mapillaryOverview.declaration.instructions, "symbol-fill-opacity", 0.6f);
+        // Docs indicate that symbol-size is total width, while we are translating from a radius. So 2 * 4 = 8.
+        assertInInstructions(mapillaryOverview.declaration.instructions, "symbol-size", 8.0f);
+    }
+
+    @Test
+    void testEqualsContract() {
+        // We need to "load" the style sources to avoid the verifier from thinking they are equal
+        StyleSource canvas = new MapCSSStyleSource("meta{title:\"canvas\";}canvas{default-points:false;}");
+        StyleSource node = new MapCSSStyleSource("meta{title:\"node\";}node{text:ref;}");
+        node.loadStyleSource();
+        canvas.loadStyleSource();
+        EqualsVerifier.forClass(MapBoxVectorStyle.class)
+          .withPrefabValues(ImageProvider.class, new ImageProvider("cancel"), new ImageProvider("ok"))
+          .withPrefabValues(StyleSource.class, canvas, node)
+          .usingGetClass().verify();
+    }
+
+    /**
+     * Check that an instruction is in a collection of instructions, and return it
+     * @param instructions The instructions to search
+     * @param key The key to look for
+     * @param value The expected value for the key
+     */
+    private void assertInInstructions(Collection<Instruction> instructions, String key, Object value) {
+        // In JOSM, all Instruction objects are AssignmentInstruction objects
+        Collection<Instruction.AssignmentInstruction> instructionKeys = instructions.stream()
+          .filter(Instruction.AssignmentInstruction.class::isInstance)
+          .map(Instruction.AssignmentInstruction.class::cast).filter(instruction -> Objects.equals(key, instruction.key))
+          .collect(Collectors.toList());
+        Optional<Instruction.AssignmentInstruction> instructionOptional = instructionKeys.stream()
+          .filter(instruction -> Objects.equals(value, instruction.val)).findAny();
+        assertTrue(instructionOptional.isPresent(), MessageFormat
+          .format("Expected {0}, but got {1}", value, instructionOptional.orElse(instructionKeys.stream().findAny()
+            .orElseThrow(() -> new AssertionError("No instruction with "+key+" found"))).val));
+    }
+
+    private static MapCSSRule getRule(MapCSSStyleSource source, String base, String subpart) {
+        // We need to do a new arraylist just to avoid the occasional ConcurrentModificationException
+        return new ArrayList<>(source.rules).stream().filter(rule -> rule.selectors.stream()
+          .anyMatch(selector -> base.equals(selector.getBase()) && subpart.equals(selector.getSubpart().getId(null))))
+          .findAny().orElse(null);
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceTest.java
new file mode 100644
index 000000000..500b5f8b5
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceTest.java
@@ -0,0 +1,188 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+import java.util.Locale;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+import javax.json.JsonValue;
+
+import org.openstreetmap.josm.data.Bounds;
+
+import nl.jqno.equalsverifier.EqualsVerifier;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link Source}
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class SourceTest {
+    @Test
+    void testEquals() {
+        EqualsVerifier.forClass(Source.class).usingGetClass().verify();
+    }
+
+    @Test
+    void testSimpleSources() {
+        final JsonObject emptyObject = Json.createObjectBuilder().build();
+        assertThrows(NullPointerException.class, () -> new Source("Test source", emptyObject));
+
+        final JsonObject badTypeValue = Json.createObjectBuilder().add("type", "bad type value").build();
+        assertThrows(IllegalArgumentException.class, () -> new Source("Test source", badTypeValue));
+
+        // Only SourceType.{VECTOR,RASTER} are supported
+        final SourceType[] supported = new SourceType[] {SourceType.VECTOR, SourceType.RASTER};
+        for (SourceType type : supported) {
+            final JsonObject goodSourceType = Json.createObjectBuilder().add("type", type.toString().toLowerCase(Locale.ROOT)).build();
+            Source source = assertDoesNotThrow(() -> new Source(type.name(), goodSourceType));
+            // Check defaults
+            assertEquals(0, source.getMinZoom());
+            assertEquals(22, source.getMaxZoom());
+            assertEquals(type.name(), source.getName());
+            assertNull(source.getAttributionText());
+            assertTrue(source.getUrls().isEmpty());
+            assertEquals(new Bounds(-85.051129, -180, 85.051129, 180), source.getBounds());
+        }
+
+        // Check that unsupported types throw
+        for (SourceType type : Stream.of(SourceType.values()).filter(t -> Stream.of(supported).noneMatch(t::equals)).collect(
+          Collectors.toList())) {
+            final JsonObject goodSourceType = Json.createObjectBuilder().add("type", type.toString().toLowerCase(Locale.ROOT)).build();
+            assertThrows(UnsupportedOperationException.class, () -> new Source(type.name(), goodSourceType));
+        }
+    }
+
+    @Test
+    void testTileJsonSpec() {
+        // This isn't currently implemented, so it should throw. Mostly here to remind implementor to add tests...
+        final JsonObject tileJsonSpec = Json.createObjectBuilder()
+          .add("type", SourceType.VECTOR.name()).add("url", "some-random-url.com")
+          .build();
+        assertThrows(UnsupportedOperationException.class, () -> new Source("Test TileJson", tileJsonSpec));
+    }
+
+    @Test
+    void testBounds() {
+        // Check a "good" bounds
+        final JsonObject tileJsonSpec = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("bounds",
+          Json.createArrayBuilder().add(-1).add(-2).add(3).add(4)).build();
+        Source source = new Source("Test Bounds[-1, -2, 3, 4]", tileJsonSpec);
+        assertEquals(new Bounds(-2, -1, 4, 3), source.getBounds());
+
+        // Check "bad" bounds
+        final JsonObject tileJsonSpecShort = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("bounds",
+          Json.createArrayBuilder().add(-1).add(-2).add(3)).build();
+        IllegalArgumentException badLengthException = assertThrows(IllegalArgumentException.class,
+          () -> new Source("Test Bounds[-1, -2, 3]", tileJsonSpecShort));
+        assertEquals("bounds must have four values, but has 3", badLengthException.getMessage());
+
+        final JsonObject tileJsonSpecLong = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("bounds",
+          Json.createArrayBuilder().add(-1).add(-2).add(3).add(4).add(5)).build();
+        badLengthException = assertThrows(IllegalArgumentException.class, () -> new Source("Test Bounds[-1, -2, 3, 4, 5]", tileJsonSpecLong));
+        assertEquals("bounds must have four values, but has 5", badLengthException.getMessage());
+    }
+
+    @Test
+    void testTiles() {
+        // No url
+        final JsonObject tileJsonSpecEmpty = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("tiles",
+          JsonValue.NULL).build();
+        Source source = new Source("Test Tile[]", tileJsonSpecEmpty);
+        assertTrue(source.getUrls().isEmpty());
+
+        // Create a tile URL
+        final JsonObject tileJsonSpec = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("tiles",
+          Json.createArrayBuilder().add("https://example.org/{bbox-epsg-3857}")).build();
+        source = new Source("Test Tile[https://example.org/{bbox-epsg-3857}]", tileJsonSpec);
+        assertEquals(1, source.getUrls().size());
+        // Make certain that {bbox-epsg-3857} is replaced with the JOSM equivalent
+        assertEquals("https://example.org/{bbox}", source.getUrls().get(0));
+
+        // Check with invalid data
+        final JsonObject tileJsonSpecBad = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("tiles",
+          Json.createArrayBuilder().add(1).add("https://example.org/{bbox-epsg-3857}").add(false).add(Json.createArrayBuilder().add("hello"))
+            .add(Json.createObjectBuilder().add("bad", "array"))).build();
+        source = new Source("Test Tile[1, https://example.org/{bbox-epsg-3857}, false, [\"hello\"], {\"bad\": \"array\"}]", tileJsonSpecBad);
+        assertEquals(1, source.getUrls().size());
+        // Make certain that {bbox-epsg-3857} is replaced with the JOSM equivalent
+        assertEquals("https://example.org/{bbox}", source.getUrls().get(0));
+    }
+
+    @Test
+    void testZoom() {
+        // Min zoom
+        final JsonObject minZoom5 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("minzoom",
+          5).build();
+        Source source = new Source("Test Zoom[minzoom=5]", minZoom5);
+        assertEquals(5, source.getMinZoom());
+        assertEquals(22, source.getMaxZoom());
+
+        // Negative min zoom
+        final JsonObject minZoomNeg1 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("minzoom",
+          -1).build();
+        source = new Source("Test Zoom[minzoom=-1]", minZoomNeg1);
+        assertEquals(0, source.getMinZoom());
+        assertEquals(22, source.getMaxZoom());
+
+        // Max zoom
+        final JsonObject maxZoom5 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("maxzoom",
+          5).build();
+        source = new Source("Test Zoom[maxzoom=5]", maxZoom5);
+        assertEquals(0, source.getMinZoom());
+        assertEquals(5, source.getMaxZoom());
+
+        // Big Max zoom
+        final JsonObject maxZoom31 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("maxzoom",
+          31).build();
+        source = new Source("Test Zoom[maxzoom=31]", maxZoom31);
+        assertEquals(0, source.getMinZoom());
+        assertEquals(30, source.getMaxZoom());
+
+        // Negative max zoom
+        final JsonObject maxZoomNeg5 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("maxzoom",
+          -5).build();
+        source = new Source("Test Zoom[maxzoom=-5]", maxZoomNeg5);
+        assertEquals(0, source.getMinZoom());
+        assertEquals(0, source.getMaxZoom());
+
+        // Min max zoom
+        final JsonObject minZoom1MaxZoom5 = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("maxzoom",
+          5).add("minzoom", 1).build();
+        source = new Source("Test Zoom[minzoom=1,maxzoom=5]", minZoom1MaxZoom5);
+        assertEquals(1, source.getMinZoom());
+        assertEquals(5, source.getMaxZoom());
+    }
+
+    @Test
+    void testToString() {
+        // Simple (no urls)
+        final JsonObject noTileJsonSpec = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).build();
+        Source source = new Source("Test String[]", noTileJsonSpec);
+        assertEquals("Test String[]", source.toString());
+
+        // With one url
+        final JsonObject tileJsonSpec = Json.createObjectBuilder().add("type", SourceType.VECTOR.name()).add("tiles",
+          Json.createArrayBuilder().add("https://example.org/{bbox-epsg-3857}")).build();
+        source = new Source("Test String[https://example.org/{bbox-epsg-3857}]", tileJsonSpec);
+        assertEquals("Test String[https://example.org/{bbox-epsg-3857}] https://example.org/{bbox}", source.toString());
+
+        // With two URLs
+        final JsonObject tileJsonSpecMultiple = Json.createObjectBuilder().add("type", SourceType.VECTOR.name())
+          .add("tiles", Json.createArrayBuilder()
+            .add("https://example.org/{bbox-epsg-3857}")
+            .add("https://example.com/{bbox-epsg-3857}")).build();
+        source = new Source("Test String[https://example.org/{bbox-epsg-3857},https://example.com/{bbox-epsg-3857}]", tileJsonSpecMultiple);
+        assertEquals("Test String[https://example.org/{bbox-epsg-3857},https://example.com/{bbox-epsg-3857}] https://example.org/{bbox} "
+          + "https://example.com/{bbox}", source.toString());
+    }
+}
-- 
GitLab


From f8f62e7ec24b189bfab536c45d6b3d2c2f97c70a Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 8 Apr 2021 15:56:16 -0600
Subject: [PATCH 05/50] Vector data storage

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/vector/DataLayer.java           |  23 +
 .../josm/data/vector/DataStore.java           | 126 ++++
 .../josm/data/vector/VectorDataSet.java       | 541 ++++++++++++++++++
 .../josm/data/vector/VectorDataStore.java     | 354 ++++++++++++
 .../josm/data/vector/VectorNode.java          | 113 ++++
 .../josm/data/vector/VectorPrimitive.java     | 256 +++++++++
 .../josm/data/vector/VectorRelation.java      | 114 ++++
 .../data/vector/VectorRelationMember.java     |  70 +++
 .../josm/data/vector/VectorWay.java           | 132 +++++
 .../josm/data/vector/VectorDataSetTest.java   | 141 +++++
 .../josm/data/vector/VectorNodeTest.java      | 153 +++++
 .../josm/data/vector/VectorRelationTest.java  |  45 ++
 .../josm/data/vector/VectorWayTest.java       | 117 ++++
 13 files changed, 2185 insertions(+)
 create mode 100644 src/org/openstreetmap/josm/data/vector/DataLayer.java
 create mode 100644 src/org/openstreetmap/josm/data/vector/DataStore.java
 create mode 100644 src/org/openstreetmap/josm/data/vector/VectorDataSet.java
 create mode 100644 src/org/openstreetmap/josm/data/vector/VectorDataStore.java
 create mode 100644 src/org/openstreetmap/josm/data/vector/VectorNode.java
 create mode 100644 src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
 create mode 100644 src/org/openstreetmap/josm/data/vector/VectorRelation.java
 create mode 100644 src/org/openstreetmap/josm/data/vector/VectorRelationMember.java
 create mode 100644 src/org/openstreetmap/josm/data/vector/VectorWay.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/vector/VectorNodeTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/vector/VectorRelationTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/vector/VectorWayTest.java

diff --git a/src/org/openstreetmap/josm/data/vector/DataLayer.java b/src/org/openstreetmap/josm/data/vector/DataLayer.java
new file mode 100644
index 000000000..9052e5b1a
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/vector/DataLayer.java
@@ -0,0 +1,23 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+/**
+ * An interface for objects that are part of a data layer
+ * @param <T> The type used to identify a layer, typically a string
+ */
+public interface DataLayer<T> {
+    /**
+     * Get the layer
+     * @return The layer
+     */
+    T getLayer();
+
+    /**
+     * Set the layer
+     * @param layer The layer to set
+     * @return {@code true} if the layer was set -- some objects may never change layers.
+     */
+    default boolean setLayer(T layer) {
+        return layer != null && layer.equals(getLayer());
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/vector/DataStore.java b/src/org/openstreetmap/josm/data/vector/DataStore.java
new file mode 100644
index 000000000..9de044f62
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/vector/DataStore.java
@@ -0,0 +1,126 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.josm.data.DataSource;
+import org.openstreetmap.josm.data.osm.DataSet;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.PrimitiveId;
+import org.openstreetmap.josm.data.osm.QuadBucketPrimitiveStore;
+import org.openstreetmap.josm.data.osm.Storage;
+
+/**
+ * A class that stores data (essentially a simple {@link DataSet})
+ * @author Taylor Smock
+ * @since xxx
+ */
+class DataStore<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>> {
+    /**
+     * This literally only exists to make {@link QuadBucketPrimitiveStore#removePrimitive} public
+     *
+     * @param <N> The node type
+     * @param <W> The way type
+     * @param <R> The relation type
+     */
+    static class LocalQuadBucketPrimitiveStore<N extends INode, W extends IWay<N>, R extends IRelation<?>>
+      extends QuadBucketPrimitiveStore<N, W, R> {
+        // Allow us to remove primitives (protected in {@link QuadBucketPrimitiveStore})
+        @Override
+        public void removePrimitive(IPrimitive primitive) {
+            super.removePrimitive(primitive);
+        }
+    }
+
+    protected final int zoom;
+    protected final LocalQuadBucketPrimitiveStore<N, W, R> store = new LocalQuadBucketPrimitiveStore<>();
+    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<>();
+    private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
+
+    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() {
+        if (this.readWriteLock.isWriteLocked()) {
+            return new HashMap<>(this.primitivesMap);
+        }
+        return this.primitivesMap;
+    }
+
+    public Collection<DataSource> getDataSources() {
+        return Collections.unmodifiableCollection(dataSources);
+    }
+
+    /**
+     * Add a datasource to this data set
+     * @param dataSource The data soure to add
+     */
+    public void addDataSource(DataSource dataSource) {
+        this.dataSources.add(dataSource);
+    }
+
+    /**
+     * Add a primitive to this dataset
+     * @param primitive The primitive to remove
+     */
+    @SuppressWarnings("squid:S2445")
+    protected void removePrimitive(O primitive) {
+        if (primitive == null) {
+            return;
+        }
+        // This is deliberate -- attempting to remove the primitive twice causes issues
+        synchronized (primitive) {
+            if (this.allPrimitives.contains(primitive)) {
+                this.store.removePrimitive(primitive);
+                this.allPrimitives.remove(primitive);
+                this.primitivesMap.remove(primitive.getPrimitiveId());
+            }
+        }
+    }
+
+    /**
+     * Add a primitive to this dataset
+     * @param primitive The primitive to add
+     */
+    protected void addPrimitive(O primitive) {
+        this.store.addPrimitive(primitive);
+        this.allPrimitives.add(primitive);
+        this.primitivesMap.put(primitive.getPrimitiveId(), primitive);
+    }
+
+    /**
+     * Get the read/write lock for this dataset
+     * @return The read/write lock
+     */
+    protected ReentrantReadWriteLock getReadWriteLock() {
+        return this.readWriteLock;
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
new file mode 100644
index 000000000..dfa9334a3
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -0,0 +1,541 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+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.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.josm.data.DataSource;
+import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
+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.OsmData;
+import org.openstreetmap.josm.data.osm.PrimitiveId;
+import org.openstreetmap.josm.data.osm.UploadPolicy;
+import org.openstreetmap.josm.data.osm.WaySegment;
+import org.openstreetmap.josm.gui.mappaint.ElemStyles;
+import org.openstreetmap.josm.tools.ListenerList;
+import org.openstreetmap.josm.tools.Logging;
+import org.openstreetmap.josm.tools.SubclassFilteredCollection;
+
+/**
+ * A data class for Vector Data
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, VectorWay, VectorRelation> {
+    // Note: In Java 8, computeIfAbsent is blocking for both pre-existing and new values. In Java 9, it is only blocking
+    // for new values (perf increase). See JDK-8161372 for more info.
+    private final Map<Integer, VectorDataStore> dataStoreMap = new ConcurrentHashMap<>();
+    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 String name;
+    private short mappaintCacheIdx = 1;
+
+    private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
+
+    /**
+     * The distance to consider nodes duplicates -- mostly a memory saving measure.
+     * 0.000_000_1 ~1.2 cm (+- 5.57 mm)
+     * Descriptions from <a href="https://xkcd.com/2170/">https://xkcd.com/2170/</a>
+     * Notes on <a href="https://wiki.openstreetmap.org/wiki/Node">https://wiki.openstreetmap.org/wiki/Node</a> indicate
+     * that IEEE 32-bit floats should not be used at high longitude (0.000_01 precision)
+     */
+    protected static final float DUPE_NODE_DISTANCE = 0.000_000_1f;
+
+    /**
+     * The current zoom we are getting/adding to
+     */
+    private int zoom;
+    /**
+     * Default to normal download policy
+     */
+    private DownloadPolicy downloadPolicy = DownloadPolicy.NORMAL;
+    /**
+     * Default to a blocked upload policy
+     */
+    private UploadPolicy uploadPolicy = UploadPolicy.BLOCKED;
+    /**
+     * The paint style for this layer
+     */
+    private ElemStyles styles;
+
+    @Override
+    public Collection<DataSource> getDataSources() {
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, tZoom -> new VectorDataStore(this, tZoom));
+        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, tZoom -> new VectorDataStore(this, tZoom));
+        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 get this dynamically. Not critical, as this is currently the _only_ version.
+    }
+
+    @Override
+    public String getName() {
+        return this.name;
+    }
+
+    @Override
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    @Override
+    public void addPrimitive(VectorPrimitive primitive) {
+        primitive.setDataSet(this);
+        final int currentZoom = this.zoom;
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, tZoom -> new VectorDataStore(this, tZoom));
+        tryWrite(dataStore, () -> dataStore.addPrimitive(primitive));
+    }
+
+    /**
+     * Remove a primitive from this dataset
+     *
+     * @param primitive The primitive to remove
+     */
+    protected void removePrimitive(VectorPrimitive primitive) {
+        if (primitive.getDataSet() == this) {
+            primitive.setDataSet(null);
+            this.dataStoreMap.values()
+              .forEach(vectorDataStore -> tryWrite(vectorDataStore, () -> vectorDataStore.removePrimitive(primitive)));
+        }
+    }
+
+    @Override
+    public void clear() {
+        synchronized (this.dataStoreMap) {
+            this.dataStoreMap.clear();
+        }
+    }
+
+    @Override
+    public List<VectorNode> searchNodes(BBox bbox) {
+        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.searchNodes(bbox))
+          .orElseGet(Collections::emptyList);
+    }
+
+    @Override
+    public boolean containsNode(VectorNode vectorNode) {
+        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.containsNode(vectorNode)).orElse(false);
+    }
+
+    @Override
+    public List<VectorWay> searchWays(BBox bbox) {
+        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.searchWays(bbox))
+          .orElseGet(Collections::emptyList);
+    }
+
+    @Override
+    public boolean containsWay(VectorWay vectorWay) {
+        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.containsWay(vectorWay)).orElse(false);
+    }
+
+    @Override
+    public List<VectorRelation> searchRelations(BBox bbox) {
+        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.searchRelations(bbox))
+          .orElseGet(Collections::emptyList);
+    }
+
+    @Override
+    public boolean containsRelation(VectorRelation vectorRelation) {
+        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.containsRelation(vectorRelation)).orElse(false);
+    }
+
+    @Override
+    public VectorPrimitive getPrimitiveById(PrimitiveId primitiveId) {
+        return this.getBestZoomDataStore().map(VectorDataStore::getPrimitivesMap).map(m -> m .get(primitiveId)).orElse(null);
+    }
+
+    // The last return statement is "unchecked", even though it is literally the same as the previous return, except
+    // as an optional.
+    @SuppressWarnings("unchecked")
+    @Override
+    public <T extends VectorPrimitive> Collection<T> getPrimitives(
+      Predicate<? super VectorPrimitive> predicate) {
+        final VectorDataStore dataStore = this.getBestZoomDataStore().orElse(null);
+        if (dataStore == null) {
+            return Collections.emptyList();
+        }
+
+        if (dataStore.getReadWriteLock().isWriteLocked()) {
+            return new SubclassFilteredCollection<>(new HashSet<>(dataStore.getAllPrimitives()), predicate);
+        }
+        return (Collection<T>) tryRead(dataStore, () -> new SubclassFilteredCollection<>(dataStore.getAllPrimitives(), predicate))
+          // Throw an NPE if we don't have a collection (this should never happen, so if it does, _something_ is wrong)
+          .orElseThrow(NullPointerException::new);
+    }
+
+    @Override
+    public Collection<VectorNode> getNodes() {
+        return this.getPrimitives(VectorNode.class::isInstance);
+    }
+
+    @Override
+    public Collection<VectorWay> getWays() {
+        return this.getPrimitives(VectorWay.class::isInstance);
+    }
+
+    @Override
+    public Collection<VectorRelation> getRelations() {
+        return this.getPrimitives(VectorRelation.class::isInstance);
+    }
+
+    @Override
+    public DownloadPolicy getDownloadPolicy() {
+        return this.downloadPolicy;
+    }
+
+    @Override
+    public void setDownloadPolicy(DownloadPolicy downloadPolicy) {
+        this.downloadPolicy = downloadPolicy;
+    }
+
+    @Override
+    public UploadPolicy getUploadPolicy() {
+        return this.uploadPolicy;
+    }
+
+    @Override
+    public void setUploadPolicy(UploadPolicy uploadPolicy) {
+        this.uploadPolicy = uploadPolicy;
+    }
+
+    /**
+     * Get the current Read/Write lock
+     * @implNote This changes based off of zoom level. Please do not use this in a finally block
+     * @return The current read/write lock
+     */
+    @Override
+    public Lock getReadLock() {
+        return getBestZoomDataStore().map(VectorDataStore::getReadWriteLock).map(ReentrantReadWriteLock::readLock)
+          .orElse(this.readWriteLock.readLock());
+    }
+
+    @Override
+    public Collection<WaySegment> getHighlightedVirtualNodes() {
+        // TODO? This requires a change to WaySegment so that it isn't Way/Node specific
+        return Collections.emptyList();
+    }
+
+    @Override
+    public void setHighlightedVirtualNodes(Collection<WaySegment> waySegments) {
+        // TODO? This requires a change to WaySegment so that it isn't Way/Node specific
+    }
+
+    @Override
+    public Collection<WaySegment> getHighlightedWaySegments() {
+        // TODO? This requires a change to WaySegment so that it isn't Way/Node specific
+        return Collections.emptyList();
+    }
+
+    @Override
+    public void setHighlightedWaySegments(Collection<WaySegment> waySegments) {
+        // TODO? This requires a change to WaySegment so that it isn't Way/Node specific
+    }
+
+    @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 Optional<VectorDataStore> dataStore = this.getBestZoomDataStore();
+        return dataStore.map(vectorDataStore -> vectorDataStore.getAllPrimitives().stream()
+          .filter(primitive -> this.selected.contains(primitive.getPrimitiveId()))
+          .collect(Collectors.toList())).orElse(Collections.emptyList());
+    }
+
+    /**
+     * Get the best zoom datastore
+     * @return A datastore with data, or {@code null} if no good datastore exists.
+     */
+    private Optional<VectorDataStore> getBestZoomDataStore() {
+        final int currentZoom = this.zoom;
+        if (this.dataStoreMap.containsKey(currentZoom)) {
+            return Optional.of(this.dataStoreMap.get(currentZoom));
+        }
+        // Check up to two zooms higher (may cause perf hit)
+        for (int tZoom = currentZoom + 1; tZoom < currentZoom + 3; tZoom++) {
+            if (this.dataStoreMap.containsKey(tZoom)) {
+                return Optional.of(this.dataStoreMap.get(tZoom));
+            }
+        }
+        // Return *any* lower zoom data (shouldn't cause a perf hit...)
+        for (int tZoom = currentZoom - 1; tZoom >= 0; tZoom--) {
+            if (this.dataStoreMap.containsKey(tZoom)) {
+                return Optional.of(this.dataStoreMap.get(tZoom));
+            }
+        }
+        // Check higher level zooms. May cause perf issues if selected datastore has a lot of data.
+        for (int tZoom = currentZoom + 3; tZoom < 34; tZoom++) {
+            if (this.dataStoreMap.containsKey(tZoom)) {
+                return Optional.of(this.dataStoreMap.get(tZoom));
+            }
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public boolean selectionEmpty() {
+        return this.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);
+    }
+
+    public short getMappaintCacheIndex() {
+        return this.mappaintCacheIdx;
+    }
+
+    @Override
+    public void clearMappaintCache() {
+        this.mappaintCacheIdx++;
+    }
+
+    public void setZoom(int zoom) {
+        if (zoom == this.zoom) {
+            return; // Do nothing -- zoom isn't actually changing
+        }
+        this.zoom = zoom;
+        this.clearMappaintCache();
+        final int[] nearestZoom = {-1, -1, -1, -1};
+        nearestZoom[0] = zoom;
+        // Create a new list to avoid concurrent modification issues
+        synchronized (this.dataStoreMap) {
+            final int[] keys = new ArrayList<>(this.dataStoreMap.keySet()).stream().filter(Objects::nonNull)
+              .mapToInt(Integer::intValue).sorted().toArray();
+            final int index;
+            if (this.dataStoreMap.containsKey(zoom)) {
+                index = Arrays.binarySearch(keys, zoom);
+            } else {
+                // (-(insertion point) - 1) = return -> insertion point = -(return + 1)
+                index = -(Arrays.binarySearch(keys, zoom) + 1);
+            }
+            if (index > 0) {
+                nearestZoom[1] = keys[index - 1];
+            }
+            if (index < keys.length - 2) {
+                nearestZoom[2] = keys[index + 1];
+            }
+
+            nearestZoom[3] = this.getBestZoomDataStore().map(VectorDataStore::getZoom).orElse(-1);
+            IntStream.of(keys).filter(key -> IntStream.of(nearestZoom).noneMatch(zoomKey -> zoomKey == key))
+              .mapToObj(this.dataStoreMap::get).forEach(VectorDataStore::destroy);
+            IntStream.of(keys).filter(key -> IntStream.of(nearestZoom).noneMatch(zoomKey -> zoomKey == key))
+              .forEach(this.dataStoreMap::remove);
+        }
+    }
+
+    public int getZoom() {
+        return this.zoom;
+    }
+
+    /**
+     * Add tile data to this dataset
+     * @param tile The tile to add
+     * @param <T> The tile type
+     */
+    public <T extends Tile & VectorTile> void addTileData(T tile) {
+        final int currentZoom = tile.getZoom();
+        // computeIfAbsent should be thread safe (ConcurrentHashMap indicates it is, anyway)
+        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, tZoom -> new VectorDataStore(this, tZoom));
+        tryWrite(dataStore, () -> dataStore.addTile(tile));
+    }
+
+    /**
+     * Try to read something (here to avoid boilerplate)
+     *
+     * @param supplier The reading function
+     * @param <T>      The return type
+     * @return The optional return
+     */
+    private static <T> Optional<T> tryRead(VectorDataStore dataStore, Supplier<T> supplier) {
+        try {
+            dataStore.getReadWriteLock().readLock().lockInterruptibly();
+            return Optional.ofNullable(supplier.get());
+        } catch (InterruptedException e) {
+            Logging.error(e);
+            Thread.currentThread().interrupt();
+        } finally {
+            dataStore.getReadWriteLock().readLock().unlock();
+        }
+        return Optional.empty();
+    }
+
+    /**
+     * Try to write something (here to avoid boilerplate)
+     *
+     * @param runnable The writing function
+     */
+    private static void tryWrite(VectorDataStore dataStore, Runnable runnable) {
+        try {
+            dataStore.getReadWriteLock().writeLock().lockInterruptibly();
+            runnable.run();
+        } catch (InterruptedException e) {
+            Logging.error(e);
+            Thread.currentThread().interrupt();
+        } finally {
+            if (dataStore.getReadWriteLock().isWriteLockedByCurrentThread()) {
+                dataStore.getReadWriteLock().writeLock().unlock();
+            }
+        }
+    }
+
+    /**
+     * Get the styles for this layer
+     *
+     * @return The styles
+     */
+    public ElemStyles getStyles() {
+        return this.styles;
+    }
+
+    /**
+     * Set the styles for this layer
+     * @param styles The styles to set for this layer
+     */
+    public void setStyles(Collection<ElemStyles> styles) {
+        if (styles.size() == 1) {
+            this.styles = styles.iterator().next();
+        } else if (!styles.isEmpty()) {
+            this.styles = new ElemStyles(styles.stream().flatMap(style -> style.getStyleSources().stream()).collect(Collectors.toList()));
+        } else {
+            this.styles = null;
+        }
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
new file mode 100644
index 000000000..f486651b6
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
@@ -0,0 +1,354 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.awt.geom.Area;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Path2D;
+import java.awt.geom.PathIterator;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import org.openstreetmap.gui.jmapviewer.Coordinate;
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.SimplePrimitiveId;
+import org.openstreetmap.josm.data.osm.UniqueIdGenerator;
+import org.openstreetmap.josm.gui.dialogs.relation.sort.RelationSorter;
+import org.openstreetmap.josm.tools.Destroyable;
+import org.openstreetmap.josm.tools.Geometry;
+
+/**
+ * A data store for Vector Data sets
+ * @author Taylor Smock
+ * @since xxx
+ */
+class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay, VectorRelation> implements Destroyable {
+    private static final String JOSM_MERGE_TYPE_KEY = "josm_merge_type";
+    private final VectorDataSet dataSet;
+
+    VectorDataStore(VectorDataSet dataSet, int zoom) {
+        super(zoom);
+        this.dataSet = dataSet;
+    }
+
+    @Override
+    protected void addPrimitive(VectorPrimitive primitive) {
+        primitive.setDataSet(this.dataSet);
+        // The field is uint64, so we can use negative numbers to indicate that it is a "generated" object (e.g., nodes for ways)
+        if (primitive.getUniqueId() == 0) {
+            final UniqueIdGenerator generator = primitive.getIdGenerator();
+            long id;
+            do {
+                id = generator.generateUniqueId();
+            } while (this.primitivesMap.containsKey(new SimplePrimitiveId(id, primitive.getType())));
+            primitive.setId(primitive.getIdGenerator().generateUniqueId());
+        }
+        if (primitive instanceof VectorRelation && !primitive.isMultipolygon()) {
+            primitive = mergeWays((VectorRelation) primitive);
+        }
+        final VectorPrimitive alreadyAdded = this.primitivesMap.get(primitive.getPrimitiveId());
+        final VectorRelation mergedRelation = (VectorRelation) this.primitivesMap
+          .get(new SimplePrimitiveId(primitive.getPrimitiveId().getUniqueId(),
+            OsmPrimitiveType.RELATION));
+        if (alreadyAdded == null || alreadyAdded.equals(primitive)) {
+            super.addPrimitive(primitive);
+        } else if (mergedRelation != null && mergedRelation.get(JOSM_MERGE_TYPE_KEY) != null) {
+            mergedRelation.addRelationMember(new VectorRelationMember("", primitive));
+            super.addPrimitive(primitive);
+            // Check that all primitives can be merged
+            if (mergedRelation.getMemberPrimitivesList().stream().allMatch(IWay.class::isInstance)) {
+                // This pretty much does the "right" thing
+                this.mergeWays(mergedRelation);
+            } else if (!(primitive instanceof IWay)) {
+                // Can't merge, ever (one of the childs is a node/relation)
+                mergedRelation.remove(JOSM_MERGE_TYPE_KEY);
+            }
+        } else if (mergedRelation != null && primitive instanceof IRelation) {
+            // Just add to the relation
+            ((VectorRelation) primitive).getMembers().forEach(mergedRelation::addRelationMember);
+        } else if (alreadyAdded instanceof VectorWay && primitive instanceof VectorWay) {
+            final VectorRelation temporaryRelation =
+              mergedRelation == null ? new VectorRelation(primitive.getLayer()) : mergedRelation;
+            if (mergedRelation == null) {
+                temporaryRelation.put(JOSM_MERGE_TYPE_KEY, "merge");
+                temporaryRelation.addRelationMember(new VectorRelationMember("", alreadyAdded));
+            }
+            temporaryRelation.addRelationMember(new VectorRelationMember("", primitive));
+            temporaryRelation.setDataSet(this.dataSet);
+            super.addPrimitive(primitive);
+            super.addPrimitive(temporaryRelation);
+        }
+    }
+
+    private VectorPrimitive mergeWays(VectorRelation relation) {
+        List<VectorRelationMember> members = RelationSorter.sortMembersByConnectivity(relation.getMembers());
+        Collection<VectorWay> relationWayList = members.stream().map(VectorRelationMember::getMember)
+          .filter(VectorWay.class::isInstance)
+          .map(VectorWay.class::cast).collect(Collectors.toCollection(ArrayList::new));
+        // Only support way-only relations
+        if (relationWayList.size() != relation.getMemberPrimitivesList().size()) {
+            return relation;
+        }
+        List<VectorWay> wayList = new ArrayList<>(relation.getMembersCount());
+        // Assume that the order may not be correct, worst case O(n), best case O(n/2)
+        // Assume that the ways were drawn in order
+        final int maxIteration = relationWayList.size();
+        int iteration = 0;
+        while (iteration < maxIteration && wayList.size() < relationWayList.size()) {
+            for (VectorWay way : relationWayList) {
+                if (wayList.isEmpty()) {
+                    wayList.add(way);
+                    continue;
+                }
+                // Check first/last ways (last first, since the list *should* be sorted)
+                if (canMergeWays(wayList.get(wayList.size() - 1), way, false)) {
+                    wayList.add(way);
+                } else if (canMergeWays(wayList.get(0), way, false)) {
+                    wayList.add(0, way);
+                }
+            }
+            iteration++;
+            relationWayList.removeIf(wayList::contains);
+        }
+        if (!relationWayList.isEmpty()) {
+            return relation;
+        }
+        // Merge ways
+        List<VectorNode> nodes = new ArrayList<>();
+        for (VectorWay way : wayList) {
+            for (VectorNode node : way.getNodes()) {
+                if (nodes.isEmpty() || !Objects.equals(nodes.get(nodes.size() - 1), node)) {
+                    nodes.add(node);
+                }
+            }
+        }
+        VectorWay way = wayList.get(0);
+        way.setNodes(nodes);
+        wayList.remove(way);
+        wayList.forEach(this::removePrimitive);
+        this.removePrimitive(relation);
+        return way;
+    }
+
+    private static <N extends INode, W extends IWay<N>> boolean canMergeWays(W old, W toAdd, boolean allowReverse) {
+        final List<N> nodes = new ArrayList<>(old.getNodes());
+        boolean added = true;
+        if (allowReverse && old.firstNode().equals(toAdd.firstNode())) {
+            // old <-|-> new becomes old ->|-> new
+            Collections.reverse(nodes);
+            nodes.addAll(toAdd.getNodes());
+        } else if (old.firstNode().equals(toAdd.lastNode())) {
+            // old <-|<- new, so we prepend the new nodes in order
+            nodes.addAll(0, toAdd.getNodes());
+        } else if (old.lastNode().equals(toAdd.firstNode())) {
+            // old ->|-> new, we just add it
+            nodes.addAll(toAdd.getNodes());
+        } else if (allowReverse && old.lastNode().equals(toAdd.lastNode())) {
+            // old ->|<- new, we need to reverse new
+            final List<N> toAddNodes = new ArrayList<>(toAdd.getNodes());
+            Collections.reverse(toAddNodes);
+            nodes.addAll(toAddNodes);
+        } else {
+            added = false;
+        }
+        if (added) {
+            // This is (technically) always correct
+            old.setNodes(nodes);
+        }
+        return added;
+    }
+
+    private synchronized <T extends Tile & VectorTile> VectorNode pointToNode(T tile, Layer layer,
+      Collection<VectorPrimitive> featureObjects, int x, int y) {
+        final 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(), VectorDataSet.DUPE_NODE_DISTANCE));
+        final VectorNode node;
+        if (!nodes.isEmpty()) {
+            final VectorNode first = nodes.iterator().next();
+            if (first.isDisabled() || !first.isVisible()) {
+                // Only replace nodes that are not visible
+                node = new VectorNode(layer.getName());
+                node.setCoor(node.getCoor());
+                first.getReferrers(true).forEach(primitive -> {
+                    if (primitive instanceof VectorWay) {
+                        List<VectorNode> nodeList = new ArrayList<>(((VectorWay) primitive).getNodes());
+                        nodeList.replaceAll(vnode -> vnode.equals(first) ? node : vnode);
+                        ((VectorWay) primitive).setNodes(nodeList);
+                    } else if (primitive instanceof VectorRelation) {
+                        List<VectorRelationMember> members = new ArrayList<>(((VectorRelation) primitive).getMembers());
+                        members.replaceAll(member ->
+                          member.getMember().equals(first) ? new VectorRelationMember(member.getRole(), node) : member);
+                        ((VectorRelation) primitive).setMembers(members);
+                    }
+                });
+                this.removePrimitive(first);
+            } else {
+                node = first;
+            }
+        } else {
+            node = new VectorNode(layer.getName());
+        }
+        node.setCoor(coords);
+        featureObjects.add(node);
+        return node;
+    }
+
+    private <T extends Tile & VectorTile> List<VectorWay> pathToWay(T tile, Layer layer,
+      Collection<VectorPrimitive> featureObjects, Path2D shape) {
+        final PathIterator pathIterator = shape.getPathIterator(null);
+        final List<VectorWay> ways = pathIteratorToObjects(tile, layer, featureObjects, pathIterator).stream()
+          .filter(VectorWay.class::isInstance).map(VectorWay.class::cast).collect(
+            Collectors.toList());
+        // These nodes technically do not exist, so we shouldn't show them
+        ways.stream().flatMap(way -> way.getNodes().stream())
+          .filter(prim -> !prim.isTagged() && prim.getReferrers(true).size() == 1 && prim.getId() <= 0)
+          .forEach(prim -> {
+              prim.setDisabled(true);
+              prim.setVisible(false);
+          });
+        return ways;
+    }
+
+    private <T extends Tile & VectorTile> List<VectorPrimitive> pathIteratorToObjects(T tile, Layer layer,
+      Collection<VectorPrimitive> featureObjects, PathIterator pathIterator) {
+        final List<VectorNode> nodes = new ArrayList<>();
+        final double[] coords = new double[6];
+        final List<VectorPrimitive> ways = new ArrayList<>();
+        do {
+            final int type = pathIterator.currentSegment(coords);
+            pathIterator.next();
+            if ((PathIterator.SEG_MOVETO == type || PathIterator.SEG_CLOSE == type) && !nodes.isEmpty()) {
+                if (PathIterator.SEG_CLOSE == type) {
+                    nodes.add(nodes.get(0));
+                }
+                // New line
+                if (!nodes.isEmpty()) {
+                    final VectorWay way = new VectorWay(layer.getName());
+                    way.setNodes(nodes);
+                    featureObjects.add(way);
+                    ways.add(way);
+                }
+                nodes.clear();
+            }
+            if (PathIterator.SEG_MOVETO == type || PathIterator.SEG_LINETO == type) {
+                final VectorNode node = pointToNode(tile, layer, featureObjects, (int) coords[0], (int) coords[1]);
+                nodes.add(node);
+            } else if (PathIterator.SEG_CLOSE != type) {
+                // Vector Tiles only have MoveTo, LineTo, and ClosePath. Anything else is not supported at this time.
+                throw new UnsupportedOperationException();
+            }
+        } while (!pathIterator.isDone());
+        if (!nodes.isEmpty()) {
+            final VectorWay way = new VectorWay(layer.getName());
+            way.setNodes(nodes);
+            featureObjects.add(way);
+            ways.add(way);
+        }
+        return ways;
+    }
+
+    private <T extends Tile & VectorTile> VectorRelation areaToRelation(T tile, Layer layer,
+      Collection<VectorPrimitive> featureObjects, Area area) {
+        final PathIterator pathIterator = area.getPathIterator(null);
+        final List<VectorPrimitive> members = pathIteratorToObjects(tile, layer, featureObjects, pathIterator);
+        VectorRelation vectorRelation = new VectorRelation(layer.getName());
+        for (VectorPrimitive member : members) {
+            final String role;
+            if (member instanceof VectorWay && ((VectorWay) member).isClosed()) {
+                role = Geometry.isClockwise(((VectorWay) member).getNodes()) ? "outer" : "inner";
+            } else {
+                role = "";
+            }
+            vectorRelation.addRelationMember(new VectorRelationMember(role, member));
+        }
+        return vectorRelation;
+    }
+
+    /**
+     * Add a tile to this data store
+     * @param tile The tile to add
+     * @param <T> The tile type
+     */
+    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())) {
+            previous.ifPresent(this.addedTiles::remove);
+            this.addedTiles.add(tile);
+            for (Layer layer : tile.getLayers()) {
+                layer.getFeatures().forEach(feature -> {
+                    org.openstreetmap.josm.data.imagery.vectortile.mapbox.Geometry geometry = feature
+                      .getGeometryObject();
+                    List<VectorPrimitive> featureObjects = new ArrayList<>();
+                    List<VectorPrimitive> primaryFeatureObjects = new ArrayList<>();
+                    geometry.getShapes().forEach(shape -> {
+                        final VectorPrimitive primitive;
+                        if (shape instanceof Ellipse2D) {
+                            primitive = pointToNode(tile, layer, featureObjects,
+                              (int) ((Ellipse2D) shape).getCenterX(), (int) ((Ellipse2D) shape).getCenterY());
+                        } else if (shape instanceof Path2D) {
+                            primitive = pathToWay(tile, layer, featureObjects, (Path2D) shape).stream().findFirst()
+                              .orElse(null);
+                        } else if (shape instanceof Area) {
+                            primitive = areaToRelation(tile, layer, featureObjects, (Area) shape);
+                            primitive.put("type", "multipolygon");
+                        } else {
+                            // We shouldn't hit this, but just in case
+                            throw new UnsupportedOperationException();
+                        }
+                        primaryFeatureObjects.add(primitive);
+                    });
+                    final VectorPrimitive primitive;
+                    if (primaryFeatureObjects.size() == 1) {
+                        primitive = primaryFeatureObjects.get(0);
+                        if (primitive instanceof IRelation && !primitive.isMultipolygon()) {
+                            primitive.put(JOSM_MERGE_TYPE_KEY, "merge");
+                        }
+                    } else if (!primaryFeatureObjects.isEmpty()) {
+                        VectorRelation relation = new VectorRelation(layer.getName());
+                        primaryFeatureObjects.stream().map(prim -> new VectorRelationMember("", prim))
+                          .forEach(relation::addRelationMember);
+                        primitive = relation;
+                    } else {
+                        return;
+                    }
+                    primitive.setId(feature.getId());
+                    feature.getTags().forEach(primitive::put);
+                    featureObjects.forEach(this::addPrimitive);
+                    primaryFeatureObjects.forEach(this::addPrimitive);
+                    this.addPrimitive(primitive);
+                });
+            }
+        }
+    }
+
+    @Override
+    public void destroy() {
+        this.addedTiles.forEach(tile -> tile.setLoaded(false));
+        this.addedTiles.forEach(tile -> tile.setImage(null));
+        this.addedTiles.clear();
+        this.store.clear();
+        this.allPrimitives.clear();
+        this.primitivesMap.clear();
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/vector/VectorNode.java b/src/org/openstreetmap/josm/data/vector/VectorNode.java
new file mode 100644
index 000000000..60aecd8ff
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/vector/VectorNode.java
@@ -0,0 +1,113 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.List;
+
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.UniqueIdGenerator;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+
+/**
+ * The "Node" type of a vector layer
+ *
+ * @since xxx
+ */
+public class VectorNode extends VectorPrimitive implements INode {
+    private static final UniqueIdGenerator ID_GENERATOR = new UniqueIdGenerator();
+    private double lon = Double.NaN;
+    private double lat = Double.NaN;
+
+    /**
+     * Create a new vector node
+     * @param layer The layer for the vector node
+     */
+    public VectorNode(String layer) {
+        super(layer);
+    }
+
+    @Override
+    public double lon() {
+        return this.lon;
+    }
+
+    @Override
+    public double lat() {
+        return this.lat;
+    }
+
+    @Override
+    public UniqueIdGenerator getIdGenerator() {
+        return ID_GENERATOR;
+    }
+
+    @Override
+    public LatLon getCoor() {
+        return new LatLon(this.lat, this.lon);
+    }
+
+    @Override
+    public void setCoor(LatLon coordinates) {
+        this.lat = coordinates.lat();
+        this.lon = coordinates.lon();
+    }
+
+    /**
+     * Set the coordinates of this node
+     *
+     * @param coordinates The coordinates to set
+     * @see #setCoor(LatLon)
+     */
+    public void setCoor(ICoordinate coordinates) {
+        this.lat = coordinates.getLat();
+        this.lon = coordinates.getLon();
+    }
+
+    @Override
+    public void setEastNorth(EastNorth eastNorth) {
+        final LatLon ll = ProjectionRegistry.getProjection().eastNorth2latlon(eastNorth);
+        this.lat = ll.lat();
+        this.lon = ll.lon();
+    }
+
+    @Override
+    public boolean isReferredByWays(int n) {
+        // Count only referrers that are members of the same dataset (primitive can have some fake references, for example
+        // when way is cloned
+        List<? extends IPrimitive> referrers = super.getReferrers();
+        if (referrers == null || referrers.isEmpty())
+            return false;
+        if (referrers instanceof IPrimitive)
+            return n <= 1 && referrers instanceof IWay && ((IPrimitive) referrers).getDataSet() == getDataSet();
+        else {
+            int counter = 0;
+            for (IPrimitive o : referrers) {
+                if (getDataSet() == o.getDataSet() && o instanceof IWay && ++counter >= n)
+                    return true;
+            }
+            return false;
+        }
+    }
+
+    @Override
+    public void accept(PrimitiveVisitor visitor) {
+        visitor.visit(this);
+    }
+
+    @Override
+    public BBox getBBox() {
+        return new BBox(this.lon, this.lat);
+    }
+
+    @Override
+    public OsmPrimitiveType getType() {
+        return OsmPrimitiveType.NODE;
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java b/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
new file mode 100644
index 000000000..17b5bef6f
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
@@ -0,0 +1,256 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.openstreetmap.josm.data.osm.AbstractPrimitive;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+import org.openstreetmap.josm.gui.mappaint.StyleCache;
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * The base class for Vector primitives
+ * @author Taylor Smock
+ * @since xxx
+ */
+public abstract class VectorPrimitive extends AbstractPrimitive implements DataLayer<String> {
+    private VectorDataSet dataSet;
+    private boolean highlighted;
+    private StyleCache mappaintStyle;
+    private final String layer;
+
+    /**
+     * Create a primitive for a specific vector layer
+     * @param layer The layer for the primitive
+     */
+    protected VectorPrimitive(String layer) {
+        this.layer = layer;
+        this.id = getIdGenerator().generateUniqueId();
+    }
+
+    @Override
+    protected void keysChangedImpl(Map<String, String> originalKeys) {
+        clearCachedStyle();
+        if (dataSet != null) {
+            for (IPrimitive ref : getReferrers()) {
+                ref.clearCachedStyle();
+            }
+        }
+    }
+
+    @Override
+    public boolean isHighlighted() {
+        return this.highlighted;
+    }
+
+    @Override
+    public void setHighlighted(boolean highlighted) {
+        this.highlighted = highlighted;
+    }
+
+    @Override
+    public boolean isTagged() {
+        return !this.getInterestingTags().isEmpty();
+    }
+
+    @Override
+    public boolean isAnnotated() {
+        return this.getInterestingTags().size() - this.getKeys().size() > 0;
+    }
+
+    @Override
+    public VectorDataSet getDataSet() {
+        return this.dataSet;
+    }
+
+    protected void setDataSet(VectorDataSet dataSet) {
+        this.dataSet = dataSet;
+    }
+
+    /*----------
+     * MAPPAINT
+     *--------*/
+    private short mappaintCacheIdx;
+
+    @Override
+    public final StyleCache getCachedStyle() {
+        return mappaintStyle;
+    }
+
+    @Override
+    public final void setCachedStyle(StyleCache mappaintStyle) {
+        this.mappaintStyle = mappaintStyle;
+    }
+
+    @Override
+    public final boolean isCachedStyleUpToDate() {
+        return mappaintStyle != null && mappaintCacheIdx == dataSet.getMappaintCacheIndex();
+    }
+
+    @Override
+    public final void declareCachedStyleUpToDate() {
+        this.mappaintCacheIdx = dataSet.getMappaintCacheIndex();
+    }
+
+    @Override
+    public boolean hasDirectionKeys() {
+        return false;
+    }
+
+    @Override
+    public boolean reversedDirection() {
+        return false;
+    }
+
+    /*------------
+     * Referrers
+     ------------*/
+    // Largely the same as OsmPrimitive, OsmPrimitive not modified at this time to avoid breaking binary compatibility
+
+    private Object referrers;
+
+    @Override
+    public final List<VectorPrimitive> getReferrers(boolean allowWithoutDataset) {
+        return referrers(allowWithoutDataset, VectorPrimitive.class)
+          .collect(Collectors.toList());
+    }
+
+    /**
+     * Add new referrer. If referrer is already included then no action is taken
+     * @param referrer The referrer to add
+     */
+    protected void addReferrer(IPrimitive referrer) {
+        if (referrers == null) {
+            referrers = referrer;
+        } else if (referrers instanceof IPrimitive) {
+            if (referrers != referrer) {
+                referrers = new IPrimitive[] {(IPrimitive) referrers, referrer};
+            }
+        } else {
+            for (IPrimitive primitive:(IPrimitive[]) referrers) {
+                if (primitive == referrer)
+                    return;
+            }
+            referrers = Utils.addInArrayCopy((IPrimitive[]) referrers, referrer);
+        }
+    }
+
+    /**
+     * Remove referrer. No action is taken if referrer is not registered
+     * @param referrer The referrer to remove
+     */
+    protected void removeReferrer(IPrimitive referrer) {
+        if (referrers instanceof IPrimitive) {
+            if (referrers == referrer) {
+                referrers = null;
+            }
+        } else if (referrers instanceof IPrimitive[]) {
+            IPrimitive[] orig = (IPrimitive[]) referrers;
+            int idx = IntStream.range(0, orig.length)
+              .filter(i -> orig[i] == referrer)
+              .findFirst().orElse(-1);
+            if (idx == -1)
+                return;
+
+            if (orig.length == 2) {
+                referrers = orig[1-idx]; // idx is either 0 or 1, take the other
+            } else { // downsize the array
+                IPrimitive[] smaller = new IPrimitive[orig.length-1];
+                System.arraycopy(orig, 0, smaller, 0, idx);
+                System.arraycopy(orig, idx+1, smaller, idx, smaller.length-idx);
+                referrers = smaller;
+            }
+        }
+    }
+
+    private <T extends IPrimitive> Stream<T> referrers(boolean allowWithoutDataset, Class<T> filter) {
+        // Returns only referrers that are members of the same dataset (primitive can have some fake references, for example
+        // when way is cloned
+
+        if (dataSet == null && !allowWithoutDataset) {
+            return Stream.empty();
+        }
+        if (referrers == null) {
+            return Stream.empty();
+        }
+        final Stream<IPrimitive> stream = referrers instanceof IPrimitive // NOPMD
+          ? Stream.of((IPrimitive) referrers)
+          : Arrays.stream((IPrimitive[]) referrers);
+        return stream
+          .filter(p -> p.getDataSet() == dataSet)
+          .filter(filter::isInstance)
+          .map(filter::cast);
+    }
+
+    /**
+     * Gets all primitives in the current dataset that reference this primitive.
+     * @param filter restrict primitives to subclasses
+     * @param <T> type of primitives
+     * @return the referrers as Stream
+     */
+    public final <T extends IPrimitive> Stream<T> referrers(Class<T> filter) {
+        return referrers(false, filter);
+    }
+
+    @Override
+    public void visitReferrers(PrimitiveVisitor visitor) {
+        if (visitor != null)
+            doVisitReferrers(o -> o.accept(visitor));
+    }
+
+    private void doVisitReferrers(Consumer<IPrimitive> visitor) {
+        if (this.referrers instanceof IPrimitive) {
+            IPrimitive ref = (IPrimitive) this.referrers;
+            if (ref.getDataSet() == dataSet) {
+                visitor.accept(ref);
+            }
+        } else if (this.referrers instanceof IPrimitive[]) {
+            IPrimitive[] refs = (IPrimitive[]) this.referrers;
+            for (IPrimitive ref: refs) {
+                if (ref.getDataSet() == dataSet) {
+                    visitor.accept(ref);
+                }
+            }
+        }
+    }
+
+    /**
+     * Set the id of the object
+     * @param id The id
+     */
+    protected void setId(long id) {
+        this.id = id;
+    }
+
+    /**
+     * Make this object disabled
+     * @param disabled {@code true} to disable the object
+     */
+    public void setDisabled(boolean disabled) {
+        this.updateFlags(FLAG_DISABLED, disabled);
+    }
+
+    /**
+     * Make this object visible
+     * @param visible {@code true} to make this object visible (default)
+     */
+    @Override
+    public void setVisible(boolean visible) {
+        this.updateFlags(FLAG_VISIBLE, visible);
+    }
+
+    /**************************
+     * Data layer information *
+     **************************/
+    @Override
+    public String getLayer() {
+        return this.layer;
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/vector/VectorRelation.java b/src/org/openstreetmap/josm/data/vector/VectorRelation.java
new file mode 100644
index 000000000..0deb57e57
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/vector/VectorRelation.java
@@ -0,0 +1,114 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.UniqueIdGenerator;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+
+/**
+ * The "Relation" type for vectors
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class VectorRelation extends VectorPrimitive implements IRelation<VectorRelationMember> {
+    private static final UniqueIdGenerator RELATION_ID_GENERATOR = new UniqueIdGenerator();
+    private final List<VectorRelationMember> members = new ArrayList<>();
+    private BBox cachedBBox;
+
+    /**
+     * Create a new relation for a layer
+     * @param layer The layer the relation will belong to
+     */
+    public VectorRelation(String layer) {
+        super(layer);
+    }
+
+    @Override
+    public UniqueIdGenerator getIdGenerator() {
+        return RELATION_ID_GENERATOR;
+    }
+
+    @Override
+    public void accept(PrimitiveVisitor visitor) {
+        visitor.visit(this);
+    }
+
+    @Override
+    public BBox getBBox() {
+        if (cachedBBox == null) {
+            cachedBBox = new BBox();
+            for (IPrimitive member : this.getMemberPrimitivesList()) {
+                cachedBBox.add(member.getBBox());
+            }
+        }
+        return cachedBBox;
+    }
+
+    protected void addRelationMember(VectorRelationMember member) {
+        this.members.add(member);
+        member.getMember().addReferrer(this);
+        cachedBBox = null;
+    }
+
+    /**
+     * Remove the first instance of a member from the relation
+     *
+     * @param member The member to remove
+     */
+    protected void removeRelationMember(VectorRelationMember member) {
+        this.members.remove(member);
+        if (!this.members.contains(member)) {
+            member.getMember().removeReferrer(this);
+        }
+    }
+
+    @Override
+    public int getMembersCount() {
+        return this.members.size();
+    }
+
+    @Override
+    public VectorRelationMember getMember(int index) {
+        return this.members.get(index);
+    }
+
+    @Override
+    public List<VectorRelationMember> getMembers() {
+        return Collections.unmodifiableList(this.members);
+    }
+
+    @Override
+    public void setMembers(List<VectorRelationMember> members) {
+        this.members.clear();
+        this.members.addAll(members);
+    }
+
+    @Override
+    public long getMemberId(int idx) {
+        return this.getMember(idx).getMember().getId();
+    }
+
+    @Override
+    public String getRole(int idx) {
+        return this.getMember(idx).getRole();
+    }
+
+    @Override
+    public OsmPrimitiveType getMemberType(int idx) {
+        return this.getMember(idx).getType();
+    }
+
+    @Override
+    public OsmPrimitiveType getType() {
+        return this.getMembers().stream().map(VectorRelationMember::getType)
+          .allMatch(OsmPrimitiveType.CLOSEDWAY::equals) ? OsmPrimitiveType.MULTIPOLYGON : OsmPrimitiveType.RELATION;
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/vector/VectorRelationMember.java b/src/org/openstreetmap/josm/data/vector/VectorRelationMember.java
new file mode 100644
index 000000000..56d6dfe77
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/vector/VectorRelationMember.java
@@ -0,0 +1,70 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.Optional;
+
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IRelationMember;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.tools.CheckParameterUtil;
+
+/**
+ * Relation members for a Vector Relation
+ */
+public class VectorRelationMember implements IRelationMember<VectorPrimitive> {
+    private final String role;
+    private final VectorPrimitive member;
+
+    /**
+     * Create a new relation member
+     * @param role The role of the member
+     * @param member The member primitive
+     */
+    public VectorRelationMember(String role, VectorPrimitive member) {
+        CheckParameterUtil.ensureParameterNotNull(member, "member");
+        this.role = Optional.ofNullable(role).orElse("").intern();
+        this.member = member;
+    }
+
+    @Override
+    public String getRole() {
+        return this.role;
+    }
+
+    @Override
+    public boolean isNode() {
+        return this.member instanceof INode;
+    }
+
+    @Override
+    public boolean isWay() {
+        return this.member instanceof IWay;
+    }
+
+    @Override
+    public boolean isRelation() {
+        return this.member instanceof IRelation;
+    }
+
+    @Override
+    public VectorPrimitive getMember() {
+        return this.member;
+    }
+
+    @Override
+    public long getUniqueId() {
+        return this.member.getId();
+    }
+
+    @Override
+    public OsmPrimitiveType getType() {
+        return this.member.getType();
+    }
+
+    @Override
+    public boolean isNew() {
+        return this.member.isNew();
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/vector/VectorWay.java b/src/org/openstreetmap/josm/data/vector/VectorWay.java
new file mode 100644
index 000000000..582fca2d4
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/vector/VectorWay.java
@@ -0,0 +1,132 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.UniqueIdGenerator;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+
+/**
+ * The "Way" type for a Vector layer
+ *
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class VectorWay extends VectorPrimitive implements IWay<VectorNode> {
+    private static final UniqueIdGenerator WAY_GENERATOR = new UniqueIdGenerator();
+    private final List<VectorNode> nodes = new ArrayList<>();
+    private BBox cachedBBox;
+
+    /**
+     * Create a new way for a layer
+     * @param layer The layer for the way
+     */
+    public VectorWay(String layer) {
+        super(layer);
+    }
+
+    @Override
+    public UniqueIdGenerator getIdGenerator() {
+        return WAY_GENERATOR;
+    }
+
+    @Override
+    public void accept(PrimitiveVisitor visitor) {
+        visitor.visit(this);
+    }
+
+    @Override
+    public BBox getBBox() {
+        if (cachedBBox == null) {
+            cachedBBox = new BBox();
+            for (INode node : this.getNodes()) {
+                cachedBBox.add(node.getBBox());
+            }
+        }
+        return cachedBBox;
+    }
+
+    @Override
+    public int getNodesCount() {
+        return this.getNodes().size();
+    }
+
+    @Override
+    public VectorNode getNode(int index) {
+        return this.getNodes().get(index);
+    }
+
+    @Override
+    public List<VectorNode> getNodes() {
+        return Collections.unmodifiableList(this.nodes);
+    }
+
+    @Override
+    public void setNodes(List<VectorNode> nodes) {
+        this.nodes.forEach(node -> node.removeReferrer(this));
+        this.nodes.clear();
+        nodes.forEach(node -> node.addReferrer(this));
+        this.nodes.addAll(nodes);
+        this.cachedBBox = null;
+    }
+
+    @Override
+    public List<Long> getNodeIds() {
+        return this.getNodes().stream().map(VectorNode::getId).collect(Collectors.toList());
+    }
+
+    @Override
+    public long getNodeId(int idx) {
+        return this.getNodes().get(idx).getId();
+    }
+
+    @Override
+    public boolean isClosed() {
+        return this.firstNode() != null && this.firstNode().equals(this.lastNode());
+    }
+
+    @Override
+    public VectorNode firstNode() {
+        if (this.nodes.isEmpty()) {
+            return null;
+        }
+        return this.getNode(0);
+    }
+
+    @Override
+    public VectorNode lastNode() {
+        if (this.nodes.isEmpty()) {
+            return null;
+        }
+        return this.getNode(this.getNodesCount() - 1);
+    }
+
+    @Override
+    public boolean isFirstLastNode(INode n) {
+        if (this.nodes.isEmpty()) {
+            return false;
+        }
+        return this.firstNode().equals(n) || this.lastNode().equals(n);
+    }
+
+    @Override
+    public boolean isInnerNode(INode n) {
+        if (this.nodes.isEmpty()) {
+            return false;
+        }
+        return !this.firstNode().equals(n) && !this.lastNode().equals(n) && this.nodes.stream()
+          .anyMatch(vectorNode -> vectorNode.equals(n));
+    }
+
+    @Override
+    public OsmPrimitiveType getType() {
+        return this.isClosed() ? OsmPrimitiveType.CLOSEDWAY : OsmPrimitiveType.WAY;
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
new file mode 100644
index 000000000..8983e8397
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
@@ -0,0 +1,141 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+import java.nio.file.Paths;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.openstreetmap.josm.TestUtils;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapBoxVectorCachedTileLoader;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorTileSource;
+import org.openstreetmap.josm.gui.layer.imagery.MVTLayer;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+import org.awaitility.Awaitility;
+import org.awaitility.Durations;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * A test for {@link VectorDataSet}
+ */
+class VectorDataSetTest {
+    /**
+     * Make some methods available for this test class
+     */
+    private static class MVTLayerMock extends MVTLayer {
+        private final Collection<MVTTile> finishedLoading = new HashSet<>();
+
+        MVTLayerMock(ImageryInfo info) {
+            super(info);
+        }
+
+        @Override
+        protected MapboxVectorTileSource getTileSource() {
+            return super.getTileSource();
+        }
+
+        protected MapBoxVectorCachedTileLoader getTileLoader() {
+            if (this.tileLoader == null) {
+                this.tileLoader = this.getTileLoaderFactory().makeTileLoader(this, Collections.emptyMap(), 7200);
+            }
+            if (this.tileLoader instanceof MapBoxVectorCachedTileLoader) {
+                return (MapBoxVectorCachedTileLoader) this.tileLoader;
+            }
+            return null;
+        }
+
+        @Override
+        public void finishedLoading(MVTTile tile) {
+            super.finishedLoading(tile);
+            this.finishedLoading.add(tile);
+        }
+
+        public Collection<MVTTile> finishedLoading() {
+            return this.finishedLoading;
+        }
+    }
+
+    @RegisterExtension
+    JOSMTestRules rule = new JOSMTestRules().projection();
+
+    /**
+     * Load arbitrary tiles
+     * @param layer The layer to add the tiles to
+     * @param tiles The tiles to load ([z, x, y, z, x, y, ...]) -- must be divisible by three
+     */
+    private static void loadTile(MVTLayerMock layer, int... tiles) {
+        if (tiles.length % 3 != 0 || tiles.length == 0) {
+            throw new IllegalArgumentException("Tiles come with a {z}, {x}, and {y} component");
+        }
+        final MapboxVectorTileSource tileSource = layer.getTileSource();
+        MapBoxVectorCachedTileLoader tileLoader = layer.getTileLoader();
+        Collection<MVTTile> tilesCollection = new ArrayList<>();
+        for (int i = 0; i < tiles.length / 3; i++) {
+            final MVTTile tile = (MVTTile) layer.createTile(tileSource, tiles[3 * i + 1], tiles[3 * i + 2], tiles[3 * i]);
+            tileLoader.createTileLoaderJob(tile).submit();
+            tilesCollection.add(tile);
+        }
+        Awaitility.await().atMost(Durations.FIVE_SECONDS).until(() -> layer.finishedLoading().size() == tilesCollection
+          .size());
+    }
+
+    private MVTLayerMock layer;
+
+    @BeforeEach
+    void setup() {
+        // Create the preconditions for the test
+        final ImageryInfo info = new ImageryInfo();
+        info.setName("en", "Test info");
+        info.setUrl("file:/" + Paths.get(TestUtils.getTestDataRoot(), "pbf", "mapillary", "{z}", "{x}", "{y}.mvt"));
+        layer = new MVTLayerMock(info);
+    }
+
+    @Test
+    void testNodeDeduplication() {
+        final VectorDataSet dataSet = this.layer.getData();
+        assertTrue(dataSet.allPrimitives().isEmpty());
+
+        // Set the zoom to 14, as that is the tile we are checking
+        dataSet.setZoom(14);
+        loadTile(this.layer, 14, 3248, 6258);
+
+        // There _does_ appear to be some kind of race condition though
+        Awaitility.await().atMost(Durations.FIVE_SECONDS).until(() -> dataSet.getNodes().size() > 50);
+        // Actual test
+        // With Mapillary, only ends of ways should be untagged
+        // There are 55 actual "nodes" in the data with two nodes for the ends of the way.
+        // One of the end nodes is a duplicate of an actual node.
+        assertEquals(56, dataSet.getNodes().size());
+        assertEquals(1, dataSet.getWays().size());
+        assertEquals(0, dataSet.getRelations().size());
+    }
+
+    @Test
+    void testWayDeduplicationSimple() {
+        final VectorDataSet dataSet = this.layer.getData();
+        assertTrue(dataSet.allPrimitives().isEmpty());
+
+        // Set the zoom to 14, as that is the tile we are checking
+        dataSet.setZoom(14);
+        // Load tiles that are next to each other
+        loadTile(this.layer, 14, 3248, 6258, 14, 3248, 6257);
+
+        Map<Long, List<VectorWay>> wayGroups = dataSet.getWays().stream()
+          .collect(Collectors.groupingBy(VectorWay::getId));
+        wayGroups.forEach((id, ways) -> assertEquals(1, ways.size(), MessageFormat.format("{0} was not deduplicated", id)));
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/vector/VectorNodeTest.java b/test/unit/org/openstreetmap/josm/data/vector/VectorNodeTest.java
new file mode 100644
index 000000000..834b46c4d
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/vector/VectorNodeTest.java
@@ -0,0 +1,153 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+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.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Test class for {@link VectorNode}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class VectorNodeTest {
+    @RegisterExtension
+    JOSMTestRules rule = new JOSMTestRules().projection();
+
+    @Test
+    void testLatLon() {
+        VectorNode node = new VectorNode("test");
+        assertTrue(Double.isNaN(node.lat()));
+        assertTrue(Double.isNaN(node.lon()));
+        LatLon testLatLon = new LatLon(50, -40);
+        node.setCoor(testLatLon);
+        assertEquals(50, node.lat());
+        assertEquals(-40, node.lon());
+        assertEquals(testLatLon, node.getCoor());
+    }
+
+    @Test
+    void testSetEastNorth() {
+        VectorNode node = new VectorNode("test");
+        LatLon latLon = new LatLon(-1, 5);
+        EastNorth eastNorth = ProjectionRegistry.getProjection().latlon2eastNorth(latLon);
+        node.setEastNorth(eastNorth);
+        assertEquals(-1, node.lat(), 0.0000000001);
+        assertEquals(5, node.lon(), 0.0000000001);
+    }
+
+    @Test
+    void testICoordinate() {
+        VectorNode node = new VectorNode("test");
+        assertTrue(Double.isNaN(node.lat()));
+        assertTrue(Double.isNaN(node.lon()));
+        ICoordinate coord = new ICoordinate() {
+            @Override
+            public double getLat() {
+                return 5;
+            }
+
+            @Override
+            public void setLat(double lat) {
+                // No op
+            }
+
+            @Override
+            public double getLon() {
+                return -1;
+            }
+
+            @Override
+            public void setLon(double lon) {
+                // no op
+            }
+        };
+        node.setCoor(coord);
+        assertEquals(5, node.lat());
+        assertEquals(-1, node.lon());
+    }
+
+    @Test
+    void testUniqueIdGenerator() {
+        VectorNode node1 = new VectorNode("test");
+        VectorNode node2 = new VectorNode("test2");
+        assertSame(node1.getIdGenerator(), node2.getIdGenerator());
+        assertNotNull(node1.getIdGenerator());
+    }
+
+    @Test
+    void testNode() {
+        assertEquals(OsmPrimitiveType.NODE, new VectorNode("test").getType());
+    }
+
+    @Test
+    void testBBox() {
+        VectorNode node = new VectorNode("test");
+        node.setCoor(new LatLon(5, -1));
+        assertTrue(node.getBBox().bboxIsFunctionallyEqual(new BBox(-1, 5), 0d));
+    }
+
+    @Test
+    void testVisitor() {
+        List<VectorNode> visited = new ArrayList<>();
+        VectorNode node = new VectorNode("test");
+        node.accept(new PrimitiveVisitor() {
+            @Override
+            public void visit(INode n) {
+                visited.add((VectorNode) n);
+            }
+
+            @Override
+            public void visit(IWay<?> w) {
+                fail("Way should not have been visited");
+            }
+
+            @Override
+            public void visit(IRelation<?> r) {
+                fail("Relation should not have been visited");
+            }
+        });
+
+        assertEquals(1, visited.size());
+        assertSame(node, visited.get(0));
+    }
+
+    @Test
+    void testIsReferredToByWays() {
+        VectorWay way = new VectorWay("test");
+        VectorNode node = new VectorNode("test");
+        assertFalse(node.isReferredByWays(1));
+        assertTrue(node.getReferrers(true).isEmpty());
+        way.setNodes(Collections.singletonList(node));
+        assertEquals(1, node.getReferrers(true).size());
+        assertSame(way, node.getReferrers(true).get(0));
+        // No dataset yet
+        assertFalse(node.isReferredByWays(1));
+        VectorDataSet dataSet = new VectorDataSet();
+        dataSet.addPrimitive(way);
+        dataSet.addPrimitive(node);
+        assertTrue(node.isReferredByWays(1));
+        assertFalse(node.isReferredByWays(2));
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/vector/VectorRelationTest.java b/test/unit/org/openstreetmap/josm/data/vector/VectorRelationTest.java
new file mode 100644
index 000000000..941143b25
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/vector/VectorRelationTest.java
@@ -0,0 +1,45 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.openstreetmap.josm.testutils.JOSMTestRules;
+
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test class for {@link VectorRelation}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class VectorRelationTest {
+    @RegisterExtension
+    JOSMTestRules rule = new JOSMTestRules();
+
+    @Test
+    void testMembers() {
+        VectorNode node1 = new VectorNode("test");
+        VectorNode node2 = new VectorNode("test");
+        VectorWay way1 = new VectorWay("test");
+        way1.setNodes(Arrays.asList(node1, node2));
+        VectorRelationMember member1 = new VectorRelationMember("randomRole", node1);
+        VectorRelationMember member2 = new VectorRelationMember("role2", way1);
+        assertSame(node1, member1.getMember());
+        assertSame(node1.getType(), member1.getType());
+        assertEquals("randomRole", member1.getRole());
+        assertSame(node1.getId(), member1.getUniqueId());
+        // Not a way.
+        assertThrows(ClassCastException.class, member1::getWay);
+
+        assertTrue(member1.isNode());
+        assertFalse(member1.isWay());
+        assertFalse(member2.isNode());
+        assertTrue(member2.isWay());
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/vector/VectorWayTest.java b/test/unit/org/openstreetmap/josm/data/vector/VectorWayTest.java
new file mode 100644
index 000000000..db2367e6b
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/vector/VectorWayTest.java
@@ -0,0 +1,117 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.vector;
+
+import org.junit.jupiter.api.Test;
+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.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
+import org.openstreetmap.josm.data.osm.visitor.PrimitiveVisitor;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Test class for {@link VectorWay}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class VectorWayTest {
+    @Test
+    void testBBox() {
+        VectorNode node1 = new VectorNode("test");
+        VectorWay way = new VectorWay("test");
+        way.setNodes(Collections.singletonList(node1));
+        node1.setCoor(new LatLon(-5, 1));
+        assertTrue(node1.getBBox().bboxIsFunctionallyEqual(way.getBBox(), 0.0));
+
+        VectorNode node2 = new VectorNode("test");
+        node2.setCoor(new LatLon(-10, 2));
+
+        way.setNodes(Arrays.asList(node1, node2));
+        assertTrue(way.getBBox().bboxIsFunctionallyEqual(new BBox(2, -10, 1, -5), 0.0));
+    }
+
+    @Test
+    void testIdGenerator() {
+        assertSame(new VectorWay("test").getIdGenerator(), new VectorWay("test").getIdGenerator());
+    }
+
+    @Test
+    void testNodes() {
+        VectorNode node1 = new VectorNode("test");
+        VectorNode node2 = new VectorNode("test");
+        VectorNode node3 = new VectorNode("test");
+        node1.setId(1);
+        node2.setId(2);
+        node3.setId(3);
+        VectorWay way = new VectorWay("test");
+        assertNull(way.firstNode());
+        assertNull(way.lastNode());
+        assertFalse(way.isClosed());
+        assertFalse(way.isFirstLastNode(node1));
+        assertFalse(way.isInnerNode(node2));
+        way.setNodes(Arrays.asList(node1, node2, node3));
+        assertEquals(3, way.getNodesCount());
+        assertEquals(node1, way.getNode(0));
+        assertEquals(node2, way.getNode(1));
+        assertEquals(node3, way.getNode(2));
+        assertTrue(way.isFirstLastNode(node1));
+        assertTrue(way.isFirstLastNode(node3));
+        assertFalse(way.isFirstLastNode(node2));
+        assertTrue(way.isInnerNode(node2));
+        assertFalse(way.isInnerNode(node1));
+        assertFalse(way.isInnerNode(node3));
+
+        assertEquals(1, way.getNodeIds().get(0));
+        assertEquals(2, way.getNodeIds().get(1));
+        assertEquals(3, way.getNodeIds().get(2));
+        assertEquals(1, way.getNodeId(0));
+        assertEquals(2, way.getNodeId(1));
+        assertEquals(3, way.getNodeId(2));
+
+        assertFalse(way.isClosed());
+        assertEquals(OsmPrimitiveType.WAY, way.getType());
+        List<VectorNode> nodes = new ArrayList<>(way.getNodes());
+        nodes.add(nodes.get(0));
+        way.setNodes(nodes);
+        assertTrue(way.isClosed());
+        assertEquals(OsmPrimitiveType.CLOSEDWAY, way.getType());
+    }
+
+    @Test
+    void testAccept() {
+        VectorWay way = new VectorWay("test");
+        List<VectorWay> visited = new ArrayList<>(1);
+        way.accept(new PrimitiveVisitor() {
+            @Override
+            public void visit(INode n) {
+                fail("No nodes should be visited");
+            }
+
+            @Override
+            public void visit(IWay<?> w) {
+                visited.add((VectorWay) w);
+            }
+
+            @Override
+            public void visit(IRelation<?> r) {
+                fail("No relations should be visited");
+            }
+        });
+
+        assertEquals(1, visited.size());
+        assertSame(way, visited.get(0));
+    }
+}
-- 
GitLab


From 60533498cd89f7b41b9e9822eb4b83ea55c60f7a Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 8 Apr 2021 16:45:20 -0600
Subject: [PATCH 06/50] Vector data test files (Mapillary)

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 test/data/pbf/mapillary/14/3248/6258.mvt         | Bin 0 -> 4256 bytes
 test/data/pbf/mapillary/14/3249/6258.mvt         | Bin 0 -> 8703 bytes
 .../josm/data/vector/VectorDataSetTest.java      |   2 +-
 3 files changed, 1 insertion(+), 1 deletion(-)
 create mode 100644 test/data/pbf/mapillary/14/3248/6258.mvt
 create mode 100644 test/data/pbf/mapillary/14/3249/6258.mvt

diff --git a/test/data/pbf/mapillary/14/3248/6258.mvt b/test/data/pbf/mapillary/14/3248/6258.mvt
new file mode 100644
index 0000000000000000000000000000000000000000..ff6a462a3d79d0250c190bf9700e52dd35011d3c
GIT binary patch
literal 4256
zcmaLaeT>`W9S89CdO%xV#!API^f-=iv;~?rvE$fj=}Vl%vE#hIC+%SzpYt9k@k?T7
zXsQtF1_F&qs7MTi#29pJswy;1jG=0qs;;YGtERoM0a{_uy)oFPPK>TXd!G12>_4{q
zBRbvZ>)-Er9zXVTaeRAb;|*QD-|TdF*>EZ1KoccFR5m@dcKyZIe$d;ram|{wYiDNG
zt(%>ln{jFz4j=fy^YS`o^YYyCoZw(La}LfS%qQpg1#W?v6P%ng>^SHwE-X8joy`2Q
zlUs@}3QOR32fvkf#^*UF>)@ORfwGR}rGp^D1C7rwfBFgjvXxmV?<nugBEcQW-3;ki
z*A@-OY!iN7r75Bef4N%V`>H03D;%#nX8XL<bIxtNaiy=VNQe(H#y-BOYA8Bx;MEQk
zp(F7lwB7HunSjUzl?0aVHb+oMPczY?y~&1zU_vGkNi{^ZHgaybY0aZoJsN&tNIGX0
z*K9w$sIPT=xUlj1>1s8*d>t&+g1J)HO;_rQE3Uk9Zmsi*4d2^;KQi^Jz)G%%EBUFv
zzV8W(Zvej0=9l)qXz{CoUt{y5dtbHqwZN~l`PsYATl_P?uebSM_FS^~XMu0B`9pgi
zL#N}v0r+N{KezjY#Xkr9Mw|cIf5zgU2R>)>i^K(s-vr!Y^T&t>ywm<$fY00fEdHd$
zZw9_#^9#O{7QY4f;;P>|xj#pI=Pm98ew*$3A7Ym*z6AVso4@OQ5}S^1EAVYLf7tu7
z#kT{$!{*1)H!XfA@Vjh&4*A65JAk`vegt{KH|@U@_%53t^SoklH*k;5&$!Q99088n
z{L-$&__V$kIA-&syPmbU4>)e~mv)}EI04*m^AB9-ExsH09-BYxIzmkQ-wk}P&0pAY
z%;Nii@3;Azcb&5M0pJ0fU%vB##Yx~no1eJzuz%V=1Uzi>b9X#raSC{3)y@5Nar-HY
z)4&<q^+&f~uy_<WYxA?)4)31!j{%R{{Qa%ZT08+fY4b<7p0;=jc-rQtZ-2+)8Q@u)
zzq@o~&$NFIcz)GapP!{;7B2uV+OB`;wo?`_0WaIG|DE$s7Owz5X!FarK6LlA{~_SZ
zw))3!ea_<d0OxFeZt<+e?*-1={GYeHZ}BQ{!RE(q`O4mD{}te(%}*~Jw|EVB-R75X
ze%0a);LTMx*YBB||84OW@V3p*%s;np+P?$5Yx9d+Ubnaeyl3+hTOQayy}l28u<GXd
zbl!2&;xcf>=0_ce4>)E7-hSGe$1yx|9kYt<P`-Ac@tvk>aEj2AMR0`v{G;q2zWGt_
zfb}#5M{bN`Svg4Z9#sz!Z57x3Wp^zA&)NTfi2skHdS-DJH24XNKNxry^a2h1!)B!4
z8-@BS?uwt(DJfL16#^ji_IUI0$j4@=7?KDq7R!tc5>XhI4wQ?*XdQ(1WB0twTsA`j
zACFh+O}cF0y)3VZL!_K+ra{Q{;!__!|Be|V@=<R_8-_8)Th-hGGNRIQ8iu}n<8OcV
z(Hmw+mrJc`yHU*JO1*|gR&p*E--iEZ%oeV5z4#+DWMGke%1Dx!*b${lCs>XR@+chm
z=;YImUEeoDQLNpP(DazDj%g%Nt`eG>XJE+q^H$ILV`gZ873w03rJ7t)47EhsC<(X#
zX9wnX&CJ(V+p!51RYXCjd+lJDELG%YSca8eTKLjm)AyMngc9SGVX30C_%K$kqxF8Q
zTZ6OP=2`!p`TNaKge;HyisEK@cQBrer{xHqF2Z)HM;@0pWzA4C;3I^zuhy%&<9*E-
zwgU)1hV8cOdTZ%Q#th~BK943D^;}}$Z`5U0$#Y?E9c+$uAb8swAAhk$m_f8wZ2Mhh
z)FY<pRxZt^<YGmF)!sYjeB<MS8PZFEmV!29m7x5-T#b-@Jr;%IdN6SBd*>)KM7Od-
zL#SnX#F)jh%(xTvNGzP;AOG{MfBYhBhLAvk2*k#Di3~TiVWXd-IWY)B@2A0PMa&T1
zA~nR9Otjb@k9rCoa>V6z*zVF7Td%15%}}+9jYmW%5$?K~mC!)(Bn=sXmEcBr>Mk>s
zYmM?^kfLdWN|#D`9H;AI9L{dCr?!|OnJJTW56xq7ArTA_DoPb2E*MHwuP^!CW+<x=
zQCc$wqoSS^O1K`&bAce-cu$^MJRyh7P<*7X&?>D+F|okWky_3x^{cSbSAYDk?S}+2
zREi4~1uX<{Bu~W{i6}H`ZUGMbS31~vi)M(a(+Mxx>jX+fA}ceVXjny)F!TlR=)Jwu
z3=O(%oJ5U8&_7N!n@yvp<~&~5ZgPWTW{5Kg#zXbhU{-9GYpIB<5Y}CA;FH_ZG(+t~
zJ~oz-kSiOC31q~D6UCAWEBU{BW_Xe{L%qlfH;DVRkwFpJdH_omwL%hBIs~VeGD9k9
zRP{cd;6vV$SfdCwG6>gU2tImm9Wq0q_@I=o7SqL|Ah{^EuXQvn4MVTJ3Vyt1D4JpF
z-9U-U=na3M(TH<CEHr|lPksp&DsP4~AywrE4Zlk*;sr(!+I%ppz;(I<4(fmzs@61p
zW!UWz*)Wdi9a1K%UN@W_oZQo-85-3ocXx#2L$AB9MEg<Po2rFirOE7em?4&@+8MFa
z(2QI(#PhL??4cMK+VlJ5Bab^*+fgMWmGXL%bPsDq2CZS)MWZk@xxpnf<j>YUv^!E|
zw2@L$DIp$fHgz~A_&TVpKAdtl?@6hnV4cAjCKHRdBo97-OE5VS@YQx<Br#0ZDxqi{
zNi@@uVb)#t!O-4~zxhTVGeabWv;u5*RO>g?U`bcJNJjR<F@fDQ-n`EYX;Mqf8+xjk
z@pb6f5FH{yhlOLB?D5t8)M79?qcUX-_ovE*VpfcgIU0uk{`_eczsamr7WEv3#!4YC
z-jU>rJZ$GmlXjDbbM-|=w7qV**{akB9>Y&Z+)9-!5wOxEw9TyKB7!+jk8k^siss9)
zqqavS!|+`IPma^;%+NScN%S;-TdaU*S4Ms15Xn=p(&SipL7E(AcoLQ3G}tKtywT>;
z6s9BI$*C}T5HvG1lni<7%>`PB3mFdMiL6)7YOqq{Yk#|M{{}OJa4e@MB9Tmo?F^fV
zq^?ME>(kIryOVa2Vi60(!3(T5r1X)N$%cJ&0S=r5uS{pp3{^?+H9AK57+zK?Za&+P
z(vdc-G})P#I+IF43iAn^9BU$KMX3t;cEnFLVI>6)>S{Ao!3*V)XB0%E^>NFSAG_py
KI$xiB<NhzTVpF>S

literal 0
HcmV?d00001

diff --git a/test/data/pbf/mapillary/14/3249/6258.mvt b/test/data/pbf/mapillary/14/3249/6258.mvt
new file mode 100644
index 0000000000000000000000000000000000000000..c5278577ec4e161a9cb5acf787c0a3ec9d5ea6d5
GIT binary patch
literal 8703
zcmaKyd)OOQnZVo93oTGefl@wd`C1BXY11Z?%S?J@a-Uo#llw($oFtRUWpbS)Gno}d
zEl{9Ts1*?r70a!5Yh~H4)@~QOwRW*8mI9)Pii(K9mdd)Q$nH6FKFORvcJoJ{?|Xi~
zdEYnZoO#bV-_wal&zpB?lke0diSLa}vd|p}t&$)wymiL;Z~x%i?S=EEO`AS_#*FjM
zn>lmwjOA}lpL^2{`)BQ&GkvxxXbP?muMb}1um-H2)fDO3=vq&>H@P?1ulGAb>!NEn
zPsXluFU?G5CL<Gt$->gm1V5RZ3@t9`zn4~kpH^(Uj+xxHDzo;3X7{R$EwjPBF18^;
zZC#h35}Tasq8k!hVw-4lluB)Kugy5<wF&T3Y{S-du`L^kh$rMDDR;mfv~Qu=t-<w_
zGe8FIfo0}38`f-CX<NT#rP=DTfZFUqGPq`w-A4vZn@Ep^^18emoY#lUDS}>}SYB8f
zUA%3gG!eb5FcDk4ZE<NLvZ%CV+mgtl!jkFJCeCXJqluX{`0t#7EcD<H7nJx;f6x=k
z1-?Hqv%|OA%NNgERPGGQEy6*JGzVYn53E+LN){zZN>Yngtc^~)!O%j%EwlDmv!+1e
zveejmz!t5x2zx0``?;#nuPDpsUODZqId^&Y4PDD;Oio*M)1*3m;>S14TQHugT9X&S
zJS|1jG=Drzvu4eny?FZaS##gp`uMCdp97}xd^nBo^QX7IYv6MMpEt!X(4T*B>xtRp
z=Pv~OBJBBdTXxSG^Z9^ZjPYYzUNrCpfM0^~2e#}!fBgJHz%Rx4Yny*+;Fke@ImQod
zeqiqS`9*+Vf$^Q2pEK~qfM1F6Gn?+4H-3Hs@T)L>@1_?Gd<ozm!1${hcU>@kektHr
zV|?$%rwn`&@MReP<%aDSj-Ou+_%#^cx8Z<+uK@g7jKA#sje%bW_)3i5<J^7GIQ}ZY
zS7ZDHb=1JG2Yk&GNAtgfdda}o0&c>dKSXYyKaRf+@bwr!=XlV-&461lexKu*ffImR
zF@DVcvVq$Gw`2Tm+q(wt0G!159^3sFkL#lVcVher>tO@m0Qkl!j=uk^#2EwM1o&p`
z`CEv$4SWmWTQR=hvS-0K{|$iC82_btzk#~|cVqk}^C<)O0Pe;3<Lh5Ba3A1)jGtb2
z%O&Id0l*oIziHZM;6cDc7=O@o%)rBdM=*YD?JEY(0v^TqZ`SNwIL^-j9-HEEy}#VI
zX1{?a08e84$n|FoJOy|fkH7k+OULmufM+qjWA#A;&jFst`0-UgG4Kxp{vnLNzH;Yf
z<M`VE|1ie)tUO`h1;9Uo@u#nQ+rW9iix_|J+5?x5<Cg$0WBlN?uNb%hcm?BUSL|6d
zeqIE;it+nboHFnl;B}0jyk^H0<L4WIOBmmE%^?GC0^Y*-(dB0iybX8<<F73{uy`E5
z3wRIX_bz+Qz-7Su7=Lc^zAMMi4*(xx{Fcen2Ce|EV*KFMyC%lZj{w&&{`%6x2L2C#
ze-z_;mYy~68v*|q#!r9X{;L*VjrQM*r+pmoo2FyJdrQt-wQv#%3#NTyIxs$o8;>rz
zYsuL76g0Nu#&cIaWiW1r#x1yU&sFajj9a0x12>LO9QwdG%WcrO9XEDNylOD+fW}VT
zIDF;)rQ;ZPLSq+hyt(+C!T2;Z?!t{ji}zhUj&V0McH_pmE6y2=&p=}jZXCGc$mBT2
zXQ6QqZoISTF9zdt(6|>j4lO#lY#ihB(6|pb{&e~7<zwRu(6}ErPF((!!T2IH9>9&A
zm+!u29OFyS_%d#sx$H%Q@fB!1h#L=Fws*xi##f=S7dPI$^o+rH2pSLL#?zPXx^^7n
z5okP$8+$K3X)qpx#y;G5cj3DR<7?2^j~k~J9=>jz<sYGO05|q7e9d5d9U2F5<Lo8-
zSB_(R0~(Lx#?ed88jOE}#uK=)=aNTPjbr>XG!Eg$OAF2!jDLZ~H*w?P1xHqoV|)u5
zhjHUC7r$dLz735dxN-dAqt}mP{3|q$;>OO4cdQv3{|1dGapR@=#|_4Jpm7X09-hBr
z?KsB2L*qDZynWG=2IITXIDs1nF8ZWt9OHY?IQe%2ZPc$`c*J1*2Q;4gyMeA62QGZY
zV0<4Mr*Pxt3+`Dr&hi6j{17*GUU1T2{0JIP<Hl3-UNaa!hQ?{!_|x2->&IE1fyNo!
zxM%J@gYhghp2Lk3=N~f|&qL!Uxbf<o7Y)V>(D*5CY@hQdgYh$Hyoei5%)Z?`uJq^7
zcnLSo%-U-(egTbN;>IUu9WfZcg2v0Zv3KSfgYj!<{029kJnv0|@mpxTf*a>%Y`2Un
z{T(!3#f@DvzHBgl4~^Gw<KXlomcMV63#R=68m}9TnRAP$4Z<5xcoP?9ylI)3QR4Bd
zH@cg{`}f358M~oh)E)3rtv@QrrFKsM_vr;$=MyLM^&5;=YjFP#g<NjQQ*mnvx6^K`
zF^$To*Hz%9`~Sbu{~!1E8Iv<Xg3<4<vz*@nxj+J?Q4TsY`Dm<QWf^OW3Wgl*WDH2n
z-~IKmw{Az$z;Dk>!w%&uGu~V=oa;qBf<FeTI1h+F`tQWAANnd1<AIh`57dhtvs#Wf
z)S_MLRu~aT>G`I|zxXvINd%n=aG?P$MznOyPg8Xz+@OK<y%2dD{SK1YVai&Oq-HwR
z^^<D8Qz_f?Jy;hk>-3ckz4&m{(<Ia8f~JYI)s<yDoZIGwQt-kVYi_+BNy$js6m$sH
zPBJLlSZ}uM$@t6gPZ)5-hj}Cg;svu*5m+@76!^Z?RZYvB7Z!eRYvHG!+>Ioq;~ud!
zzp9WW$wHbvQcAP8VLJHb{_`0mHNw6$7mv9`zB+W(Jh>Q4P%bEa=+npkYn3YLg_lx^
zPJpR&G>b-Rt&CNQc$^Vf)199K`3@qflM))Tn#o68b|+&hsshy|z}(Nha^G90BgL~w
zQV2_2j241iqvNf$#aLS%XhS%ddf|(jdOFeTZ4HVFpNZ2ZZ>Qp*hs;2OHT@zA5*|Pj
zWo0YLKs=dg)<()G8{##d)W6+EX#E9AvG`Dx**HzqA|tM=nl)$LNx*SB(*MljnQTi>
zS11kTs#d0bCc9a**J+aVWuh?Mja%&>K71pRM3t8d9nr^mvw>2q?B{ZJr3b&=kC*@K
z&mVgZNoH%ZGICjk21%8~R>oH5>a_%nbpP?mgT1wFJ)NiE%Z2Gc)1PET2kDBKB0N8Y
zHT_Dv?0}y^QZ>wxWYJ8e%$*w9_xDO3n_piltKqyPkrYdeLQyj<xT0QHL+(=celJ^q
zgDF?<e&eGel8Qt%)QiY<Hc=ZE>r@p)OR%seeF%0VDc2gvb+cS;t8Cg|i^m8qk*z{$
z8vNXaq;kVw4=aq#<Fw^LR8v3T@zr4AAlX*?$EG6DD$}E4v4Yp7wbIU3$k$^fSkpI`
z-u#DH7D?<-Bg&odut%6Hsyme_RD`+=2lH7Kyz4h1sg&-~{Z`*2^cXU#rg$ci>`G87
z!x=6h$>PzhLNioOWQ*Qf)!TK~z2OK<_k&a5^HC&qHJ;!Tb=l$xTjGiq%sA4N2n(;n
zF_@Ap{(h&`tkzjVvE>NbD+#n)h3Q`SA?O$}6e-`RSzP&aQg!f>$z|pgrZLnbodZjJ
z!FD7u)}lKfA60WMQ&Zt+Ic8F<J(%wMU-2ILqa8_6t!5t7TK$kF`K?Z`<TY2@1vuiC
z^Y<^wPa>&8$)p-4h=3FnYI3NRE(E(>DCu40Ewt!t)`(y7IbxP>SanH#mzT?xC@AR-
z<1~`O5*Kfmg+|{~P)+`7kDzS9m<U?pbRhovp)VYt+>S(7!J15kn>M294HJ1sqv~r7
zt8hT!lG?frNm{a)jMj#1pym#=X`i1pdj$?k`s!SZq+wcQQmR_vy!lEcYf=jB(I5n+
zWe0oJyZ?+{&@$^E<*QsvbH(^(FI?{r`a}_qx;|FlKvI}b^232Wku<SwF&=bu>VjWi
z!l2hZ^_9;fsnRy(%qFGI`TS}(CfgV{-)g~hXCFBcA~z$68VOc8qGftbt174!UM_J(
zCzSLzrj4YAFB@qGMlGV3WE8?Ax1$|VA9ejzcOfb6kOMufASY-~miDv$W;o=mz(~)4
zg^;}&No5P)j#fFfE!I=MZqF>3)9oN!32;Z-&rL-tcWQ1yrHN+Vn~M--yO^%T;rt!?
z<KNDxP9zcGPQx3uG3h8B8f8iyuI3U+D1GF&PcQ%3B}f9V66<JMdpy%2L+M0NELdVJ
ztV!RkHy|nRsPHrq3ze8)e%O_pk%7zFhtkhi%>Kk@H%>{apUY5%CL8hyxma^3hsi+(
z7S6$r??;k7S&4y-zUuBp99FZ&#3@3u!kSjXHJnD$KpK{{As?$Uv?zD8rh>wntT0_0
zPF@X3qiiyftAf#rni$?yiWXIKw*u3FZE?{HyO2bf+)T}u)2LjV6r!18VZel&Fr9ur
zIE5rjxGoGWRF0&gu3$cBl6s282iH>tejZ1XRg1(;-qMg^Rj*S`QX|^o(>w4z?mML4
z$w+D?yD_yxMMq*u86=ggJe2!cm=129kKB%=fG1TRIFvv?l8HBqY`ehJa(V~W`#}~-
zVJ*T?0dptF1cDVQpQ|XjWFOY_aoB&tND^qjP4PNxyk%e!T<s=TWYYRhr5{DMBB>It
z2h8b2fZ^p_W@N3!vo$6JBk4=!4Yb9o7LUMp<XkpD`5c})RZglJ3kOqQ<6lQoG1P4|
z6_2ypX;M93C^s;t{Q)>FaoDGCL{hVF8a5J2f=-tc)@a+t#{=CKjHF8uBqhuWT`5&9
zO|e^K9a^eVk5~1Ep&yUVP8~&jM9e<)2`<hOH<c|Tu2Zu*U^=~~=aFP{jxrn-$$>Ku
zA=PqaFUxu@?~nLVByn^pAo|o?qapa*X(n7{giabt`tErrlBz?qoA#wzTwjSZ8fEtm
zd=4*62U=@+<JXa7FFQ!3AqEK{EM^_;%uq1<G$`o}e=1Tqk*qcpN{N_?=|nTfCPz^*
z3MGBSV<?hF(_El#j(dGtv}E$O$u=7v!J72_<%X%6_>`EhI0o(@*KZbzqi}<d^r57m
z4VECO7pl~AK}PMF8<|3cbH(g3O~Z70>zR$Dve5GNB4sz99oB_pv>O<edU+U0f6W&m
zDJPLmcg-%@L7N*E6LO_AYU#UzUehF!Y$0nSUX2Q*7VO#`X35!zWJ55L-dLST3S~2j
zi%ty^DSJ0R$a#vis~U$j-S>I$VURSa<P}Bk6btdVj}}vwPNG!Tdx3r~uONvs)kJ!f
zHPzd>P=(3n*mm73z;ycIuZSdpY_>|SKtImA)Gpf?3`lQk0MmW=FmvII^+?J)g+iM5
z<XK9U*s`M$CEIrWY;YS58saPHOz!D>B(3XmSMBLaz~boBVKzR3k@T7-P$WhjmRt2q
zk+V0uTp<$>`UQ)A^3i+293+La9kRuZ+AYb|ETozaMjKWW@4r%0H3=p;(;qecETKi?
zt|-k%5*9a<!r+?lk<}<tHJb0aQ*_ByNjF-}K~-s{do@@SXgq&?Y#EXga*{FGf`xRs
zBADum%T0|&B^c=nxHMNHsqSiLYZ}FrWw}0ZH1j<v$=Bduz60(zU+^Hw+;xT$jY=Zh
zQ-X=0&z^`=R()maZ}4}K6b%&}RkGWY>r5(6DXnsh)AVgaIr^oE^%nG+^G*xX=)`(_
zq)4UeUOPz_dKH)sZsu>?g`{e|*=t(5?)<<z2*(`mP+o}yV1Lpt6O*Wkm82xW3@z4J
zs#OW+JWbxr`ipP~^ds92B)NPe(KHgRWwSQ$nIq;vqei6Q5Wq#S=6{eBX0lSL;mt<n
zQZ&j1J-jFv(olL3oU{IR13HFu$y#0$iCmsF$HWqo9JV5jHcY24x&ufuMZ{`_OvI=*
z39cG7ksS1k8Y~>{KR>(!oyJmZsZ6Wkpw%C1rHD$pK*pnHI0WD-k)Ho8Bxxb9YA^U~
zU0=T!lm}d;oQtMmB>h@)6iHploz^NXQjD~#^>##}2du20?)7nc9Z3m47xz`meQ&hU
zOWUX*mvssWSU9+E)Q?O_oRAJne8lg|vflon(@f_jO~01tXSyk=m*A6@QX^xP`Anb@
zjE}^APu~&rmH8}6M+#O`l;(1IpKNb~<6>DfrNi*sJ^$6t=6TN}sbA~+xu{y{G;@?t
zOnVyPbY0&O^saIiNkmfwou^GEOJc#=baL51cw~V?pdXE1K$7I?iX5G@4&p?T5Odj*
z*dEp2Ur(oy<m=k2_5o{&ID&48B}t{%BWO6mdb9ryl6u*`KT_vArVf#h`U*LBP?Yp(
z(Kn!zNOBZRik9<pVaneh3|qCDlFDRYP4ArnSB|N<OKC}ynvQVEe%@M*D!e<Lcc`!?
h{aW%96sZ=<g~4A7N~XRy=OLW^ysCt)ivE8J{uhw)>!Sbw

literal 0
HcmV?d00001

diff --git a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
index 8983e8397..38ab53ad2 100644
--- a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
+++ b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
@@ -132,7 +132,7 @@ class VectorDataSetTest {
         // Set the zoom to 14, as that is the tile we are checking
         dataSet.setZoom(14);
         // Load tiles that are next to each other
-        loadTile(this.layer, 14, 3248, 6258, 14, 3248, 6257);
+        loadTile(this.layer, 14, 3248, 6258, 14, 3249, 6258);
 
         Map<Long, List<VectorWay>> wayGroups = dataSet.getWays().stream()
           .collect(Collectors.groupingBy(VectorWay::getId));
-- 
GitLab


From 25dfa842314b093a39bb34169c82fa95b72938b7 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 8 Apr 2021 15:56:47 -0600
Subject: [PATCH 07/50] Other frontend items

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 resources/images/dialogs/add_mvt.svg          | 147 +++++++++
 .../data/cache/JCSCachedTileLoaderJob.java    |  47 ++-
 .../josm/data/imagery/ImageryInfo.java        |   8 +-
 .../data/imagery/TMSCachedTileLoaderJob.java  |  10 +-
 .../josm/data/osm/IRelationMember.java        |   9 +
 .../josm/data/osm/IWaySegment.java            | 177 +++++++++++
 .../josm/data/osm/RelationMember.java         |   1 +
 .../josm/data/osm/WaySegment.java             |  99 +------
 .../osm/visitor/paint/StyledMapRenderer.java  |   7 +-
 .../relation/sort/RelationNodeMap.java        |  73 ++---
 .../relation/sort/RelationSortUtils.java      |  32 +-
 .../dialogs/relation/sort/RelationSorter.java |  10 +-
 .../gui/layer/AbstractTileSourceLayer.java    |  30 +-
 .../josm/gui/layer/ImageryLayer.java          |   3 +
 .../josm/gui/layer/imagery/MVTLayer.java      | 278 ++++++++++++++++++
 .../gui/mappaint/mapcss/ConditionFactory.java |  11 +
 .../preferences/imagery/AddMVTLayerPanel.java |  94 ++++++
 .../imagery/ImageryProvidersPanel.java        |  11 +-
 18 files changed, 891 insertions(+), 156 deletions(-)
 create mode 100644 resources/images/dialogs/add_mvt.svg
 create mode 100644 src/org/openstreetmap/josm/data/osm/IWaySegment.java
 create mode 100644 src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
 create mode 100644 src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java

diff --git a/resources/images/dialogs/add_mvt.svg b/resources/images/dialogs/add_mvt.svg
new file mode 100644
index 000000000..eeae80f10
--- /dev/null
+++ b/resources/images/dialogs/add_mvt.svg
@@ -0,0 +1,147 @@
+<?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="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>
diff --git a/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java b/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
index a8561a771..eeac761c6 100644
--- a/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
+++ b/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
@@ -1,10 +1,13 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.cache;
 
+import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.io.InputStream;
 import java.net.HttpURLConnection;
 import java.net.URL;
+import java.nio.file.Files;
 import java.security.SecureRandom;
 import java.util.Collections;
 import java.util.List;
@@ -17,8 +20,6 @@ import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.util.regex.Matcher;
 
-import org.apache.commons.jcs3.access.behavior.ICacheAccess;
-import org.apache.commons.jcs3.engine.behavior.ICacheElement;
 import org.openstreetmap.josm.data.cache.ICachedLoaderListener.LoadResult;
 import org.openstreetmap.josm.data.imagery.TileJobOptions;
 import org.openstreetmap.josm.data.preferences.IntegerProperty;
@@ -27,6 +28,10 @@ import org.openstreetmap.josm.tools.HttpClient;
 import org.openstreetmap.josm.tools.Logging;
 import org.openstreetmap.josm.tools.Utils;
 
+import org.apache.commons.compress.utils.IOUtils;
+import org.apache.commons.jcs3.access.behavior.ICacheAccess;
+import org.apache.commons.jcs3.engine.behavior.ICacheElement;
+
 /**
  * Generic loader for HTTP based tiles. Uses custom attribute, to check, if entry has expired
  * according to HTTP headers sent with tile. If so, it tries to verify using Etags
@@ -294,6 +299,43 @@ public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements
         if (attributes == null) {
             attributes = new CacheEntryAttributes();
         }
+        final URL url = this.getUrlNoException();
+        if (url == null) {
+            return false;
+        }
+
+        if (url.getProtocol().contains("http")) {
+            return loadObjectHttp();
+        }
+        if (url.getProtocol().contains("file")) {
+            return loadObjectFile(url);
+        }
+
+        return false;
+    }
+
+    private boolean loadObjectFile(URL url) {
+        String fileName = url.toExternalForm();
+        File file = new File(fileName.substring("file:/".length() - 1));
+        if (!file.exists()) {
+            file = new File(fileName.substring("file://".length() - 1));
+        }
+        try (InputStream fileInputStream = Files.newInputStream(file.toPath())) {
+            cacheData = createCacheEntry(IOUtils.toByteArray(fileInputStream));
+            cache.put(getCacheKey(), cacheData, attributes);
+            return true;
+        } catch (IOException e) {
+            Logging.error(e);
+            attributes.setError(e);
+            attributes.setException(e);
+        }
+        return false;
+    }
+
+    /**
+     * @return true if object was successfully downloaded via http, false, if there was a loading failure
+     */
+    private boolean loadObjectHttp() {
         try {
             // if we have object in cache, and host doesn't support If-Modified-Since nor If-None-Match
             // then just use HEAD request and check returned values
@@ -553,6 +595,7 @@ public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements
         try {
             return getUrl();
         } catch (IOException e) {
+            Logging.trace(e);
             return null;
         }
     }
diff --git a/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java b/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java
index 07cabc76a..32b1055ed 100644
--- a/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java
+++ b/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java
@@ -61,7 +61,9 @@ public class ImageryInfo extends
         /** 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 @@ public class ImageryInfo extends
         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 @@ public class ImageryInfo extends
         }
 
         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)));
             }
diff --git a/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java b/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java
index e9f163781..1f2c0d1d7 100644
--- a/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java
+++ b/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java
@@ -10,6 +10,7 @@ import java.net.URL;
 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;
@@ -33,6 +34,8 @@ import org.openstreetmap.josm.data.cache.CacheEntry;
 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;
@@ -149,7 +152,7 @@ public class TMSCachedTileLoaderJob extends JCSCachedTileLoaderJob<String, Buffe
     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;
@@ -320,10 +323,11 @@ public class TMSCachedTileLoaderJob extends JCSCachedTileLoaderJob<String, Buffe
     private boolean tryLoadTileImage(CacheEntry object) throws IOException {
         if (object != null) {
             byte[] content = object.getContent();
-            if (content.length > 0) {
+            if (content.length > 0 || tile instanceof VectorTile) {
                 try (ByteArrayInputStream in = new ByteArrayInputStream(content)) {
                     tile.loadImage(in);
-                    if (tile.getImage() == null) {
+                    if ((!(tile instanceof VectorTile) && tile.getImage() == null)
+                        || ((tile instanceof VectorTile) && !tile.isLoaded())) {
                         String s = new String(content, StandardCharsets.UTF_8);
                         Matcher m = SERVICE_EXCEPTION_PATTERN.matcher(s);
                         if (m.matches()) {
diff --git a/src/org/openstreetmap/josm/data/osm/IRelationMember.java b/src/org/openstreetmap/josm/data/osm/IRelationMember.java
index c2803e38d..69091056d 100644
--- a/src/org/openstreetmap/josm/data/osm/IRelationMember.java
+++ b/src/org/openstreetmap/josm/data/osm/IRelationMember.java
@@ -66,4 +66,13 @@ public interface IRelationMember<P extends IPrimitive> extends PrimitiveId {
      * @since 13766 (IRelationMember)
      */
     P getMember();
+
+    /**
+     * Returns the relation member as a way.
+     * @return Member as a way
+     * @since xxx
+     */
+    default IWay<?> getWay() {
+        return (IWay<?>) getMember();
+    }
 }
diff --git a/src/org/openstreetmap/josm/data/osm/IWaySegment.java b/src/org/openstreetmap/josm/data/osm/IWaySegment.java
new file mode 100644
index 000000000..0735935de
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/osm/IWaySegment.java
@@ -0,0 +1,177 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm;
+
+import java.awt.geom.Line2D;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A segment consisting of 2 consecutive nodes out of a way.
+ * @author Taylor Smock
+ * @param <N> The node type
+ * @param <W> The way type
+ * @since xxx
+ */
+public class IWaySegment<N extends INode, W extends IWay<N>> implements Comparable<IWaySegment<N, W>> {
+
+    /**
+     * The way.
+     */
+    public final W way;
+
+    /**
+     * The index of one of the 2 nodes in the way.  The other node has the
+     * index <code>lowerIndex + 1</code>.
+     */
+    public final int lowerIndex;
+
+    /**
+     * Constructs a new {@code IWaySegment}.
+     * @param w The way
+     * @param i The node lower index
+     * @throws IllegalArgumentException in case of invalid index
+     */
+    public IWaySegment(W w, int i) {
+        way = w;
+        lowerIndex = i;
+        if (i < 0 || i >= w.getNodesCount() - 1) {
+            throw new IllegalArgumentException(toString());
+        }
+    }
+
+    /**
+     * Returns the first node of the way segment.
+     * @return the first node
+     */
+    public N getFirstNode() {
+        return way.getNode(lowerIndex);
+    }
+
+    /**
+     * Returns the second (last) node of the way segment.
+     * @return the second node
+     */
+    public N getSecondNode() {
+        return way.getNode(lowerIndex + 1);
+    }
+
+    /**
+     * Determines and returns the way segment for the given way and node pair.
+     * @param way way
+     * @param first first node
+     * @param second second node
+     * @return way segment
+     * @throws IllegalArgumentException if the node pair is not part of way
+     */
+    public static <N extends INode, W extends IWay<N>> IWaySegment<N, W> forNodePair(W way, N first, N second) {
+        int endIndex = way.getNodesCount() - 1;
+        while (endIndex > 0) {
+            final int indexOfFirst = way.getNodes().subList(0, endIndex).lastIndexOf(first);
+            if (second.equals(way.getNode(indexOfFirst + 1))) {
+                return new IWaySegment<>(way, indexOfFirst);
+            }
+            endIndex--;
+        }
+        throw new IllegalArgumentException("Node pair is not part of way!");
+    }
+
+    /**
+     * Returns this way segment as complete way.
+     * @return the way segment as {@code Way}
+     * @throws IllegalAccessException See {@link Constructor#newInstance}
+     * @throws IllegalArgumentException See {@link Constructor#newInstance}
+     * @throws InstantiationException See {@link Constructor#newInstance}
+     * @throws InvocationTargetException See {@link Constructor#newInstance}
+     * @throws NoSuchMethodException See {@link Class#getConstructor}
+     */
+    public W toWay()
+      throws IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
+        // If the number of nodes is 2, then don't bother creating a new way
+        if (this.way.getNodes().size() == 2) {
+            return this.way;
+        }
+        // Since the way determines the generic class, this.way.getClass() is always Class<W>, assuming
+        // that way remains the defining element for the type, and remains final.
+        @SuppressWarnings("unchecked")
+        Class<W> clazz = (Class<W>) this.way.getClass();
+        Constructor<W> constructor;
+        W w;
+        try {
+            // Check for clone constructor
+            constructor = clazz.getConstructor(clazz);
+            w = constructor.newInstance(this.way);
+        } catch (NoSuchMethodException e) {
+            Logging.trace(e);
+            constructor = clazz.getConstructor();
+            w = constructor.newInstance();
+        }
+
+        w.setNodes(Arrays.asList(getFirstNode(), getSecondNode()));
+        return w;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        IWaySegment<?, ?> that = (IWaySegment<?, ?>) o;
+        return lowerIndex == that.lowerIndex &&
+          Objects.equals(way, that.way);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(way, lowerIndex);
+    }
+
+    @Override
+    public int compareTo(IWaySegment o) {
+        final W thisWay;
+        final IWay<?> otherWay;
+        try {
+            thisWay = toWay();
+            otherWay = o == null ? null : o.toWay();
+        } catch (ReflectiveOperationException e) {
+            Logging.error(e);
+            return -1;
+        }
+        return o == null ? -1 : (equals(o) ? 0 : thisWay.compareTo(otherWay));
+    }
+
+    /**
+     * Checks whether this segment crosses other segment
+     *
+     * @param s2 The other segment
+     * @return true if both segments crosses
+     */
+    public boolean intersects(IWaySegment<?, ?> s2) {
+        if (getFirstNode().equals(s2.getFirstNode()) || getSecondNode().equals(s2.getSecondNode()) ||
+          getFirstNode().equals(s2.getSecondNode()) || getSecondNode().equals(s2.getFirstNode()))
+            return false;
+
+        return Line2D.linesIntersect(
+          getFirstNode().getEastNorth().east(), getFirstNode().getEastNorth().north(),
+          getSecondNode().getEastNorth().east(), getSecondNode().getEastNorth().north(),
+          s2.getFirstNode().getEastNorth().east(), s2.getFirstNode().getEastNorth().north(),
+          s2.getSecondNode().getEastNorth().east(), s2.getSecondNode().getEastNorth().north());
+    }
+
+    /**
+     * Checks whether this segment and another way segment share the same points
+     * @param s2 The other segment
+     * @return true if other way segment is the same or reverse
+     */
+    public boolean isSimilar(IWaySegment<?, ?> s2) {
+        return (getFirstNode().equals(s2.getFirstNode()) && getSecondNode().equals(s2.getSecondNode()))
+          || (getFirstNode().equals(s2.getSecondNode()) && getSecondNode().equals(s2.getFirstNode()));
+    }
+
+    @Override
+    public String toString() {
+        return "IWaySegment [way=" + way.getUniqueId() + ", lowerIndex=" + lowerIndex + ']';
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/osm/RelationMember.java b/src/org/openstreetmap/josm/data/osm/RelationMember.java
index fc62c71f3..5add40403 100644
--- a/src/org/openstreetmap/josm/data/osm/RelationMember.java
+++ b/src/org/openstreetmap/josm/data/osm/RelationMember.java
@@ -57,6 +57,7 @@ public class RelationMember implements IRelationMember<OsmPrimitive> {
      * @return Member as way
      * @since 1937
      */
+    @Override
     public Way getWay() {
         return (Way) member;
     }
diff --git a/src/org/openstreetmap/josm/data/osm/WaySegment.java b/src/org/openstreetmap/josm/data/osm/WaySegment.java
index 2ca1cc379..302f82842 100644
--- a/src/org/openstreetmap/josm/data/osm/WaySegment.java
+++ b/src/org/openstreetmap/josm/data/osm/WaySegment.java
@@ -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());
-        }
+    public WaySegment(Way way, int i) {
+        super(way, i);
     }
 
     /**
-     * Returns the first node of the way segment.
-     * @return the first node
-     */
-    public Node getFirstNode() {
-        return way.getNode(lowerIndex);
-    }
-
-    /**
-     * Returns the second (last) node of the way segment.
-     * @return the second node
-     */
-    public Node getSecondNode() {
-        return way.getNode(lowerIndex + 1);
-    }
-
-    /**
-     * Determines and returns the way segment for the given way and node pair.
+     * 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 @@ public final class WaySegment implements Comparable<WaySegment> {
      * 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 @@ public final class WaySegment implements Comparable<WaySegment> {
         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 + ']';
diff --git a/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledMapRenderer.java b/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledMapRenderer.java
index 038374233..03ba0e5b2 100644
--- a/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledMapRenderer.java
+++ b/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledMapRenderer.java
@@ -36,6 +36,7 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.ForkJoinPool;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
 import java.util.function.BiConsumer;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
@@ -1637,13 +1638,13 @@ public class StyledMapRenderer extends AbstractMapRenderer {
         RenderBenchmarkCollector benchmark = benchmarkFactory.get();
         BBox bbox = bounds.toBBox();
         getSettings(renderVirtualNodes);
-
         try {
-            if (data.getReadLock().tryLock(1, TimeUnit.SECONDS)) {
+            Lock readLock = data.getReadLock();
+            if (readLock.tryLock(1, TimeUnit.SECONDS)) {
                 try {
                     paintWithLock(data, renderVirtualNodes, benchmark, bbox);
                 } finally {
-                    data.getReadLock().unlock();
+                    readLock.unlock();
                 }
             } else {
                 Logging.warn("Cannot paint layer {0}: It is locked.");
diff --git a/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationNodeMap.java b/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationNodeMap.java
index 0ac990275..1b768e6fb 100644
--- a/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationNodeMap.java
+++ b/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationNodeMap.java
@@ -10,9 +10,10 @@ import java.util.Set;
 import java.util.TreeMap;
 import java.util.TreeSet;
 
-import org.openstreetmap.josm.data.osm.Node;
-import org.openstreetmap.josm.data.osm.RelationMember;
-import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelationMember;
+import org.openstreetmap.josm.data.osm.IWay;
 
 /**
  * Auxiliary class for relation sorting.
@@ -26,15 +27,16 @@ import org.openstreetmap.josm.data.osm.Way;
  * (that are shared by other members).
  *
  * @author Christiaan Welvaart &lt;cjw@time4t.net&gt;
- * @since 1785
+ * @param <T> The type of {@link IRelationMember}
+ * @since 1785, xxx (generics)
  */
-public class RelationNodeMap {
+public class RelationNodeMap<T extends IRelationMember<? extends IPrimitive>> {
 
     private static final String ROLE_BACKWARD = "backward";
 
     private static class NodesWays {
-        public final Map<Node, Set<Integer>> nodes = new TreeMap<>();
-        public final Map<Integer, Set<Node>> ways = new TreeMap<>();
+        public final Map<INode, Set<Integer>> nodes = new TreeMap<>();
+        public final Map<Integer, Set<INode>> ways = new TreeMap<>();
         public final boolean oneWay;
 
         NodesWays(boolean oneWay) {
@@ -56,7 +58,7 @@ public class RelationNodeMap {
      * Used to keep track of what members are done.
      */
     private final Set<Integer> remaining = new TreeSet<>();
-    private final Map<Integer, Set<Node>> remainingOneway = new TreeMap<>();
+    private final Map<Integer, Set<INode>> remainingOneway = new TreeMap<>();
 
     /**
      * All members that are incomplete or not a way
@@ -67,8 +69,9 @@ public class RelationNodeMap {
      * Gets the start node of the member, respecting the direction role.
      * @param m The relation member.
      * @return <code>null</code> if the member is no way, the node otherwise.
+     * @since xxx (generics)
      */
-    public static Node firstOnewayNode(RelationMember m) {
+    public static INode firstOnewayNode(IRelationMember<?> m) {
         if (!m.isWay()) return null;
         if (ROLE_BACKWARD.equals(m.getRole())) {
             return m.getWay().lastNode();
@@ -81,7 +84,7 @@ public class RelationNodeMap {
      * @param m The relation member.
      * @return <code>null</code> if the member is no way, the node otherwise.
      */
-    public static Node lastOnewayNode(RelationMember m) {
+    public static INode lastOnewayNode(IRelationMember<?> m) {
         if (!m.isWay()) return null;
         if (ROLE_BACKWARD.equals(m.getRole())) {
             return m.getWay().firstNode();
@@ -89,17 +92,17 @@ public class RelationNodeMap {
         return m.getWay().lastNode();
     }
 
-    RelationNodeMap(List<RelationMember> members) {
+    RelationNodeMap(List<T> members) {
         for (int i = 0; i < members.size(); ++i) {
-            RelationMember m = members.get(i);
+            T m = members.get(i);
             if (m.getMember().isIncomplete() || !m.isWay() || m.getWay().getNodesCount() < 2) {
                 notSortable.add(i);
                 continue;
             }
 
-            Way w = m.getWay();
+            IWay<?> w = m.getWay();
             if (RelationSortUtils.roundaboutType(w) != NONE) {
-                for (Node nd : w.getNodes()) {
+                for (INode nd : w.getNodes()) {
                     addPair(nd, i);
                 }
             } else if (RelationSortUtils.isOneway(m)) {
@@ -118,34 +121,34 @@ public class RelationNodeMap {
         remaining.addAll(map.ways.keySet());
     }
 
-    private void addPair(Node n, int i) {
+    private void addPair(INode n, int i) {
         map.nodes.computeIfAbsent(n, k -> new TreeSet<>()).add(i);
         map.ways.computeIfAbsent(i, k -> new TreeSet<>()).add(n);
     }
 
-    private void addNodeWayMap(Node n, int i) {
+    private void addNodeWayMap(INode n, int i) {
         onewayMap.nodes.computeIfAbsent(n, k -> new TreeSet<>()).add(i);
     }
 
-    private void addWayNodeMap(Node n, int i) {
+    private void addWayNodeMap(INode n, int i) {
         onewayMap.ways.computeIfAbsent(i, k -> new TreeSet<>()).add(n);
     }
 
-    private void addNodeWayMapReverse(Node n, int i) {
+    private void addNodeWayMapReverse(INode n, int i) {
         onewayReverseMap.nodes.computeIfAbsent(n, k -> new TreeSet<>()).add(i);
     }
 
-    private void addWayNodeMapReverse(Node n, int i) {
+    private void addWayNodeMapReverse(INode n, int i) {
         onewayReverseMap.ways.computeIfAbsent(i, k -> new TreeSet<>()).add(n);
     }
 
-    private void addRemainingForward(Node n, int i) {
+    private void addRemainingForward(INode n, int i) {
         remainingOneway.computeIfAbsent(i, k -> new TreeSet<>()).add(n);
     }
 
     private Integer firstOneway;
-    private Node lastOnewayNode;
-    private Node firstCircular;
+    private INode lastOnewayNode;
+    private INode firstCircular;
 
     /**
      * Return a relation member that is linked to the member 'i', but has not been popped yet.
@@ -158,7 +161,7 @@ public class RelationNodeMap {
         if (firstOneway != null) return popForwardOnewayPart(way);
 
         if (map.ways.containsKey(way)) {
-            for (Node n : map.ways.get(way)) {
+            for (INode n : map.ways.get(way)) {
                 Integer i = deleteAndGetAdjacentNode(map, n);
                 if (i != null) return i;
 
@@ -176,7 +179,7 @@ public class RelationNodeMap {
 
     private Integer popForwardOnewayPart(Integer way) {
         if (onewayMap.ways.containsKey(way)) {
-            Node exitNode = onewayMap.ways.get(way).iterator().next();
+            INode exitNode = onewayMap.ways.get(way).iterator().next();
 
             if (checkIfEndOfLoopReached(exitNode)) {
                 lastOnewayNode = exitNode;
@@ -201,7 +204,7 @@ public class RelationNodeMap {
     // Check if the given node can be the end of the loop (i.e. it has
     // an outgoing bidirectional or multiple outgoing oneways, or we
     // looped back to our first circular node)
-    private boolean checkIfEndOfLoopReached(Node n) {
+    private boolean checkIfEndOfLoopReached(INode n) {
         return map.nodes.containsKey(n)
                 || (onewayMap.nodes.containsKey(n) && (onewayMap.nodes.get(n).size() > 1))
                 || ((firstCircular != null) && (firstCircular == n));
@@ -209,14 +212,14 @@ public class RelationNodeMap {
 
     private Integer popBackwardOnewayPart(int way) {
         if (lastOnewayNode != null) {
-            Set<Node> nodes = new TreeSet<>();
+            Set<INode> nodes = new TreeSet<>();
             if (onewayReverseMap.ways.containsKey(way)) {
                 nodes.addAll(onewayReverseMap.ways.get(way));
             }
             if (map.ways.containsKey(way)) {
                 nodes.addAll(map.ways.get(way));
             }
-            for (Node n : nodes) {
+            for (INode n : nodes) {
                 if (n == lastOnewayNode) { //if oneway part ends
                     firstOneway = null;
                     lastOnewayNode = null;
@@ -247,20 +250,20 @@ public class RelationNodeMap {
      * @param n node
      * @return node next to n
      */
-    private Integer deleteAndGetAdjacentNode(NodesWays nw, Node n) {
+    private Integer deleteAndGetAdjacentNode(NodesWays nw, INode n) {
         Integer j = findAdjacentWay(nw, n);
         if (j == null) return null;
         deleteWayNode(nw, j, n);
         return j;
     }
 
-    private static Integer findAdjacentWay(NodesWays nw, Node n) {
+    private static Integer findAdjacentWay(NodesWays nw, INode n) {
         Set<Integer> adj = nw.nodes.get(n);
         if (adj == null || adj.isEmpty()) return null;
         return adj.iterator().next();
     }
 
-    private void deleteWayNode(NodesWays nw, Integer way, Node n) {
+    private void deleteWayNode(NodesWays nw, Integer way, INode n) {
         if (nw.oneWay) {
             doneOneway(way);
         } else {
@@ -285,7 +288,7 @@ public class RelationNodeMap {
 
         if (remainingOneway.isEmpty()) return null;
         for (Integer i : remainingOneway.keySet()) { //find oneway, which is connected to more than one way (is between two oneway loops)
-            for (Node n : onewayReverseMap.ways.get(i)) {
+            for (INode n : onewayReverseMap.ways.get(i)) {
                 if (onewayReverseMap.nodes.containsKey(n) && onewayReverseMap.nodes.get(n).size() > 1) {
                     doneOneway(i);
                     firstCircular = n;
@@ -305,8 +308,8 @@ public class RelationNodeMap {
      * @param i member key
      */
     private void doneOneway(Integer i) {
-        Set<Node> nodesForward = remainingOneway.get(i);
-        for (Node n : nodesForward) {
+        Set<INode> nodesForward = remainingOneway.get(i);
+        for (INode n : nodesForward) {
             if (onewayMap.nodes.containsKey(n)) {
                 onewayMap.nodes.get(n).remove(i);
             }
@@ -319,8 +322,8 @@ public class RelationNodeMap {
 
     private void done(Integer i) {
         remaining.remove(i);
-        Set<Node> nodes = map.ways.get(i);
-        for (Node n : nodes) {
+        Set<INode> nodes = map.ways.get(i);
+        for (INode n : nodes) {
             boolean result = map.nodes.get(n).remove(i);
             if (!result) throw new AssertionError();
         }
diff --git a/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSortUtils.java b/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSortUtils.java
index d7457f7f1..70023011d 100644
--- a/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSortUtils.java
+++ b/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSortUtils.java
@@ -6,9 +6,9 @@ import static org.openstreetmap.josm.gui.dialogs.relation.sort.WayConnectionType
 import static org.openstreetmap.josm.gui.dialogs.relation.sort.WayConnectionType.Direction.ROUNDABOUT_RIGHT;
 
 import org.openstreetmap.josm.data.coor.EastNorth;
-import org.openstreetmap.josm.data.osm.Node;
-import org.openstreetmap.josm.data.osm.RelationMember;
-import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IRelationMember;
+import org.openstreetmap.josm.data.osm.IWay;
 import org.openstreetmap.josm.gui.dialogs.relation.sort.WayConnectionType.Direction;
 
 /**
@@ -24,19 +24,27 @@ final class RelationSortUtils {
      * determine, if the way i is a roundabout and if yes, what type of roundabout
      * @param member relation member
      * @return roundabout type
+     * @since xxx (generics)
      */
-    static Direction roundaboutType(RelationMember member) {
+    static Direction roundaboutType(IRelationMember<?> member) {
         if (member == null || !member.isWay()) return NONE;
-        return roundaboutType(member.getWay());
+        return roundaboutType((IWay<?>) member.getWay());
     }
 
-    static Direction roundaboutType(Way w) {
+    /**
+     * Check if a way is a roundabout type
+     * @param w The way to check
+     * @param <W> The way type
+     * @return The roundabout type
+     * @since xxx (generics)
+     */
+    static <W extends IWay<?>> Direction roundaboutType(W w) {
         if (w != null && w.hasTag("junction", "circular", "roundabout")) {
             int nodesCount = w.getNodesCount();
             if (nodesCount > 2 && nodesCount < 200) {
-                Node n1 = w.getNode(0);
-                Node n2 = w.getNode(1);
-                Node n3 = w.getNode(2);
+                INode n1 = w.getNode(0);
+                INode n2 = w.getNode(1);
+                INode n3 = w.getNode(2);
                 if (n1 != null && n2 != null && n3 != null && w.isClosed()) {
                     /** do some simple determinant / cross product test on the first 3 nodes
                         to see, if the roundabout goes clock wise or ccw */
@@ -54,15 +62,15 @@ final class RelationSortUtils {
         return NONE;
     }
 
-    static boolean isBackward(final RelationMember member) {
+    static boolean isBackward(final IRelationMember<?> member) {
         return "backward".equals(member.getRole());
     }
 
-    static boolean isForward(final RelationMember member) {
+    static boolean isForward(final IRelationMember<?> member) {
         return "forward".equals(member.getRole());
     }
 
-    static boolean isOneway(final RelationMember member) {
+    static boolean isOneway(final IRelationMember<?> member) {
         return isForward(member) || isBackward(member);
     }
 }
diff --git a/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSorter.java b/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSorter.java
index 12713094a..34d6bdf4f 100644
--- a/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSorter.java
+++ b/src/org/openstreetmap/josm/gui/dialogs/relation/sort/RelationSorter.java
@@ -15,6 +15,8 @@ import java.util.Objects;
 import java.util.stream.Collectors;
 
 import org.openstreetmap.josm.data.osm.DefaultNameFormatter;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelationMember;
 import org.openstreetmap.josm.data.osm.OsmPrimitive;
 import org.openstreetmap.josm.data.osm.Relation;
 import org.openstreetmap.josm.data.osm.RelationMember;
@@ -194,12 +196,12 @@ public class RelationSorter {
      * Sorts a list of members by connectivity
      * @param defaultMembers The members to sort
      * @return A sorted list of the same members
+     * @since xxx (signature change, generics)
      */
-    public static List<RelationMember> sortMembersByConnectivity(List<RelationMember> defaultMembers) {
+    public static <T extends IRelationMember<? extends IPrimitive>> List<T> sortMembersByConnectivity(List<T> defaultMembers) {
+        List<T> newMembers;
 
-        List<RelationMember> newMembers;
-
-        RelationNodeMap map = new RelationNodeMap(defaultMembers);
+        RelationNodeMap<T> map = new RelationNodeMap<>(defaultMembers);
         // List of groups of linked members
         //
         List<LinkedList<Integer>> allGroups = new ArrayList<>();
diff --git a/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java b/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
index 4dcb27fd5..c7331faba 100644
--- a/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
+++ b/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
@@ -86,6 +86,7 @@ import org.openstreetmap.josm.data.imagery.ImageryInfo;
 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;
@@ -109,6 +110,7 @@ import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings.FilterChan
 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;
@@ -888,7 +890,7 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
             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);
         }
@@ -1041,7 +1043,7 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
                     img = getLoadedTileImage(tile);
                     anchorImage = getAnchor(tile, img);
                 }
-                if (img == null || anchorImage == null) {
+                if (img == null || anchorImage == null || (tile instanceof VectorTile && !tile.isLoaded())) {
                     miss = true;
                 }
             }
@@ -1050,7 +1052,9 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
                 return;
             }
 
-            img = applyImageProcessors(img);
+            if (img != null) {
+                img = applyImageProcessors(img);
+            }
 
             TileAnchor anchorScreen = coordinateConverter.getScreenAnchorForTile(tile);
             synchronized (paintMutex) {
@@ -1862,7 +1866,7 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
 
                 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));
                     }
                 }
             }
@@ -1968,6 +1972,20 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
         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();
@@ -1988,6 +2006,10 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
             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();
diff --git a/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java b/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java
index 6c902b92a..933933da3 100644
--- a/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java
+++ b/src/org/openstreetmap/josm/gui/layer/ImageryLayer.java
@@ -37,6 +37,7 @@ import org.openstreetmap.josm.gui.MainApplication;
 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 @@ public abstract class ImageryLayer extends Layer {
         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()));
         }
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
index 000000000..aa335f7b0
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
@@ -0,0 +1,278 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Component;
+import java.awt.Graphics2D;
+import java.awt.event.ActionEvent;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.BooleanSupplier;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import javax.swing.AbstractAction;
+import javax.swing.Action;
+import javax.swing.JCheckBoxMenuItem;
+import javax.swing.JMenuItem;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTFile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile.TileListener;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapBoxVectorCachedTileLoader;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorTileSource;
+import org.openstreetmap.josm.data.osm.DataSet;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.data.osm.Relation;
+import org.openstreetmap.josm.data.osm.RelationMember;
+import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.data.osm.visitor.paint.AbstractMapRenderer;
+import org.openstreetmap.josm.data.osm.visitor.paint.MapRendererFactory;
+import org.openstreetmap.josm.data.osm.visitor.paint.StyledMapRenderer;
+import org.openstreetmap.josm.data.vector.VectorDataSet;
+import org.openstreetmap.josm.data.vector.VectorNode;
+import org.openstreetmap.josm.data.vector.VectorPrimitive;
+import org.openstreetmap.josm.data.vector.VectorRelation;
+import org.openstreetmap.josm.data.vector.VectorWay;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.layer.AbstractCachedTileSourceLayer;
+import org.openstreetmap.josm.gui.layer.LayerManager;
+import org.openstreetmap.josm.gui.layer.OsmDataLayer;
+import org.openstreetmap.josm.gui.mappaint.ElemStyles;
+import org.openstreetmap.josm.gui.mappaint.StyleSource;
+
+/**
+ * A layer for MapBox Vector Tiles
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSource> implements TileListener {
+    private static final String CACHE_REGION_NAME = "MVT";
+    // Just to avoid allocating a bunch of 0 length action arrays
+    private static final Action[] EMPTY_ACTIONS = new Action[0];
+    private final Map<String, Boolean> layerNames = new HashMap<>();
+    private final VectorDataSet dataSet = new VectorDataSet();
+
+    /**
+     * Creates an instance of an MVT layer
+     *
+     * @param info ImageryInfo describing the layer
+     */
+    public MVTLayer(ImageryInfo info) {
+        super(info);
+    }
+
+    @Override
+    protected Class<? extends TileLoader> getTileLoaderClass() {
+        return MapBoxVectorCachedTileLoader.class;
+    }
+
+    @Override
+    protected String getCacheName() {
+        return CACHE_REGION_NAME;
+    }
+
+    @Override
+    public Collection<String> getNativeProjections() {
+        // MapBox Vector Tiles <i>specifically</i> only support EPSG:3857
+        // ("it is exclusively geared towards square pixel tiles in {link to EPSG:3857}").
+        return Collections.singleton(MVTFile.DEFAULT_PROJECTION);
+    }
+
+    @Override
+    public void paint(Graphics2D g, MapView mv, Bounds box) {
+        this.dataSet.setZoom(this.getZoomLevel());
+        AbstractMapRenderer painter = MapRendererFactory.getInstance().createActiveRenderer(g, mv, false);
+        painter.enableSlowOperations(mv.getMapMover() == null || !mv.getMapMover().movementInProgress()
+          || !OsmDataLayer.PROPERTY_HIDE_LABELS_WHILE_DRAGGING.get());
+        // Set the painter to use our custom style sheet
+        if (painter instanceof StyledMapRenderer && this.dataSet.getStyles() != null) {
+            ((StyledMapRenderer) painter).setStyles(this.dataSet.getStyles());
+        }
+        painter.render(this.dataSet, false, box);
+    }
+
+    @Override
+    protected MapboxVectorTileSource getTileSource() {
+        MapboxVectorTileSource source = new MapboxVectorTileSource(this.info);
+        this.info.setAttribution(source);
+        if (source.getStyleSource() != null) {
+            List<ElemStyles> styles = source.getStyleSource().getSources().entrySet().stream()
+              .filter(entry -> entry.getKey() == null || entry.getKey().getUrls().contains(source.getBaseUrl()))
+              .map(Map.Entry::getValue).collect(Collectors.toList());
+            // load the style sources
+            styles.stream().map(ElemStyles::getStyleSources).flatMap(Collection::stream).forEach(StyleSource::loadStyleSource);
+            this.dataSet.setStyles(styles);
+            this.setName(source.getName());
+        }
+        return source;
+    }
+
+    @Override
+    public Tile createTile(MapboxVectorTileSource source, int x, int y, int zoom) {
+        final MVTTile tile = new MVTTile(source, x, y, zoom);
+        tile.addTileLoaderFinisher(this);
+        return tile;
+    }
+
+    @Override
+    public Action[] getMenuEntries() {
+        ArrayList<Action> actions = new ArrayList<>(Arrays.asList(super.getMenuEntries()));
+        // Add separator between Info and the layers
+        actions.add(SeparatorLayerAction.INSTANCE);
+        for (Map.Entry<String, Boolean> layerConfig : layerNames.entrySet()) {
+            actions.add(new EnableLayerAction(layerConfig.getKey(), () -> layerNames.computeIfAbsent(layerConfig.getKey(), key -> true),
+                    layer -> {
+                layerNames.compute(layer, (key, value) -> Boolean.FALSE.equals(value));
+                this.invalidate();
+            }));
+        }
+        // Add separator between layers and convert action
+        actions.add(SeparatorLayerAction.INSTANCE);
+        actions.add(new ConvertLayerAction(this));
+        return actions.toArray(EMPTY_ACTIONS);
+    }
+
+    /**
+     * Get the data set for this layer
+     */
+    public VectorDataSet getData() {
+        return this.dataSet;
+    }
+    
+    private static class ConvertLayerAction extends AbstractAction implements LayerAction {
+        private final MVTLayer layer;
+
+        ConvertLayerAction(MVTLayer layer) {
+            this.layer = layer;
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            LayerManager manager = MainApplication.getLayerManager();
+            VectorDataSet dataSet = layer.getData();
+            DataSet osmData = new DataSet();
+            // Add nodes first, map is to ensure we can map new nodes to vector nodes
+            Map<VectorNode, Node> nodeMap = new HashMap<>(dataSet.getNodes().size());
+            for (VectorNode vectorNode : dataSet.getNodes()) {
+                Node newNode = new Node(vectorNode.getCoor());
+                if (vectorNode.isTagged()) {
+                    vectorNode.getInterestingTags().forEach(newNode::put);
+                }
+                nodeMap.put(vectorNode, newNode);
+            }
+            // Add ways next
+            Map<VectorWay, Way> wayMap = new HashMap<>(dataSet.getWays().size());
+            for (VectorWay vectorWay : dataSet.getWays()) {
+                Way newWay = new Way();
+                List<Node> nodes = vectorWay.getNodes().stream().map(nodeMap::get).filter(Objects::nonNull).collect(Collectors.toList());
+                newWay.setNodes(nodes);
+                if (vectorWay.isTagged()) {
+                    vectorWay.getInterestingTags().forEach(newWay::put);
+                }
+                wayMap.put(vectorWay, newWay);
+            }
+
+            // Finally, add Relations
+            Map<VectorRelation, Relation> relationMap = new HashMap<>(dataSet.getRelations().size());
+            for (VectorRelation vectorRelation : dataSet.getRelations()) {
+                Relation relation = new Relation();
+                if (vectorRelation.isTagged()) {
+                    vectorRelation.getInterestingTags().forEach(relation::put);
+                }
+                List<RelationMember> members = vectorRelation.getMembers().stream().map(member -> {
+                    final OsmPrimitive primitive;
+                    final VectorPrimitive vectorPrimitive = member.getMember();
+                    if (vectorPrimitive instanceof VectorNode) {
+                        primitive = nodeMap.get(vectorPrimitive);
+                    } else if (vectorPrimitive instanceof VectorWay) {
+                        primitive = wayMap.get(vectorPrimitive);
+                    } else if (vectorPrimitive instanceof VectorRelation) {
+                        // Hopefully, relations are encountered in order...
+                        primitive = relationMap.get(vectorPrimitive);
+                    } else {
+                        primitive = null;
+                    }
+                    if (primitive == null) return null;
+                    return new RelationMember(member.getRole(), primitive);
+                }).filter(Objects::nonNull).collect(Collectors.toList());
+                relation.setMembers(members);
+                relationMap.put(vectorRelation, relation);
+            }
+            try {
+                osmData.beginUpdate();
+                nodeMap.values().forEach(osmData::addPrimitive);
+                wayMap.values().forEach(osmData::addPrimitive);
+                relationMap.values().forEach(osmData::addPrimitive);
+            } finally {
+                osmData.endUpdate();
+            }
+            manager.addLayer(new OsmDataLayer(osmData, this.layer.getName(), null));
+            manager.removeLayer(this.layer);
+        }
+
+        @Override
+        public boolean supportLayers(List<org.openstreetmap.josm.gui.layer.Layer> layers) {
+            return layers.stream().allMatch(MVTLayer.class::isInstance);
+        }
+
+        @Override
+        public Component createMenuComponent() {
+            JMenuItem menuItem = new JMenuItem(tr("Convert to OSM Data"));
+            menuItem.addActionListener(this);
+            return menuItem;
+        }
+    }
+
+    private static class EnableLayerAction extends AbstractAction implements LayerAction {
+        private final String layer;
+        private final Consumer<String> consumer;
+        private final BooleanSupplier state;
+
+        EnableLayerAction(String layer, BooleanSupplier state, Consumer<String> consumer) {
+            super(tr("Toggle layer {0}", layer));
+            this.layer = layer;
+            this.consumer = consumer;
+            this.state = state;
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            consumer.accept(layer);
+        }
+
+        @Override
+        public boolean supportLayers(List<org.openstreetmap.josm.gui.layer.Layer> layers) {
+            return layers.stream().allMatch(MVTLayer.class::isInstance);
+        }
+
+        @Override
+        public Component createMenuComponent() {
+            JCheckBoxMenuItem item = new JCheckBoxMenuItem(this);
+            item.setSelected(this.state.getAsBoolean());
+            return item;
+        }
+    }
+
+    @Override
+    public void finishedLoading(MVTTile tile) {
+        for (Layer layer : tile.getLayers()) {
+            this.layerNames.putIfAbsent(layer.getName(), true);
+        }
+        this.dataSet.addTileData(tile);
+    }
+}
diff --git a/src/org/openstreetmap/josm/gui/mappaint/mapcss/ConditionFactory.java b/src/org/openstreetmap/josm/gui/mappaint/mapcss/ConditionFactory.java
index d195742d8..a97573ecc 100644
--- a/src/org/openstreetmap/josm/gui/mappaint/mapcss/ConditionFactory.java
+++ b/src/org/openstreetmap/josm/gui/mappaint/mapcss/ConditionFactory.java
@@ -870,6 +870,17 @@ public final class ConditionFactory {
             }
             return e.osm.isSelected();
         }
+
+        /**
+         * Check if the object is highlighted (i.e., is hovered over)
+         * @param e The MapCSS environment
+         * @return {@code true} if the object is highlighted
+         * @see IPrimitive#isHighlighted
+         * @since xxx
+         */
+        static boolean highlighted(Environment e) { // NO_UCD (unused code)
+            return e.osm.isHighlighted();
+        }
     }
 
     /**
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
index 000000000..99bbd058d
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java
@@ -0,0 +1,94 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.preferences.imagery;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+import java.util.Arrays;
+
+import javax.swing.JLabel;
+
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
+import org.openstreetmap.josm.gui.widgets.JosmTextArea;
+import org.openstreetmap.josm.gui.widgets.JosmTextField;
+import org.openstreetmap.josm.tools.GBC;
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * A panel for adding MapBox Vector Tile layers
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class AddMVTLayerPanel extends AddImageryPanel {
+    private final JosmTextField mvtZoom = new JosmTextField();
+    private final JosmTextArea mvtUrl = new JosmTextArea(3, 40).transferFocusOnTab();
+
+    /**
+     * Constructs a new {@code AddMVTLayerPanel}.
+     */
+    public AddMVTLayerPanel() {
+
+        add(new JLabel(tr("{0} Make sure OSM has the permission to use this service", "1.")), GBC.eol());
+        add(new JLabel(tr("{0} Enter URL (may be a style sheet url)", "2.")), GBC.eol());
+        add(new JLabel("<html>" + Utils.joinAsHtmlUnorderedList(Arrays.asList(
+                tr("{0} is replaced by tile zoom level, also supported:<br>" +
+                        "offsets to the zoom level: {1} or {2}<br>" +
+                        "reversed zoom level: {3}", "{zoom}", "{zoom+1}", "{zoom-1}", "{19-zoom}"),
+                tr("{0} is replaced by X-coordinate of the tile", "{x}"),
+                tr("{0} is replaced by Y-coordinate of the tile", "{y}"),
+                tr("{0} is replaced by a random selection from the given comma separated list, e.g. {1}", "{switch:...}", "{switch:a,b,c}")
+        )) + "</html>"), GBC.eol().fill());
+
+        final KeyAdapter keyAdapter = new KeyAdapter() {
+            @Override
+            public void keyReleased(KeyEvent e) {
+                mvtUrl.setText(buildMvtUrl());
+            }
+        };
+
+        add(rawUrl, GBC.eop().fill());
+        rawUrl.setLineWrap(true);
+        rawUrl.addKeyListener(keyAdapter);
+
+        add(new JLabel(tr("{0} Enter maximum zoom (optional)", "3.")), GBC.eol());
+        mvtZoom.addKeyListener(keyAdapter);
+        add(mvtZoom, GBC.eop().fill());
+
+        add(new JLabel(tr("{0} Edit generated {1} URL (optional)", "4.", "MVT")), GBC.eol());
+        add(mvtUrl, GBC.eop().fill());
+        mvtUrl.setLineWrap(true);
+
+        add(new JLabel(tr("{0} Enter name for this layer", "5.")), GBC.eol());
+        add(name, GBC.eop().fill());
+
+        registerValidableComponent(mvtUrl);
+    }
+
+    private String buildMvtUrl() {
+        StringBuilder a = new StringBuilder("mvt");
+        String z = sanitize(mvtZoom.getText());
+        if (!z.isEmpty()) {
+            a.append('[').append(z).append(']');
+        }
+        a.append(':').append(sanitize(getImageryRawUrl(), ImageryType.MVT));
+        return a.toString();
+    }
+
+    @Override
+    public ImageryInfo getImageryInfo() {
+        final ImageryInfo generated = new ImageryInfo(getImageryName(), getMvtUrl());
+        generated.setImageryType(ImageryType.MVT);
+        return generated;
+    }
+
+    protected final String getMvtUrl() {
+        return sanitize(mvtUrl.getText());
+    }
+
+    @Override
+    protected boolean isImageryValid() {
+        return !getImageryName().isEmpty() && !getMvtUrl().isEmpty();
+    }
+}
diff --git a/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java b/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java
index d1d0ed096..cb78f9bda 100644
--- a/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java
+++ b/src/org/openstreetmap/josm/gui/preferences/imagery/ImageryProvidersPanel.java
@@ -312,6 +312,7 @@ public class ImageryProvidersPanel extends JPanel {
         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));
@@ -440,6 +441,9 @@ public class ImageryProvidersPanel extends JPanel {
             case WMTS:
                 icon = /* ICON(dialogs/) */ "add_wmts";
                 break;
+            case MVT:
+                icon = /* ICON(dialogs/) */ "add_mvt";
+                break;
             default:
                 break;
             }
@@ -460,6 +464,9 @@ public class ImageryProvidersPanel extends JPanel {
             case WMTS:
                 p = new AddWMTSLayerPanel();
                 break;
+            case MVT:
+                p = new AddMVTLayerPanel();
+                break;
             default:
                 throw new IllegalStateException("Type " + type + " not supported");
             }
@@ -741,7 +748,7 @@ public class ImageryProvidersPanel extends JPanel {
     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 @@ public class ImageryProvidersPanel extends JPanel {
                 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);
-- 
GitLab


From 1f1b2e09776ce71c88845240a32d6532b8b58c56 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Fri, 16 Apr 2021 06:25:45 -0600
Subject: [PATCH 08/50] MVT: Update maps.xsd for mvt support

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 resources/data/maps.xsd | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/resources/data/maps.xsd b/resources/data/maps.xsd
index 0c7022370..ce5a1e88b 100644
--- a/resources/data/maps.xsd
+++ b/resources/data/maps.xsd
@@ -38,6 +38,7 @@
             <xs:enumeration value="wms_endpoint" />
             <xs:enumeration value="wmts" />
             <xs:enumeration value="tms" />
+            <xs:enumeration value="mvt" />
             <xs:enumeration value="bing" />
             <xs:enumeration value="scanex" />
         </xs:restriction>
@@ -647,7 +648,7 @@
                             <xs:element name="id" minOccurs="1" maxOccurs="1" type="tns:id" />
                             <!-- Historic id for the imagery source -->
                             <xs:element name="oldid" minOccurs="0" maxOccurs="unbounded" type="tns:oldid" />
-                            <!-- The type. Can be tms, wms and html. In addition, there are the special types bing and scanex 
+                            <!-- The type. Can be mvt, tms, wms and html. In addition, there are the special types bing and scanex
                                 with hardcoded behaviour. -->
                             <xs:element name="type" minOccurs="1" maxOccurs="1" type="tns:type" />
                             <!-- To define as default server for this type -->
-- 
GitLab


From 0c314b387c48828fe7c3e90bb29be80de74585c2 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Fri, 16 Apr 2021 07:03:43 -0600
Subject: [PATCH 09/50] MVT: Catch known exception

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../mapbox/InvalidMapboxVectorTileException.java   |  2 +-
 .../vectortile/mapbox/style/MapBoxVectorStyle.java | 14 +++++++++++++-
 .../imagery/vectortile/mapbox/style/Source.java    |  3 ++-
 3 files changed, 16 insertions(+), 3 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java
index d1186ad3f..bd47fe65f 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/InvalidMapboxVectorTileException.java
@@ -16,7 +16,7 @@ public class InvalidMapboxVectorTileException extends RuntimeException {
     }
 
     /**
-     * Create a new {@link InvalidMapboxVectorTile} exception with a message
+     * Create a new {@link InvalidMapboxVectorTileException} exception with a message
      * @param message The message
      */
     public InvalidMapboxVectorTileException(final String message) {
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java
index 746913042..ec24ee5cf 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java
@@ -29,6 +29,7 @@ import javax.json.JsonReader;
 import javax.json.JsonStructure;
 import javax.json.JsonValue;
 
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.InvalidMapboxVectorTileException;
 import org.openstreetmap.josm.data.preferences.JosmBaseDirectories;
 import org.openstreetmap.josm.gui.MainApplication;
 import org.openstreetmap.josm.gui.mappaint.ElemStyles;
@@ -100,7 +101,18 @@ public class MapBoxVectorStyle {
             if (jsonObject.containsKey("sources") && jsonObject.get("sources").getValueType() == JsonValue.ValueType.OBJECT) {
                 final JsonObject sourceObj = jsonObject.getJsonObject("sources");
                 sourceList = sourceObj.entrySet().stream().filter(entry -> entry.getValue().getValueType() == JsonValue.ValueType.OBJECT)
-                  .map(entry -> new Source(entry.getKey(), entry.getValue().asJsonObject())).collect(Collectors.toList());
+                  .map(entry -> {
+                      try {
+                          return new Source(entry.getKey(), entry.getValue().asJsonObject());
+                      } catch (InvalidMapboxVectorTileException e) {
+                          Logging.error(e);
+                          // Reraise if not a known exception
+                          if (!"TileJson not yet supported".equals(e.getMessage())) {
+                              throw e;
+                          }
+                      }
+                      return null;
+                  }).filter(Objects::nonNull).collect(Collectors.toList());
             } else {
                 sourceList = Collections.emptyList();
             }
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java
index dc7c62d62..dd41da72f 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java
@@ -17,6 +17,7 @@ import javax.json.JsonString;
 import javax.json.JsonValue;
 
 import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.InvalidMapboxVectorTileException;
 
 /**
  * A source from a MapBox Vector Style
@@ -128,7 +129,7 @@ public class Source {
         if (SourceType.VECTOR == this.sourceType || SourceType.RASTER == this.sourceType) {
             if (data.containsKey("url")) {
                 // TODO implement https://github.com/mapbox/tilejson-spec
-                throw new UnsupportedOperationException();
+                throw new InvalidMapboxVectorTileException("TileJson not yet supported");
             } else {
                 this.minZoom = ZOOM_BOUND_FUNCTION.apply(data.getInt("minzoom", 0));
                 this.maxZoom = ZOOM_BOUND_FUNCTION.apply(data.getInt("maxzoom", 22));
-- 
GitLab


From 80afe5c40c8c9d9a99b9b336b99f769a8bd09b8e Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Fri, 16 Apr 2021 07:10:18 -0600
Subject: [PATCH 10/50] Update highlighted mapcss stanza for modified mapcss
 function registration

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/gui/mappaint/mapcss/ConditionFactory.java             | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/org/openstreetmap/josm/gui/mappaint/mapcss/ConditionFactory.java b/src/org/openstreetmap/josm/gui/mappaint/mapcss/ConditionFactory.java
index a97573ecc..becc3f0a8 100644
--- a/src/org/openstreetmap/josm/gui/mappaint/mapcss/ConditionFactory.java
+++ b/src/org/openstreetmap/josm/gui/mappaint/mapcss/ConditionFactory.java
@@ -878,7 +878,7 @@ public final class ConditionFactory {
          * @see IPrimitive#isHighlighted
          * @since xxx
          */
-        static boolean highlighted(Environment e) { // NO_UCD (unused code)
+        static boolean highlighted(Environment e) {
             return e.osm.isHighlighted();
         }
     }
@@ -898,6 +898,7 @@ public final class ConditionFactory {
             PseudoClassCondition.register("closed2", PseudoClasses::closed2);
             PseudoClassCondition.register("completely_downloaded", PseudoClasses::completely_downloaded);
             PseudoClassCondition.register("connection", PseudoClasses::connection);
+            PseudoClassCondition.register("highlighted", PseudoClasses::highlighted);
             PseudoClassCondition.register("inDownloadedArea", PseudoClasses::inDownloadedArea);
             PseudoClassCondition.register("modified", PseudoClasses::modified);
             PseudoClassCondition.register("new", PseudoClasses::_new);
-- 
GitLab


From 729060323f9bceececcec48ddf9eb45f5c3cdeff Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Wed, 21 Apr 2021 16:23:57 -0600
Subject: [PATCH 11/50] Fix dropping features with dupe ids for MVTv1

Also, make some performance modifications (hopefully)

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/vector/DataStore.java           |  13 +-
 .../josm/data/vector/VectorDataSet.java       |   2 +-
 .../josm/data/vector/VectorDataStore.java     | 111 +++++++++++-------
 .../josm/data/vector/VectorDataSetTest.java   |   9 +-
 4 files changed, 88 insertions(+), 47 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/DataStore.java b/src/org/openstreetmap/josm/data/vector/DataStore.java
index 9de044f62..5175a534b 100644
--- a/src/org/openstreetmap/josm/data/vector/DataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/DataStore.java
@@ -20,6 +20,7 @@ import org.openstreetmap.josm.data.osm.IWay;
 import org.openstreetmap.josm.data.osm.PrimitiveId;
 import org.openstreetmap.josm.data.osm.QuadBucketPrimitiveStore;
 import org.openstreetmap.josm.data.osm.Storage;
+import org.openstreetmap.josm.tools.Logging;
 
 /**
  * A class that stores data (essentially a simple {@link DataSet})
@@ -46,6 +47,7 @@ class DataStore<O extends IPrimitive, N extends INode, W extends IWay<N>, R exte
     protected final int zoom;
     protected final LocalQuadBucketPrimitiveStore<N, W, R> store = new LocalQuadBucketPrimitiveStore<>();
     protected final Storage<O> allPrimitives = new Storage<>(new Storage.PrimitiveIdHash(), true);
+    // TODO what happens when I use hashCode?
     protected final Set<Tile> addedTiles = new HashSet<>();
     protected final Map<PrimitiveId, O> primitivesMap = allPrimitives
       .foreignKey(new Storage.PrimitiveIdHash());
@@ -96,13 +98,20 @@ class DataStore<O extends IPrimitive, N extends INode, W extends IWay<N>, R exte
         if (primitive == null) {
             return;
         }
-        // This is deliberate -- attempting to remove the primitive twice causes issues
-        synchronized (primitive) {
+        try {
+            this.readWriteLock.writeLock().lockInterruptibly();
             if (this.allPrimitives.contains(primitive)) {
                 this.store.removePrimitive(primitive);
                 this.allPrimitives.remove(primitive);
                 this.primitivesMap.remove(primitive.getPrimitiveId());
             }
+        } catch (InterruptedException e) {
+            Logging.error(e);
+            Thread.currentThread().interrupt();
+        } finally {
+            if (this.readWriteLock.isWriteLockedByCurrentThread()) {
+                this.readWriteLock.writeLock().unlock();
+            }
         }
     }
 
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index dfa9334a3..8e18dae25 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -474,7 +474,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
         final int currentZoom = tile.getZoom();
         // computeIfAbsent should be thread safe (ConcurrentHashMap indicates it is, anyway)
         final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, tZoom -> new VectorDataStore(this, tZoom));
-        tryWrite(dataStore, () -> dataStore.addTile(tile));
+        dataStore.addTile(tile);
     }
 
     /**
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
index f486651b6..92a74be74 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
@@ -28,6 +28,7 @@ import org.openstreetmap.josm.data.osm.UniqueIdGenerator;
 import org.openstreetmap.josm.gui.dialogs.relation.sort.RelationSorter;
 import org.openstreetmap.josm.tools.Destroyable;
 import org.openstreetmap.josm.tools.Geometry;
+import org.openstreetmap.josm.tools.Logging;
 
 /**
  * A data store for Vector Data sets
@@ -288,57 +289,83 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
      * @param tile The tile to add
      * @param <T> The tile type
      */
-    public synchronized <T extends Tile & VectorTile> void addTile(T tile) {
+    public <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();
+                .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())) {
             previous.ifPresent(this.addedTiles::remove);
             this.addedTiles.add(tile);
-            for (Layer layer : tile.getLayers()) {
-                layer.getFeatures().forEach(feature -> {
-                    org.openstreetmap.josm.data.imagery.vectortile.mapbox.Geometry geometry = feature
-                      .getGeometryObject();
-                    List<VectorPrimitive> featureObjects = new ArrayList<>();
-                    List<VectorPrimitive> primaryFeatureObjects = new ArrayList<>();
-                    geometry.getShapes().forEach(shape -> {
-                        final VectorPrimitive primitive;
-                        if (shape instanceof Ellipse2D) {
-                            primitive = pointToNode(tile, layer, featureObjects,
-                              (int) ((Ellipse2D) shape).getCenterX(), (int) ((Ellipse2D) shape).getCenterY());
-                        } else if (shape instanceof Path2D) {
-                            primitive = pathToWay(tile, layer, featureObjects, (Path2D) shape).stream().findFirst()
-                              .orElse(null);
-                        } else if (shape instanceof Area) {
-                            primitive = areaToRelation(tile, layer, featureObjects, (Area) shape);
-                            primitive.put("type", "multipolygon");
-                        } else {
-                            // We shouldn't hit this, but just in case
-                            throw new UnsupportedOperationException();
-                        }
-                        primaryFeatureObjects.add(primitive);
-                    });
+            VectorDataStore tStore = new VectorDataStore(this.dataSet, this.zoom);
+            tStore.createDataTile(tile);
+            try {
+                this.getReadWriteLock().writeLock().lockInterruptibly();
+                tStore.getAllPrimitives().forEach(this::addPrimitive);
+            } catch (InterruptedException e) {
+                Logging.error(e);
+                Thread.currentThread().interrupt();
+            } finally {
+                if (this.getReadWriteLock().isWriteLockedByCurrentThread()) {
+                    this.getReadWriteLock().writeLock().unlock();
+                }
+            }
+        }
+    }
+
+    private <T extends Tile & VectorTile> void createDataTile(T tile) {
+        for (Layer layer : tile.getLayers()) {
+            layer.getFeatures().forEach(feature -> {
+                org.openstreetmap.josm.data.imagery.vectortile.mapbox.Geometry geometry = feature
+                  .getGeometryObject();
+                List<VectorPrimitive> featureObjects = new ArrayList<>();
+                List<VectorPrimitive> primaryFeatureObjects = new ArrayList<>();
+                geometry.getShapes().forEach(shape -> {
                     final VectorPrimitive primitive;
-                    if (primaryFeatureObjects.size() == 1) {
-                        primitive = primaryFeatureObjects.get(0);
-                        if (primitive instanceof IRelation && !primitive.isMultipolygon()) {
-                            primitive.put(JOSM_MERGE_TYPE_KEY, "merge");
-                        }
-                    } else if (!primaryFeatureObjects.isEmpty()) {
-                        VectorRelation relation = new VectorRelation(layer.getName());
-                        primaryFeatureObjects.stream().map(prim -> new VectorRelationMember("", prim))
-                          .forEach(relation::addRelationMember);
-                        primitive = relation;
+                    if (shape instanceof Ellipse2D) {
+                        primitive = pointToNode(tile, layer, featureObjects,
+                          (int) ((Ellipse2D) shape).getCenterX(), (int) ((Ellipse2D) shape).getCenterY());
+                    } else if (shape instanceof Path2D) {
+                        primitive = pathToWay(tile, layer, featureObjects, (Path2D) shape).stream().findFirst()
+                          .orElse(null);
+                    } else if (shape instanceof Area) {
+                        primitive = areaToRelation(tile, layer, featureObjects, (Area) shape);
+                        primitive.put("type", "multipolygon");
                     } else {
-                        return;
+                        // We shouldn't hit this, but just in case
+                        throw new UnsupportedOperationException();
                     }
-                    primitive.setId(feature.getId());
-                    feature.getTags().forEach(primitive::put);
-                    featureObjects.forEach(this::addPrimitive);
-                    primaryFeatureObjects.forEach(this::addPrimitive);
-                    this.addPrimitive(primitive);
+                    primaryFeatureObjects.add(primitive);
                 });
-            }
+                final VectorPrimitive primitive;
+                if (primaryFeatureObjects.size() == 1) {
+                    primitive = primaryFeatureObjects.get(0);
+                    if (primitive instanceof IRelation && !primitive.isMultipolygon()) {
+                        primitive.put(JOSM_MERGE_TYPE_KEY, "merge");
+                    }
+                } else if (!primaryFeatureObjects.isEmpty()) {
+                    VectorRelation relation = new VectorRelation(layer.getName());
+                    primaryFeatureObjects.stream().map(prim -> new VectorRelationMember("", prim))
+                      .forEach(relation::addRelationMember);
+                    primitive = relation;
+                } else {
+                    return;
+                }
+                primitive.setId(feature.getId());
+                // Version 1 <i>does not guarantee</i> that non-zero ids are unique
+                // We depend upon unique ids in the data store
+                if (layer.getVersion() == 1 && feature.getId() != 0 && this.primitivesMap.containsKey(primitive.getPrimitiveId())) {
+                    // Reduce total memory usage by getting pre-existing strings, if they exist
+                    // Avoid interning, as the intern pool is known to be slow when many strings are added to it (Java 8)
+                    // HashSets tend to be a bit faster when interning many strings with relatively low usage
+                    String originalId = Long.toString(feature.getId());
+                    primitive.put("original_id", this.dataSet.allPrimitives().parallelStream().map(p -> p.get("original_id")).filter(originalId::equals).findAny().orElse(originalId));
+                    primitive.setId(primitive.getIdGenerator().generateUniqueId());
+                }
+                feature.getTags().forEach(primitive::put);
+                featureObjects.forEach(this::addPrimitive);
+                primaryFeatureObjects.forEach(this::addPrimitive);
+                this.addPrimitive(primitive);
+            });
         }
     }
 
diff --git a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
index 38ab53ad2..a33c396f4 100644
--- a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
+++ b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
@@ -15,6 +15,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 
+import org.junit.jupiter.api.RepeatedTest;
 import org.openstreetmap.josm.TestUtils;
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
@@ -113,13 +114,17 @@ class VectorDataSetTest {
         dataSet.setZoom(14);
         loadTile(this.layer, 14, 3248, 6258);
 
-        // There _does_ appear to be some kind of race condition though
-        Awaitility.await().atMost(Durations.FIVE_SECONDS).until(() -> dataSet.getNodes().size() > 50);
         // Actual test
         // With Mapillary, only ends of ways should be untagged
         // There are 55 actual "nodes" in the data with two nodes for the ends of the way.
         // One of the end nodes is a duplicate of an actual node.
         assertEquals(56, dataSet.getNodes().size());
+        // There should be 55 nodes from the mapillary-images layer
+        assertEquals(55, dataSet.getNodes().stream().filter(node -> "mapillary-images".equals(node.getLayer())).count());
+        // Please note that this dataset originally had the <i>same</i> id for all the images
+        // (MVT v2 explicitly said that ids had to be unique in a layer, MVT v1 did not)
+        assertEquals(55, dataSet.getNodes().stream().map(node -> node.get("original_id")).count());
+        assertEquals(1, dataSet.getNodes().stream().map(node -> node.get("original_id")).distinct().count());
         assertEquals(1, dataSet.getWays().size());
         assertEquals(0, dataSet.getRelations().size());
     }
-- 
GitLab


From 8ea1c070ed9de9cf0f02d209713a862f0f5f960d Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Wed, 21 Apr 2021 16:44:57 -0600
Subject: [PATCH 12/50] Add layer filtering back in

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/vector/VectorDataSet.java       | 21 +++++++++++++-
 .../josm/data/vector/VectorDataStore.java     | 28 ++++++++++++++++---
 .../josm/data/vector/VectorPrimitive.java     |  5 ++++
 .../josm/gui/layer/imagery/MVTLayer.java      |  1 +
 4 files changed, 50 insertions(+), 5 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index 8e18dae25..4e9cb9ffd 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -49,6 +49,8 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     // 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 static final String[] NO_INVISIBLE_LAYERS = new String[0];
+    private String[] invisibleLayers = NO_INVISIBLE_LAYERS;
     private boolean lock = true;
     private String name;
     private short mappaintCacheIdx = 1;
@@ -474,7 +476,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
         final int currentZoom = tile.getZoom();
         // computeIfAbsent should be thread safe (ConcurrentHashMap indicates it is, anyway)
         final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, tZoom -> new VectorDataStore(this, tZoom));
-        dataStore.addTile(tile);
+        dataStore.addTile(tile, this.invisibleLayers);
     }
 
     /**
@@ -538,4 +540,21 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
             this.styles = null;
         }
     }
+
+    /**
+     * Mark some layers as invisible
+     * @param invisibleLayers The layer to not show
+     */
+    public void setInvisibleLayers(Collection<String> invisibleLayers) {
+        if (invisibleLayers == null || invisibleLayers.isEmpty() || invisibleLayers.stream().filter(Objects::nonNull).filter(string -> !string.isEmpty()).count() == 0) {
+            this.invisibleLayers = NO_INVISIBLE_LAYERS;
+            return;
+        }
+        String[] currentInvisibleLayers = invisibleLayers.stream().filter(Objects::nonNull).toArray(String[]::new);
+        this.invisibleLayers = currentInvisibleLayers;
+        List<String> temporaryList = Arrays.asList(currentInvisibleLayers);
+        this.dataStoreMap.values().forEach(dataStore -> {
+            dataStore.getAllPrimitives().parallelStream().forEach(primitive -> primitive.setVisible(!temporaryList.contains(primitive.getLayer())));
+        });
+    }
 }
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
index 92a74be74..4f2e37252 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
@@ -6,6 +6,7 @@ import java.awt.geom.Ellipse2D;
 import java.awt.geom.Path2D;
 import java.awt.geom.PathIterator;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -28,14 +29,17 @@ import org.openstreetmap.josm.data.osm.UniqueIdGenerator;
 import org.openstreetmap.josm.gui.dialogs.relation.sort.RelationSorter;
 import org.openstreetmap.josm.tools.Destroyable;
 import org.openstreetmap.josm.tools.Geometry;
+import org.openstreetmap.josm.tools.JosmRuntimeException;
 import org.openstreetmap.josm.tools.Logging;
 
+
 /**
  * A data store for Vector Data sets
  * @author Taylor Smock
  * @since xxx
  */
 class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay, VectorRelation> implements Destroyable {
+    private static final String[] EMPTY_STRING_ARRAY = new String[0];
     private static final String JOSM_MERGE_TYPE_KEY = "josm_merge_type";
     private final VectorDataSet dataSet;
 
@@ -174,7 +178,7 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
     private synchronized <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 int layerExtent = layer.getExtent();
         final ICoordinate lowerRight = tile.getTileSource()
           .tileXYToLatLon(tile.getXtile() + 1, tile.getYtile() + 1, tile.getZoom());
         final ICoordinate coords = new Coordinate(
@@ -286,10 +290,21 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
 
     /**
      * Add a tile to this data store
+     * @param <T> The tile type
      * @param tile The tile to add
+     */
+    public synchronized <T extends Tile & VectorTile> void addTile(T tile) {
+        addTile(tile, EMPTY_STRING_ARRAY);
+    }
+
+    /**
+     * Add a tile to this data store
      * @param <T> The tile type
+     * @param tile The tile to add
+     * @param invisibleLayers Any invisible current invisible layers
      */
-    public <T extends Tile & VectorTile> void addTile(T tile) {
+    public <T extends Tile & VectorTile> void addTile(T tile, String[] invisibleLayers) {
+        List<String> invisibleLayerList = Arrays.asList(invisibleLayers);
         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)
@@ -297,7 +312,7 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
             previous.ifPresent(this.addedTiles::remove);
             this.addedTiles.add(tile);
             VectorDataStore tStore = new VectorDataStore(this.dataSet, this.zoom);
-            tStore.createDataTile(tile);
+            tStore.createDataTile(tile, invisibleLayerList);
             try {
                 this.getReadWriteLock().writeLock().lockInterruptibly();
                 tStore.getAllPrimitives().forEach(this::addPrimitive);
@@ -312,7 +327,7 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
         }
     }
 
-    private <T extends Tile & VectorTile> void createDataTile(T tile) {
+    private <T extends Tile & VectorTile> void createDataTile(T tile, List<String> invisibleLayerList) {
         for (Layer layer : tile.getLayers()) {
             layer.getFeatures().forEach(feature -> {
                 org.openstreetmap.josm.data.imagery.vectortile.mapbox.Geometry geometry = feature
@@ -364,6 +379,11 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
                 feature.getTags().forEach(primitive::put);
                 featureObjects.forEach(this::addPrimitive);
                 primaryFeatureObjects.forEach(this::addPrimitive);
+                if (invisibleLayerList.contains(primitive.getLayer())) {
+                    primitive.setVisible(false);
+                    featureObjects.forEach(p -> p.setVisible(false));
+                    primaryFeatureObjects.forEach(p -> p.setVisible(false));
+                }
                 this.addPrimitive(primitive);
             });
         }
diff --git a/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java b/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
index 17b5bef6f..ed9c93937 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
@@ -253,4 +253,9 @@ public abstract class VectorPrimitive extends AbstractPrimitive implements DataL
     public String getLayer() {
         return this.layer;
     }
+
+    @Override
+    public boolean isDrawable() {
+        return super.isDrawable() && this.isVisible();
+    }
 }
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
index aa335f7b0..4007e8495 100644
--- a/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
@@ -138,6 +138,7 @@ public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSour
             actions.add(new EnableLayerAction(layerConfig.getKey(), () -> layerNames.computeIfAbsent(layerConfig.getKey(), key -> true),
                     layer -> {
                 layerNames.compute(layer, (key, value) -> Boolean.FALSE.equals(value));
+                this.dataSet.setInvisibleLayers(layerNames.entrySet().stream().filter(entry -> Boolean.FALSE.equals(entry.getValue())).map(Map.Entry::getKey).collect(Collectors.toList()));
                 this.invalidate();
             }));
         }
-- 
GitLab


From 5f86499538c07e0c2b7659dcb5b2071e49021624 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Wed, 21 Apr 2021 16:45:33 -0600
Subject: [PATCH 13/50] MvtLayer: Add additional information to objects when
 converting

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/gui/layer/imagery/MVTLayer.java           | 14 ++++++++++----
 1 file changed, 10 insertions(+), 4 deletions(-)

diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
index 4007e8495..d0b98d633 100644
--- a/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
@@ -173,6 +173,8 @@ public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSour
                 Node newNode = new Node(vectorNode.getCoor());
                 if (vectorNode.isTagged()) {
                     vectorNode.getInterestingTags().forEach(newNode::put);
+                    newNode.put("layer", vectorNode.getLayer());
+                    newNode.put("id", Long.toString(vectorNode.getId()));
                 }
                 nodeMap.put(vectorNode, newNode);
             }
@@ -184,6 +186,8 @@ public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSour
                 newWay.setNodes(nodes);
                 if (vectorWay.isTagged()) {
                     vectorWay.getInterestingTags().forEach(newWay::put);
+                    newWay.put("layer", vectorWay.getLayer());
+                    newWay.put("id", Long.toString(vectorWay.getId()));
                 }
                 wayMap.put(vectorWay, newWay);
             }
@@ -191,9 +195,11 @@ public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSour
             // Finally, add Relations
             Map<VectorRelation, Relation> relationMap = new HashMap<>(dataSet.getRelations().size());
             for (VectorRelation vectorRelation : dataSet.getRelations()) {
-                Relation relation = new Relation();
+                Relation newRelation = new Relation();
                 if (vectorRelation.isTagged()) {
-                    vectorRelation.getInterestingTags().forEach(relation::put);
+                    vectorRelation.getInterestingTags().forEach(newRelation::put);
+                    newRelation.put("layer", vectorRelation.getLayer());
+                    newRelation.put("id", Long.toString(vectorRelation.getId()));
                 }
                 List<RelationMember> members = vectorRelation.getMembers().stream().map(member -> {
                     final OsmPrimitive primitive;
@@ -211,8 +217,8 @@ public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSour
                     if (primitive == null) return null;
                     return new RelationMember(member.getRole(), primitive);
                 }).filter(Objects::nonNull).collect(Collectors.toList());
-                relation.setMembers(members);
-                relationMap.put(vectorRelation, relation);
+                newRelation.setMembers(members);
+                relationMap.put(vectorRelation, newRelation);
             }
             try {
                 osmData.beginUpdate();
-- 
GitLab


From abb734cd52710755b287f596521510f7659f4dbb Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Wed, 21 Apr 2021 17:01:57 -0600
Subject: [PATCH 14/50] VectorDataStore: Don't attempt to deduplicate ways

This has led to some ConcurrentModificationExceptions and/or primitives
that cannot be removed. This functionality should be done in a different
ticket.

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/vector/DataStore.java           |  2 +-
 .../josm/data/vector/VectorDataStore.java     | 35 +++++--------------
 2 files changed, 10 insertions(+), 27 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/DataStore.java b/src/org/openstreetmap/josm/data/vector/DataStore.java
index 5175a534b..86f1948d1 100644
--- a/src/org/openstreetmap/josm/data/vector/DataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/DataStore.java
@@ -48,7 +48,7 @@ class DataStore<O extends IPrimitive, N extends INode, W extends IWay<N>, R exte
     protected final LocalQuadBucketPrimitiveStore<N, W, R> store = new LocalQuadBucketPrimitiveStore<>();
     protected final Storage<O> allPrimitives = new Storage<>(new Storage.PrimitiveIdHash(), true);
     // TODO what happens when I use hashCode?
-    protected final Set<Tile> addedTiles = new HashSet<>();
+    protected final Set<Tile> addedTiles = Collections.synchronizedSet(new HashSet<>());
     protected final Map<PrimitiveId, O> primitivesMap = allPrimitives
       .foreignKey(new Storage.PrimitiveIdHash());
     protected final Collection<DataSource> dataSources = new LinkedList<>();
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
index 4f2e37252..cd5a03fe9 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
@@ -9,8 +9,8 @@ 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.Objects;
 import java.util.Optional;
 import java.util.stream.Collectors;
 
@@ -127,24 +127,7 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
             iteration++;
             relationWayList.removeIf(wayList::contains);
         }
-        if (!relationWayList.isEmpty()) {
-            return relation;
-        }
-        // Merge ways
-        List<VectorNode> nodes = new ArrayList<>();
-        for (VectorWay way : wayList) {
-            for (VectorNode node : way.getNodes()) {
-                if (nodes.isEmpty() || !Objects.equals(nodes.get(nodes.size() - 1), node)) {
-                    nodes.add(node);
-                }
-            }
-        }
-        VectorWay way = wayList.get(0);
-        way.setNodes(nodes);
-        wayList.remove(way);
-        wayList.forEach(this::removePrimitive);
-        this.removePrimitive(relation);
-        return way;
+        return relation;
     }
 
     private static <N extends INode, W extends IWay<N>> boolean canMergeWays(W old, W toAdd, boolean allowReverse) {
@@ -305,8 +288,11 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
      */
     public <T extends Tile & VectorTile> void addTile(T tile, String[] invisibleLayers) {
         List<String> invisibleLayerList = Arrays.asList(invisibleLayers);
-        Optional<Tile> previous = this.addedTiles.stream()
-                .filter(t -> t.getTileXY().equals(tile.getTileXY()) && t.getZoom() == tile.getZoom()).findAny();
+        Optional<Tile> previous;
+        synchronized (this.addedTiles) {
+            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())) {
             previous.ifPresent(this.addedTiles::remove);
@@ -369,11 +355,8 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
                 // Version 1 <i>does not guarantee</i> that non-zero ids are unique
                 // We depend upon unique ids in the data store
                 if (layer.getVersion() == 1 && feature.getId() != 0 && this.primitivesMap.containsKey(primitive.getPrimitiveId())) {
-                    // Reduce total memory usage by getting pre-existing strings, if they exist
-                    // Avoid interning, as the intern pool is known to be slow when many strings are added to it (Java 8)
-                    // HashSets tend to be a bit faster when interning many strings with relatively low usage
-                    String originalId = Long.toString(feature.getId());
-                    primitive.put("original_id", this.dataSet.allPrimitives().parallelStream().map(p -> p.get("original_id")).filter(originalId::equals).findAny().orElse(originalId));
+                    // This, unfortunately, makes a new string
+                    primitive.put("original_id", Long.toString(feature.getId()));
                     primitive.setId(primitive.getIdGenerator().generateUniqueId());
                 }
                 feature.getTags().forEach(primitive::put);
-- 
GitLab


From 676ec09fa4726006134816f152c00e65364b49dd Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 22 Apr 2021 08:03:40 -0600
Subject: [PATCH 15/50] MVTLayer: Make some options expert only

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/gui/layer/imagery/MVTLayer.java      | 23 +++++++++++--------
 1 file changed, 13 insertions(+), 10 deletions(-)

diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
index d0b98d633..876ce5399 100644
--- a/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
@@ -25,6 +25,7 @@ import javax.swing.JMenuItem;
 
 import org.openstreetmap.gui.jmapviewer.Tile;
 import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
+import org.openstreetmap.josm.actions.ExpertToggleAction;
 import org.openstreetmap.josm.data.Bounds;
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
@@ -134,17 +135,19 @@ public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSour
         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) -> Boolean.FALSE.equals(value));
-                this.dataSet.setInvisibleLayers(layerNames.entrySet().stream().filter(entry -> Boolean.FALSE.equals(entry.getValue())).map(Map.Entry::getKey).collect(Collectors.toList()));
-                this.invalidate();
-            }));
+        if (ExpertToggleAction.isExpert()) {
+            for (Map.Entry<String, Boolean> layerConfig : layerNames.entrySet()) {
+                actions.add(new EnableLayerAction(layerConfig.getKey(), () -> layerNames.computeIfAbsent(layerConfig.getKey(), key -> true),
+                        layer -> {
+                            layerNames.compute(layer, (key, value) -> Boolean.FALSE.equals(value));
+                            this.dataSet.setInvisibleLayers(layerNames.entrySet().stream().filter(entry -> Boolean.FALSE.equals(entry.getValue())).map(Map.Entry::getKey).collect(Collectors.toList()));
+                            this.invalidate();
+                        }));
+            }
+            // Add separator between layers and convert action
+            actions.add(SeparatorLayerAction.INSTANCE);
+            actions.add(new ConvertLayerAction(this));
         }
-        // Add separator between layers and convert action
-        actions.add(SeparatorLayerAction.INSTANCE);
-        actions.add(new ConvertLayerAction(this));
         return actions.toArray(EMPTY_ACTIONS);
     }
 
-- 
GitLab


From 88b205a99a757dd4d929eca11d66b474decf698d Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 22 Apr 2021 08:04:00 -0600
Subject: [PATCH 16/50] MVT v1 and v2 don't *require* that ids be unique.

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/vector/VectorDataStore.java     | 26 ++++++++++++++++---
 1 file changed, 22 insertions(+), 4 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
index cd5a03fe9..34c5dbd47 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
@@ -9,11 +9,17 @@ 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.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
+import com.google.common.base.Functions;
+import org.antlr.v4.runtime.atn.SemanticContext;
 import org.openstreetmap.gui.jmapviewer.Coordinate;
 import org.openstreetmap.gui.jmapviewer.Tile;
 import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
@@ -21,6 +27,7 @@ import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
 import org.openstreetmap.josm.data.osm.BBox;
 import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
 import org.openstreetmap.josm.data.osm.IRelation;
 import org.openstreetmap.josm.data.osm.IWay;
 import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
@@ -41,6 +48,7 @@ import org.openstreetmap.josm.tools.Logging;
 class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay, VectorRelation> implements Destroyable {
     private static final String[] EMPTY_STRING_ARRAY = new String[0];
     private static final String JOSM_MERGE_TYPE_KEY = "josm_merge_type";
+    private static final String ORIGINAL_ID = "original_id";
     private final VectorDataSet dataSet;
 
     VectorDataStore(VectorDataSet dataSet, int zoom) {
@@ -352,11 +360,11 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
                     return;
                 }
                 primitive.setId(feature.getId());
-                // Version 1 <i>does not guarantee</i> that non-zero ids are unique
+                // Version 1 <i>and</i> 2 <i>do not guarantee</i> that non-zero ids are unique
                 // We depend upon unique ids in the data store
-                if (layer.getVersion() == 1 && feature.getId() != 0 && this.primitivesMap.containsKey(primitive.getPrimitiveId())) {
+                if (feature.getId() != 0 && this.primitivesMap.containsKey(primitive.getPrimitiveId())) {
                     // This, unfortunately, makes a new string
-                    primitive.put("original_id", Long.toString(feature.getId()));
+                    primitive.put(ORIGINAL_ID, Long.toString(feature.getId()));
                     primitive.setId(primitive.getIdGenerator().generateUniqueId());
                 }
                 feature.getTags().forEach(primitive::put);
@@ -367,9 +375,19 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
                     featureObjects.forEach(p -> p.setVisible(false));
                     primaryFeatureObjects.forEach(p -> p.setVisible(false));
                 }
-                this.addPrimitive(primitive);
+                try {
+                    this.addPrimitive(primitive);
+                } catch (JosmRuntimeException e) {
+                    Logging.error("{0}/{1}/{2}: {3}", tile.getZoom(), tile.getXtile(), tile.getYtile(), primitive.get("key"));
+                    throw e;
+                }
             });
         }
+        // Replace original_ids with the same object (reduce memory usage)
+        // Strings aren't interned automatically (see
+        Collection<IPrimitive> primitives = this.dataSet.allPrimitives().stream().filter(p -> p.hasKey(ORIGINAL_ID)).collect(Collectors.toList());
+        List<String> toReplace = primitives.stream().map(p -> p.get(ORIGINAL_ID)).filter(Objects::nonNull).collect(Collectors.toList());
+        primitives.stream().filter(p -> toReplace.contains(p.get(ORIGINAL_ID))).forEach(p -> p.put(ORIGINAL_ID, toReplace.stream().filter(shared -> shared.equals(p.get(ORIGINAL_ID))).findAny().orElse(null)));
     }
 
     @Override
-- 
GitLab


From 48a97b203f0f6a091fdf698fd4ba75b0a9449feb Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 22 Apr 2021 08:21:10 -0600
Subject: [PATCH 17/50] FIXUP: VectorDataStore: Optimize imports

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/vector/VectorDataStore.java     | 32 ++++++++-----------
 1 file changed, 13 insertions(+), 19 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
index 34c5dbd47..bd76e3538 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
@@ -1,25 +1,6 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.vector;
 
-import java.awt.geom.Area;
-import java.awt.geom.Ellipse2D;
-import java.awt.geom.Path2D;
-import java.awt.geom.PathIterator;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import com.google.common.base.Functions;
-import org.antlr.v4.runtime.atn.SemanticContext;
 import org.openstreetmap.gui.jmapviewer.Coordinate;
 import org.openstreetmap.gui.jmapviewer.Tile;
 import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
@@ -39,6 +20,19 @@ import org.openstreetmap.josm.tools.Geometry;
 import org.openstreetmap.josm.tools.JosmRuntimeException;
 import org.openstreetmap.josm.tools.Logging;
 
+import java.awt.geom.Area;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Path2D;
+import java.awt.geom.PathIterator;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
 
 /**
  * A data store for Vector Data sets
-- 
GitLab


From f8f50fa8560899c79a6d76167d9d4e594644d4af Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 22 Apr 2021 08:31:55 -0600
Subject: [PATCH 18/50] FIXUP: PMD

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 src/org/openstreetmap/josm/data/vector/VectorDataSet.java | 8 ++++----
 .../openstreetmap/josm/data/vector/VectorDataStore.java   | 8 +++++---
 .../openstreetmap/josm/gui/layer/imagery/MVTLayer.java    | 4 +++-
 .../openstreetmap/josm/data/vector/VectorDataSetTest.java | 1 -
 4 files changed, 12 insertions(+), 9 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index 4e9cb9ffd..61eeb0279 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -546,15 +546,15 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
      * @param invisibleLayers The layer to not show
      */
     public void setInvisibleLayers(Collection<String> invisibleLayers) {
-        if (invisibleLayers == null || invisibleLayers.isEmpty() || invisibleLayers.stream().filter(Objects::nonNull).filter(string -> !string.isEmpty()).count() == 0) {
+        if (invisibleLayers == null || invisibleLayers.isEmpty()
+                || invisibleLayers.stream().filter(Objects::nonNull).allMatch(String::isEmpty)) {
             this.invisibleLayers = NO_INVISIBLE_LAYERS;
             return;
         }
         String[] currentInvisibleLayers = invisibleLayers.stream().filter(Objects::nonNull).toArray(String[]::new);
         this.invisibleLayers = currentInvisibleLayers;
         List<String> temporaryList = Arrays.asList(currentInvisibleLayers);
-        this.dataStoreMap.values().forEach(dataStore -> {
-            dataStore.getAllPrimitives().parallelStream().forEach(primitive -> primitive.setVisible(!temporaryList.contains(primitive.getLayer())));
-        });
+        this.dataStoreMap.values().forEach(dataStore -> dataStore.getAllPrimitives().parallelStream()
+                .forEach(primitive -> primitive.setVisible(!temporaryList.contains(primitive.getLayer()))));
     }
 }
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
index bd76e3538..7e48e4313 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
@@ -33,7 +33,6 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.stream.Collectors;
 
-
 /**
  * A data store for Vector Data sets
  * @author Taylor Smock
@@ -379,9 +378,12 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
         }
         // Replace original_ids with the same object (reduce memory usage)
         // Strings aren't interned automatically (see
-        Collection<IPrimitive> primitives = this.dataSet.allPrimitives().stream().filter(p -> p.hasKey(ORIGINAL_ID)).collect(Collectors.toList());
+        Collection<IPrimitive> primitives = this.dataSet.allPrimitives().stream().filter(p -> p.hasKey(ORIGINAL_ID))
+                .collect(Collectors.toList());
         List<String> toReplace = primitives.stream().map(p -> p.get(ORIGINAL_ID)).filter(Objects::nonNull).collect(Collectors.toList());
-        primitives.stream().filter(p -> toReplace.contains(p.get(ORIGINAL_ID))).forEach(p -> p.put(ORIGINAL_ID, toReplace.stream().filter(shared -> shared.equals(p.get(ORIGINAL_ID))).findAny().orElse(null)));
+        primitives.stream().filter(p -> toReplace.contains(p.get(ORIGINAL_ID)))
+                .forEach(p -> p.put(ORIGINAL_ID, toReplace.stream().filter(shared -> shared.equals(p.get(ORIGINAL_ID)))
+                        .findAny().orElse(null)));
     }
 
     @Override
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
index 876ce5399..2e7aac3e6 100644
--- a/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
@@ -140,7 +140,9 @@ public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSour
                 actions.add(new EnableLayerAction(layerConfig.getKey(), () -> layerNames.computeIfAbsent(layerConfig.getKey(), key -> true),
                         layer -> {
                             layerNames.compute(layer, (key, value) -> Boolean.FALSE.equals(value));
-                            this.dataSet.setInvisibleLayers(layerNames.entrySet().stream().filter(entry -> Boolean.FALSE.equals(entry.getValue())).map(Map.Entry::getKey).collect(Collectors.toList()));
+                            this.dataSet.setInvisibleLayers(layerNames.entrySet().stream()
+                                    .filter(entry -> Boolean.FALSE.equals(entry.getValue()))
+                                    .map(Map.Entry::getKey).collect(Collectors.toList()));
                             this.invalidate();
                         }));
             }
diff --git a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
index a33c396f4..35e208979 100644
--- a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
+++ b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
@@ -15,7 +15,6 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 
-import org.junit.jupiter.api.RepeatedTest;
 import org.openstreetmap.josm.TestUtils;
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
-- 
GitLab


From aafdee1cb7cd640b5980d5ccd280f713930d7758 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 22 Apr 2021 10:54:47 -0600
Subject: [PATCH 19/50] FIXUP: Failing tests (largely 2048->4096, extent is
 uint not sint)

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/imagery/vectortile/mapbox/LayerTest.java        | 4 ++--
 .../josm/data/imagery/vectortile/mapbox/MVTTileTest.java      | 4 +---
 .../josm/data/imagery/vectortile/mapbox/style/SourceTest.java | 3 ++-
 3 files changed, 5 insertions(+), 6 deletions(-)

diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java
index fc3ba9c27..61e21dc60 100644
--- a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java
@@ -79,14 +79,14 @@ public class LayerTest {
         assertEquals("mapillary-sequences", sequenceLayer.getName());
         assertEquals(1, sequenceLayer.getFeatures().size());
         assertEquals(1, sequenceLayer.getGeometry().size());
-        assertEquals(2048, sequenceLayer.getExtent());
+        assertEquals(4096, sequenceLayer.getExtent());
         assertEquals(1, sequenceLayer.getVersion());
 
         Layer imageLayer = new Layer(layers.get(1).getBytes());
         assertEquals("mapillary-images", imageLayer.getName());
         assertEquals(116, imageLayer.getFeatures().size());
         assertEquals(116, imageLayer.getGeometry().size());
-        assertEquals(2048, imageLayer.getExtent());
+        assertEquals(4096, imageLayer.getExtent());
         assertEquals(1, imageLayer.getVersion());
     }
 
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java
index 66e4ea781..12b86ebc7 100644
--- a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java
@@ -67,9 +67,7 @@ public class MVTTileTest {
         if (isLoaded) {
             Awaitility.await().atMost(Durations.ONE_SECOND).until(() -> tile.getLayers() != null && tile.getLayers().size() > 1);
             assertEquals(2, tile.getLayers().size());
-            // The test Mapillary tiles have 2048 instead of 4096 for their extent. This *may* change
-            // in future Mapillary tiles, so if the test PBF files are updated, beware.
-            assertEquals(2048, tile.getExtent());
+            assertEquals(4096, tile.getExtent());
             // Ensure that we have the clear image set, such that the tile doesn't add to the dataset again
             // and we don't have a loading image
             assertEquals(MVTTile.CLEAR_LOADED, tile.getImage());
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceTest.java
index 500b5f8b5..8513be831 100644
--- a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceTest.java
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceTest.java
@@ -20,6 +20,7 @@ import org.openstreetmap.josm.data.Bounds;
 
 import nl.jqno.equalsverifier.EqualsVerifier;
 import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.InvalidMapboxVectorTileException;
 
 /**
  * Test class for {@link Source}
@@ -68,7 +69,7 @@ public class SourceTest {
         final JsonObject tileJsonSpec = Json.createObjectBuilder()
           .add("type", SourceType.VECTOR.name()).add("url", "some-random-url.com")
           .build();
-        assertThrows(UnsupportedOperationException.class, () -> new Source("Test TileJson", tileJsonSpec));
+        assertThrows(InvalidMapboxVectorTileException.class, () -> new Source("Test TileJson", tileJsonSpec));
     }
 
     @Test
-- 
GitLab


From d924f81efddc1da4c6b54977ece1c76aa2862d6d Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Tue, 27 Apr 2021 08:39:14 -0600
Subject: [PATCH 20/50] VectorDataSet: Add selection listener interface

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../osm/event/IDataSelectionEventSource.java  |  34 ++
 .../osm/event/IDataSelectionListener.java     | 361 ++++++++++++++++++
 .../josm/data/vector/VectorDataSet.java       |  88 ++++-
 3 files changed, 466 insertions(+), 17 deletions(-)
 create mode 100644 src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java
 create mode 100644 src/org/openstreetmap/josm/data/osm/event/IDataSelectionListener.java

diff --git a/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java b/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java
new file mode 100644
index 000000000..e5fc0ea19
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java
@@ -0,0 +1,34 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm.event;
+
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmData;
+
+/**
+ * This interface indicates that the class can fire {@link IDataSelectionListener}.
+ * @author Taylor Smock, Michael Zangl (original code)
+ * @since xxx
+ * @param <O> the base type of OSM primitives
+ * @param <N> type representing OSM nodes
+ * @param <W> type representing OSM ways
+ * @param <R> type representing OSM relations
+ * @param <D> The dataset type
+ */
+public interface IDataSelectionEventSource<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> {
+    /**
+     * Add a listener
+     * @param listener The listener to add
+     * @return {@code true} if the listener was added
+     */
+    boolean addListener(IDataSelectionListener<O, N, W, R, D> listener);
+
+    /**
+     * Remove a listener
+     * @param listener The listener to remove
+     * @return {@code true} if the listener was removed
+     */
+    boolean removeListener(IDataSelectionListener<O, N, W, R, D> listener);
+}
diff --git a/src/org/openstreetmap/josm/data/osm/event/IDataSelectionListener.java b/src/org/openstreetmap/josm/data/osm/event/IDataSelectionListener.java
new file mode 100644
index 000000000..5a95aa1c9
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/osm/event/IDataSelectionListener.java
@@ -0,0 +1,361 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm.event;
+
+import org.openstreetmap.josm.data.osm.DataSelectionListener;
+import org.openstreetmap.josm.data.osm.INode;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.IRelation;
+import org.openstreetmap.josm.data.osm.IWay;
+import org.openstreetmap.josm.data.osm.OsmData;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.tools.CheckParameterUtil;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * This interface is the same as {@link DataSelectionListener}, except it isn't {@link OsmPrimitive} specific.
+ * @author Taylor Smock, Michael Zangl (original code)
+ * @since xxx
+ * @param <O> the base type of OSM primitives
+ * @param <N> type representing OSM nodes
+ * @param <W> type representing OSM ways
+ * @param <R> type representing OSM relations
+ * @param <D> The dataset type
+ */
+@FunctionalInterface
+public interface IDataSelectionListener<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> {
+    /**
+     * Called whenever the selection is changed.
+     *
+     * You get notified about the new selection, the elements that were added and removed and the layer that triggered the event.
+     * @param event The selection change event.
+     * @see SelectionChangeEvent
+     */
+    void selectionChanged(SelectionChangeEvent<O, N, W, R, D> event);
+
+    /**
+     * The event that is fired when the selection changed.
+     * @author Michael Zangl
+     * @since xxx generics
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     */
+    interface SelectionChangeEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> {
+        /**
+         * Gets the previous selection
+         * <p>
+         * This collection cannot be modified and will not change.
+         * @return The old selection
+         */
+        Set<O> getOldSelection();
+
+        /**
+         * Gets the new selection. New elements are added to the end of the collection.
+         * <p>
+         * This collection cannot be modified and will not change.
+         * @return The new selection
+         */
+        Set<O> getSelection();
+
+        /**
+         * Gets the primitives that have been removed from the selection.
+         * <p>
+         * Those are the primitives contained in {@link #getOldSelection()} but not in {@link #getSelection()}
+         * <p>
+         * This collection cannot be modified and will not change.
+         * @return The primitives that were removed
+         */
+        Set<O> getRemoved();
+
+        /**
+         * Gets the primitives that have been added to the selection.
+         * <p>
+         * Those are the primitives contained in {@link #getSelection()} but not in {@link #getOldSelection()}
+         * <p>
+         * This collection cannot be modified and will not change.
+         * @return The primitives that were added
+         */
+        Set<O> getAdded();
+
+        /**
+         * Gets the data set that triggered this selection event.
+         * @return The data set.
+         */
+        D getSource();
+
+        /**
+         * Test if this event did not change anything.
+         * <p>
+         * This will return <code>false</code> for all events that are sent to listeners, so you don't need to test it.
+         * @return <code>true</code> if this did not change the selection.
+         */
+        default boolean isNop() {
+            return getAdded().isEmpty() && getRemoved().isEmpty();
+        }
+    }
+
+    /**
+     * The base class for selection events
+     * @author Michael Zangl
+     * @since 12048, xxx (generics)
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     */
+    abstract class AbstractSelectionEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> implements SelectionChangeEvent<O, N, W, R, D> {
+        private final D source;
+        private final Set<O> old;
+
+        protected AbstractSelectionEvent(D source, Set<O> old) {
+            CheckParameterUtil.ensureParameterNotNull(source, "source");
+            CheckParameterUtil.ensureParameterNotNull(old, "old");
+            this.source = source;
+            this.old = Collections.unmodifiableSet(old);
+        }
+
+        @Override
+        public Set<O> getOldSelection() {
+            return old;
+        }
+
+        @Override
+        public D getSource() {
+            return source;
+        }
+    }
+
+    /**
+     * The selection is replaced by a new selection
+     * @author Michael Zangl
+     * @since xxx (generics)
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     */
+    class SelectionReplaceEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> extends AbstractSelectionEvent<O, N, W, R, D> {
+        private final Set<O> current;
+        private Set<O> removed;
+        private Set<O> added;
+
+        /**
+         * Create a {@link SelectionReplaceEvent}
+         * @param source The source dataset
+         * @param old The old primitives that were previously selected. The caller needs to ensure that this set is not modified.
+         * @param newSelection The primitives of the new selection.
+         */
+        public SelectionReplaceEvent(D source, Set<O> old, Stream<O> newSelection) {
+            super(source, old);
+            this.current = newSelection.collect(Collectors.toCollection(LinkedHashSet::new));
+        }
+
+        @Override
+        public Set<O> getSelection() {
+            return current;
+        }
+
+        @Override
+        public synchronized Set<O> getRemoved() {
+            if (removed == null) {
+                removed = getOldSelection().stream()
+                        .filter(p -> !current.contains(p))
+                        .collect(Collectors.toCollection(LinkedHashSet::new));
+            }
+            return removed;
+        }
+
+        @Override
+        public synchronized Set<O> getAdded() {
+            if (added == null) {
+                added = current.stream()
+                        .filter(p -> !getOldSelection().contains(p)).collect(Collectors.toCollection(LinkedHashSet::new));
+            }
+            return added;
+        }
+
+        @Override
+        public String toString() {
+            return "SelectionReplaceEvent [current=" + current + ", removed=" + removed + ", added=" + added + ']';
+        }
+    }
+
+    /**
+     * Primitives are added to the selection
+     * @author Michael Zangl
+     * @since xxx (generics)
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     */
+    class SelectionAddEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> extends AbstractSelectionEvent<O, N, W, R, D> {
+        private final Set<O> add;
+        private final Set<O> current;
+
+        /**
+         * Create a {@link SelectionAddEvent}
+         * @param source The source dataset
+         * @param old The old primitives that were previously selected. The caller needs to ensure that this set is not modified.
+         * @param toAdd The primitives to add.
+         */
+        public SelectionAddEvent(D source, Set<O> old, Stream<O> toAdd) {
+            super(source, old);
+            this.add = toAdd
+                    .filter(p -> !old.contains(p))
+                    .collect(Collectors.toCollection(LinkedHashSet::new));
+            if (this.add.isEmpty()) {
+                this.current = this.getOldSelection();
+            } else {
+                this.current = new LinkedHashSet<>(old);
+                this.current.addAll(add);
+            }
+        }
+
+        @Override
+        public Set<O> getSelection() {
+            return Collections.unmodifiableSet(current);
+        }
+
+        @Override
+        public Set<O> getRemoved() {
+            return Collections.emptySet();
+        }
+
+        @Override
+        public Set<O> getAdded() {
+            return Collections.unmodifiableSet(add);
+        }
+
+        @Override
+        public String toString() {
+            return "SelectionAddEvent [add=" + add + ", current=" + current + ']';
+        }
+    }
+
+    /**
+     * Primitives are removed from the selection
+     * @author Michael Zangl
+     * @since 12048, xxx (generics)
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     */
+    class SelectionRemoveEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> extends AbstractSelectionEvent<O, N, W, R, D> {
+        private final Set<O> remove;
+        private final Set<O> current;
+
+        /**
+         * Create a {@link DataSelectionListener.SelectionRemoveEvent}
+         * @param source The source dataset
+         * @param old The old primitives that were previously selected. The caller needs to ensure that this set is not modified.
+         * @param toRemove The primitives to remove.
+         */
+        public SelectionRemoveEvent(D source, Set<O> old, Stream<O> toRemove) {
+            super(source, old);
+            this.remove = toRemove
+                    .filter(old::contains)
+                    .collect(Collectors.toCollection(LinkedHashSet::new));
+            if (this.remove.isEmpty()) {
+                this.current = this.getOldSelection();
+            } else {
+                HashSet<O> currentSet = new LinkedHashSet<>(old);
+                currentSet.removeAll(remove);
+                current = currentSet;
+            }
+        }
+
+        @Override
+        public Set<O> getSelection() {
+            return Collections.unmodifiableSet(current);
+        }
+
+        @Override
+        public Set<O> getRemoved() {
+            return Collections.unmodifiableSet(remove);
+        }
+
+        @Override
+        public Set<O> getAdded() {
+            return Collections.emptySet();
+        }
+
+        @Override
+        public String toString() {
+            return "SelectionRemoveEvent [remove=" + remove + ", current=" + current + ']';
+        }
+    }
+
+    /**
+     * Toggle the selected state of a primitive
+     * @author Michael Zangl
+     * @since xxx (generics)
+     * @param <O> the base type of OSM primitives
+     * @param <N> type representing OSM nodes
+     * @param <W> type representing OSM ways
+     * @param <R> type representing OSM relations
+     * @param <D> The dataset type
+     */
+    class SelectionToggleEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> extends AbstractSelectionEvent<O, N, W, R, D> {
+        private final Set<O> current;
+        private final Set<O> remove;
+        private final Set<O> add;
+
+        /**
+         * Create a {@link SelectionToggleEvent}
+         * @param source The source dataset
+         * @param old The old primitives that were previously selected. The caller needs to ensure that this set is not modified.
+         * @param toToggle The primitives to toggle.
+         */
+        public SelectionToggleEvent(D source, Set<O> old, Stream<O> toToggle) {
+            super(source, old);
+            HashSet<O> currentSet = new LinkedHashSet<>(old);
+            HashSet<O> removeSet = new LinkedHashSet<>();
+            HashSet<O> addSet = new LinkedHashSet<>();
+            toToggle.forEach(p -> {
+                if (currentSet.remove(p)) {
+                    removeSet.add(p);
+                } else {
+                    addSet.add(p);
+                    currentSet.add(p);
+                }
+            });
+            this.current = Collections.unmodifiableSet(currentSet);
+            this.remove = Collections.unmodifiableSet(removeSet);
+            this.add = Collections.unmodifiableSet(addSet);
+        }
+
+        @Override
+        public Set<O> getSelection() {
+            return current;
+        }
+
+        @Override
+        public Set<O> getRemoved() {
+            return remove;
+        }
+
+        @Override
+        public Set<O> getAdded() {
+            return add;
+        }
+
+        @Override
+        public String toString() {
+            return "SelectionToggleEvent [current=" + current + ", remove=" + remove + ", add=" + add + ']';
+        }
+    }
+}
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index 61eeb0279..229fdf40e 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -6,13 +6,16 @@ import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
@@ -24,12 +27,17 @@ import org.openstreetmap.josm.data.DataSource;
 import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
 import org.openstreetmap.josm.data.osm.BBox;
 import org.openstreetmap.josm.data.osm.DataSelectionListener;
+import org.openstreetmap.josm.data.osm.DataSet;
 import org.openstreetmap.josm.data.osm.DownloadPolicy;
 import org.openstreetmap.josm.data.osm.HighlightUpdateListener;
+import org.openstreetmap.josm.data.osm.IPrimitive;
 import org.openstreetmap.josm.data.osm.OsmData;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
 import org.openstreetmap.josm.data.osm.PrimitiveId;
 import org.openstreetmap.josm.data.osm.UploadPolicy;
 import org.openstreetmap.josm.data.osm.WaySegment;
+import org.openstreetmap.josm.data.osm.event.IDataSelectionEventSource;
+import org.openstreetmap.josm.data.osm.event.IDataSelectionListener;
 import org.openstreetmap.josm.gui.mappaint.ElemStyles;
 import org.openstreetmap.josm.tools.ListenerList;
 import org.openstreetmap.josm.tools.Logging;
@@ -41,11 +49,10 @@ import org.openstreetmap.josm.tools.SubclassFilteredCollection;
  * @author Taylor Smock
  * @since xxx
  */
-public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, VectorWay, VectorRelation> {
+public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, VectorWay, VectorRelation>, IDataSelectionEventSource<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> {
     // Note: In Java 8, computeIfAbsent is blocking for both pre-existing and new values. In Java 9, it is only blocking
     // for new values (perf increase). See JDK-8161372 for more info.
     private final Map<Integer, VectorDataStore> dataStoreMap = new ConcurrentHashMap<>();
-    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();
@@ -55,6 +62,17 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     private String name;
     private short mappaintCacheIdx = 1;
 
+    private final Object selectionLock = new Object();
+    /**
+     * The current selected primitives. This is always a unmodifiable set.
+     *
+     * The set should be ordered in the order in which the primitives have been added to the selection.
+     */
+    private Set<PrimitiveId> currentSelectedPrimitives = Collections.emptySet();
+
+    private final ListenerList<IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet>> listeners =
+            ListenerList.create();
+
     private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
 
     /**
@@ -298,7 +316,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     public Collection<VectorPrimitive> getAllSelected() {
         final Optional<VectorDataStore> dataStore = this.getBestZoomDataStore();
         return dataStore.map(vectorDataStore -> vectorDataStore.getAllPrimitives().stream()
-          .filter(primitive -> this.selected.contains(primitive.getPrimitiveId()))
+          .filter(primitive -> this.currentSelectedPrimitives.contains(primitive.getPrimitiveId()))
           .collect(Collectors.toList())).orElse(Collections.emptyList());
     }
 
@@ -334,12 +352,12 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     @Override
     public boolean selectionEmpty() {
-        return this.selected.isEmpty();
+        return this.currentSelectedPrimitives.isEmpty();
     }
 
     @Override
     public boolean isSelected(VectorPrimitive osm) {
-        return this.selected.contains(osm.getPrimitiveId());
+        return this.currentSelectedPrimitives.contains(osm.getPrimitiveId());
     }
 
     @Override
@@ -353,13 +371,8 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     }
 
     private void toggleSelectedImpl(Stream<? extends PrimitiveId> osm) {
-        osm.forEach(primitiveId -> {
-            if (this.selected.contains(primitiveId)) {
-                this.selected.remove(primitiveId);
-            } else {
-                this.selected.add(primitiveId);
-            }
-        });
+        this.doSelectionChange(old -> new IDataSelectionListener.SelectionToggleEvent<>(this, old,
+                osm.map(this::getPrimitiveById).filter(Objects::nonNull)));
     }
 
     @Override
@@ -373,8 +386,8 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     }
 
     private void setSelectedImpl(Stream<? extends PrimitiveId> osm) {
-        this.selected.clear();
-        osm.forEach(this.selected::add);
+        this.doSelectionChange(old -> new IDataSelectionListener.SelectionReplaceEvent<>(this, old,
+                osm.map(this::getPrimitiveById).filter(Objects::nonNull)));
     }
 
     @Override
@@ -388,7 +401,8 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     }
 
     private void addSelectedImpl(Stream<? extends PrimitiveId> osm) {
-        osm.forEach(this.selected::add);
+        this.doSelectionChange(old -> new IDataSelectionListener.SelectionAddEvent<>(this, old,
+                osm.map(this::getPrimitiveById).filter(Objects::nonNull)));
     }
 
     @Override
@@ -403,11 +417,35 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     @Override
     public void clearSelection() {
-        this.clearSelectionImpl(new ArrayList<>(this.selected).stream());
+        this.clearSelectionImpl(new ArrayList<>(this.currentSelectedPrimitives).stream());
     }
 
     private void clearSelectionImpl(Stream<? extends PrimitiveId> osm) {
-        osm.forEach(this.selected::remove);
+        this.doSelectionChange(old -> new IDataSelectionListener.SelectionRemoveEvent<>(this, old,
+                osm.map(this::getPrimitiveById).filter(Objects::nonNull)));
+    }
+
+    /**
+     * Do a selection change.
+     * <p>
+     * This is the only method that changes the current selection state.
+     * @param command A generator that generates the {@link DataSelectionListener.SelectionChangeEvent}
+     *                for the given base set of currently selected primitives.
+     * @return true iff the command did change the selection.
+     */
+    private boolean doSelectionChange(final Function<Set<VectorPrimitive>,
+            IDataSelectionListener.SelectionChangeEvent<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet>> command) {
+        synchronized (this.selectionLock) {
+            IDataSelectionListener.SelectionChangeEvent<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> event =
+                    command.apply(currentSelectedPrimitives.stream().map(this::getPrimitiveById).collect(Collectors.toSet()));
+            if (event.isNop()) {
+                return false;
+            }
+            this.currentSelectedPrimitives = event.getSelection().stream().map(IPrimitive::getPrimitiveId)
+                    .collect(Collectors.toCollection(LinkedHashSet::new));
+            this.listeners.fireEvent(l -> l.selectionChanged(event));
+            return true;
+        }
     }
 
     @Override
@@ -557,4 +595,20 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
         this.dataStoreMap.values().forEach(dataStore -> dataStore.getAllPrimitives().parallelStream()
                 .forEach(primitive -> primitive.setVisible(!temporaryList.contains(primitive.getLayer()))));
     }
+
+    @Override
+    public boolean addListener(IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> listener) {
+        if (!this.listeners.containsListener(listener)) {
+            this.listeners.addListener(listener);
+        }
+        return this.listeners.containsListener(listener);
+    }
+
+    @Override
+    public boolean removeListener(IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> listener) {
+        if (this.listeners.containsListener(listener)) {
+            this.listeners.removeListener(listener);
+        }
+        return this.listeners.containsListener(listener);
+    }
 }
-- 
GitLab


From 46085d1dadda30d4b690776b9ffb02932aa85c0a Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Tue, 27 Apr 2021 16:31:32 -0600
Subject: [PATCH 21/50] BBox: addPrimitive: Overload so that IPrimitives work

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 src/org/openstreetmap/josm/data/osm/BBox.java | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/src/org/openstreetmap/josm/data/osm/BBox.java b/src/org/openstreetmap/josm/data/osm/BBox.java
index e16f984d1..7dcc79a41 100644
--- a/src/org/openstreetmap/josm/data/osm/BBox.java
+++ b/src/org/openstreetmap/josm/data/osm/BBox.java
@@ -174,6 +174,16 @@ public class BBox implements IBounds {
      * @param extraSpace the value to extend the primitives bbox. Unit is in LatLon degrees.
      */
     public void addPrimitive(OsmPrimitive primitive, double extraSpace) {
+        this.addPrimitive((IPrimitive) primitive, extraSpace);
+    }
+
+    /**
+     * Extends this bbox to include the bbox of the primitive extended by extraSpace.
+     * @param primitive an primitive
+     * @param extraSpace the value to extend the primitives bbox. Unit is in LatLon degrees.
+     * @since xxx
+     */
+    public void addPrimitive(IPrimitive primitive, double extraSpace) {
         IBounds primBbox = primitive.getBBox();
         add(primBbox.getMinLon() - extraSpace, primBbox.getMinLat() - extraSpace);
         add(primBbox.getMaxLon() + extraSpace, primBbox.getMaxLat() + extraSpace);
-- 
GitLab


From f898d4e21e97655892cea722693c27f0a8408931 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Wed, 28 Apr 2021 12:18:03 -0600
Subject: [PATCH 22/50] Vector Data: Performance fix, fix an
 UnsupportedOperationException

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../openstreetmap/josm/data/vector/DataStore.java   | 12 +++++++-----
 .../josm/data/vector/VectorDataSet.java             | 13 +++++++++----
 2 files changed, 16 insertions(+), 9 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/DataStore.java b/src/org/openstreetmap/josm/data/vector/DataStore.java
index 86f1948d1..d3fb4ac59 100644
--- a/src/org/openstreetmap/josm/data/vector/DataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/DataStore.java
@@ -49,8 +49,8 @@ class DataStore<O extends IPrimitive, N extends INode, W extends IWay<N>, R exte
     protected final Storage<O> allPrimitives = new Storage<>(new Storage.PrimitiveIdHash(), true);
     // TODO what happens when I use hashCode?
     protected final Set<Tile> addedTiles = Collections.synchronizedSet(new HashSet<>());
-    protected final Map<PrimitiveId, O> primitivesMap = allPrimitives
-      .foreignKey(new Storage.PrimitiveIdHash());
+    protected final Map<PrimitiveId, O> primitivesMap = Collections.synchronizedMap(allPrimitives
+      .foreignKey(new Storage.PrimitiveIdHash()));
     protected final Collection<DataSource> dataSources = new LinkedList<>();
     private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
 
@@ -70,10 +70,12 @@ class DataStore<O extends IPrimitive, N extends INode, W extends IWay<N>, R exte
         return this.allPrimitives;
     }
 
+    /**
+     * Get the primitives map.
+     * @implNote The returned map is a {@link Collections#synchronizedMap}. Please synchronize on it.
+     * @return The Primitives map.
+     */
     public Map<PrimitiveId, O> getPrimitivesMap() {
-        if (this.readWriteLock.isWriteLocked()) {
-            return new HashMap<>(this.primitivesMap);
-        }
         return this.primitivesMap;
     }
 
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index 229fdf40e..b262194ab 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -34,6 +34,7 @@ import org.openstreetmap.josm.data.osm.IPrimitive;
 import org.openstreetmap.josm.data.osm.OsmData;
 import org.openstreetmap.josm.data.osm.OsmPrimitive;
 import org.openstreetmap.josm.data.osm.PrimitiveId;
+import org.openstreetmap.josm.data.osm.Storage;
 import org.openstreetmap.josm.data.osm.UploadPolicy;
 import org.openstreetmap.josm.data.osm.WaySegment;
 import org.openstreetmap.josm.data.osm.event.IDataSelectionEventSource;
@@ -314,10 +315,14 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     @Override
     public Collection<VectorPrimitive> getAllSelected() {
-        final Optional<VectorDataStore> dataStore = this.getBestZoomDataStore();
-        return dataStore.map(vectorDataStore -> vectorDataStore.getAllPrimitives().stream()
-          .filter(primitive -> this.currentSelectedPrimitives.contains(primitive.getPrimitiveId()))
-          .collect(Collectors.toList())).orElse(Collections.emptyList());
+        final Map<PrimitiveId, VectorPrimitive> dataStore = this.getBestZoomDataStore().map(VectorDataStore::getPrimitivesMap).orElse(null);
+        if (dataStore != null) {
+            // The dataStore is a final variable from the VectorDataStore.
+            synchronized (dataStore) {
+                return this.currentSelectedPrimitives.stream().map(dataStore::get).collect(Collectors.toList());
+            }
+        }
+        return Collections.emptyList();
     }
 
     /**
-- 
GitLab


From 3d0eb727137005aa60ff0bd48dd1acfc38453d57 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Wed, 28 Apr 2021 13:08:57 -0600
Subject: [PATCH 23/50] VectorData: Add method to highlight primitives

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/vector/VectorDataSet.java       | 23 ++++++++++++++++++-
 1 file changed, 22 insertions(+), 1 deletion(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index b262194ab..7e82030eb 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -101,6 +101,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
      * The paint style for this layer
      */
     private ElemStyles styles;
+    private final Collection<PrimitiveId> highlighted = new HashSet<>();
 
     @Override
     public Collection<DataSource> getDataSources() {
@@ -303,6 +304,26 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
         // TODO? This requires a change to WaySegment so that it isn't Way/Node specific
     }
 
+    /**
+     * Mark some primitives as highlighted
+     * @param primitives The primitives to highlight
+     * @apiNote This is *highly likely* to change, as the inherited methods are modified to accept primitives other than OSM primitives.
+     */
+    public void setHighlighted(Collection<PrimitiveId> primitives) {
+        this.highlighted.clear();
+        this.highlighted.addAll(primitives);
+        // The highlight event updates are very OSM specific, and require a DataSet.
+        this.highlightUpdateListenerListenerList.fireEvent(event -> event.highlightUpdated(null));
+    }
+
+    /**
+     * Get the highlighted objects
+     * @return The highlighted objects
+     */
+    public Collection<PrimitiveId> getHighlighted() {
+        return Collections.unmodifiableCollection(this.highlighted);
+    }
+
     @Override
     public void addHighlightUpdateListener(HighlightUpdateListener listener) {
         this.highlightUpdateListenerListenerList.addListener(listener);
@@ -319,7 +340,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
         if (dataStore != null) {
             // The dataStore is a final variable from the VectorDataStore.
             synchronized (dataStore) {
-                return this.currentSelectedPrimitives.stream().map(dataStore::get).collect(Collectors.toList());
+                return this.currentSelectedPrimitives.stream().map(dataStore::get).filter(Objects::nonNull).collect(Collectors.toList());
             }
         }
         return Collections.emptyList();
-- 
GitLab


From fccaf1e53603d221f18667995da72cb6f37ea12f Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 29 Apr 2021 11:32:34 -0600
Subject: [PATCH 24/50] VectorTiles: Rework to avoid locks preventing paint

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../data/imagery/vectortile/mapbox/Layer.java |  10 +-
 .../imagery/vectortile/mapbox/MVTTile.java    |  70 +++--
 src/org/openstreetmap/josm/data/osm/BBox.java |   5 +-
 .../osm/event/IDataSelectionEventSource.java  |   5 +-
 .../osm/event/IDataSelectionListener.java     |  35 ++-
 .../josm/data/vector/DataStore.java           |  10 -
 .../josm/data/vector/VectorDataSet.java       | 249 ++++++++++--------
 .../josm/data/vector/VectorDataStore.java     |  82 ++----
 8 files changed, 240 insertions(+), 226 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
index 1c496d55d..0a6bb073e 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
@@ -16,6 +16,7 @@ import java.util.stream.Collectors;
 
 import org.openstreetmap.josm.data.protobuf.ProtoBufParser;
 import org.openstreetmap.josm.data.protobuf.ProtoBufRecord;
+import org.openstreetmap.josm.tools.Destroyable;
 import org.openstreetmap.josm.tools.Logging;
 
 /**
@@ -23,7 +24,7 @@ import org.openstreetmap.josm.tools.Logging;
  * @author Taylor Smock
  * @since xxx
  */
-public final class Layer {
+public final class Layer implements Destroyable {
     private static final class ValueFields<T> {
         static final ValueFields<String> STRING = new ValueFields<>(1, ProtoBufRecord::asString);
         static final ValueFields<Float> FLOAT = new ValueFields<>(2, ProtoBufRecord::asFloat);
@@ -224,6 +225,13 @@ public final class Layer {
         return this.version;
     }
 
+    @Override
+    public void destroy() {
+        this.featureCollection.clear();
+        this.keyList.clear();
+        this.valueList.clear();
+    }
+
     @Override
     public boolean equals(Object other) {
         if (other instanceof Layer) {
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
index 5d1d781dd..ab77c43f4 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
@@ -1,33 +1,39 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.imagery.vectortile.mapbox;
 
-import java.awt.image.BufferedImage;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.stream.Collectors;
-
 import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
 import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
+import org.openstreetmap.josm.data.IQuadBucketType;
 import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
+import org.openstreetmap.josm.data.osm.BBox;
 import org.openstreetmap.josm.data.protobuf.ProtoBufParser;
 import org.openstreetmap.josm.data.protobuf.ProtoBufRecord;
+import org.openstreetmap.josm.data.vector.VectorDataStore;
 import org.openstreetmap.josm.tools.ListenerList;
 import org.openstreetmap.josm.tools.Logging;
 
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.stream.Collectors;
+
 /**
  * A class for MapBox Vector Tiles
  *
  * @author Taylor Smock
  * @since xxx
  */
-public class MVTTile extends Tile implements VectorTile {
+public class MVTTile extends Tile implements VectorTile, IQuadBucketType {
     private final ListenerList<TileListener> listenerList = ListenerList.create();
     private Collection<Layer> layers;
     private int extent = Layer.DEFAULT_EXTENT;
     static final BufferedImage CLEAR_LOADED = new BufferedImage(1, 1, BufferedImage.TYPE_4BYTE_ABGR);
+    private BBox bbox;
+    private VectorDataStore vectorDataStore;
 
     /**
      * Create a new Tile
@@ -47,25 +53,30 @@ public class MVTTile extends Tile implements VectorTile {
             ProtoBufParser parser = new ProtoBufParser(inputStream);
             Collection<ProtoBufRecord> protoBufRecords = parser.allRecords();
             this.layers = new HashSet<>();
-            this.layers = protoBufRecords.stream().map(record -> {
+            this.layers = protoBufRecords.stream().map(protoBufRecord -> {
                 Layer mvtLayer = null;
-                if (record.getField() == Layer.LAYER_FIELD) {
-                    try (ProtoBufParser tParser = new ProtoBufParser(record.getBytes())) {
+                if (protoBufRecord.getField() == Layer.LAYER_FIELD) {
+                    try (ProtoBufParser tParser = new ProtoBufParser(protoBufRecord.getBytes())) {
                         mvtLayer = new Layer(tParser.allRecords());
                     } catch (IOException e) {
                         Logging.error(e);
                     } finally {
                         // Cleanup bytes
-                        record.close();
+                        protoBufRecord.close();
                     }
                 }
                 return mvtLayer;
             }).collect(Collectors.toCollection(HashSet::new));
             this.extent = layers.stream().map(Layer::getExtent).max(Integer::compare).orElse(Layer.DEFAULT_EXTENT);
-            this.finishLoading();
-            this.listenerList.fireEvent(event -> event.finishedLoading(this));
-            // Ensure that we don't keep the loading image around
-            this.image = CLEAR_LOADED;
+            if (this.getData() != null) {
+                this.finishLoading();
+                this.listenerList.fireEvent(event -> event.finishedLoading(this));
+                // Ensure that we don't keep the loading image around
+                this.image = CLEAR_LOADED;
+                // Cleanup as much as possible -- layers will still exist, but only base information (like name, extent) will remain.
+                // Called last just in case the listeners need the layers.
+                this.layers.forEach(Layer::destroy);
+            }
         }
     }
 
@@ -89,6 +100,31 @@ public class MVTTile extends Tile implements VectorTile {
         this.listenerList.addWeakListener(listener);
     }
 
+    @Override
+    public BBox getBBox() {
+        if (this.bbox == null) {
+            final ICoordinate upperLeft = this.getTileSource().tileXYToLatLon(this);
+            final ICoordinate lowerRight = this.getTileSource()
+                    .tileXYToLatLon(this.getXtile() + 1, this.getYtile() + 1, this.getZoom());
+            BBox newBBox = new BBox(upperLeft.getLon(), upperLeft.getLat(), lowerRight.getLon(), lowerRight.getLat());
+            this.bbox = newBBox.toImmutable();
+        }
+        return this.bbox;
+    }
+
+    /**
+     * Get the datastore for this tile
+     * @return The data
+     */
+    public VectorDataStore getData() {
+        if (this.vectorDataStore == null) {
+            VectorDataStore newDataStore = new VectorDataStore();
+            newDataStore.addDataTile(this);
+            this.vectorDataStore = newDataStore;
+        }
+        return this.vectorDataStore;
+    }
+
     /**
      * A class that can be notified that a tile has finished loading
      *
diff --git a/src/org/openstreetmap/josm/data/osm/BBox.java b/src/org/openstreetmap/josm/data/osm/BBox.java
index 7dcc79a41..106037ec9 100644
--- a/src/org/openstreetmap/josm/data/osm/BBox.java
+++ b/src/org/openstreetmap/josm/data/osm/BBox.java
@@ -465,8 +465,9 @@ public class BBox implements IBounds {
     /**
      * Returns an immutable version of this bbox, i.e., modifying calls throw an {@link UnsupportedOperationException}.
      * @return an immutable version of this bbox
+     * @since xxx (interface)
      */
-    BBox toImmutable() {
+    public BBox toImmutable() {
         return new Immutable(this);
     }
 
@@ -482,7 +483,7 @@ public class BBox implements IBounds {
         }
 
         @Override
-        BBox toImmutable() {
+        public BBox toImmutable() {
             return this;
         }
     }
diff --git a/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java b/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java
index e5fc0ea19..4f1d75d18 100644
--- a/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java
+++ b/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java
@@ -10,14 +10,15 @@ import org.openstreetmap.josm.data.osm.OsmData;
 /**
  * This interface indicates that the class can fire {@link IDataSelectionListener}.
  * @author Taylor Smock, Michael Zangl (original code)
- * @since xxx
  * @param <O> the base type of OSM primitives
  * @param <N> type representing OSM nodes
  * @param <W> type representing OSM ways
  * @param <R> type representing OSM relations
  * @param <D> The dataset type
+ * @since xxx
  */
-public interface IDataSelectionEventSource<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> {
+public interface IDataSelectionEventSource<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>,
+       D extends OsmData<O, N, W, R>> {
     /**
      * Add a listener
      * @param listener The listener to add
diff --git a/src/org/openstreetmap/josm/data/osm/event/IDataSelectionListener.java b/src/org/openstreetmap/josm/data/osm/event/IDataSelectionListener.java
index 5a95aa1c9..7550e8dbd 100644
--- a/src/org/openstreetmap/josm/data/osm/event/IDataSelectionListener.java
+++ b/src/org/openstreetmap/josm/data/osm/event/IDataSelectionListener.java
@@ -20,15 +20,16 @@ import java.util.stream.Stream;
 /**
  * This interface is the same as {@link DataSelectionListener}, except it isn't {@link OsmPrimitive} specific.
  * @author Taylor Smock, Michael Zangl (original code)
- * @since xxx
  * @param <O> the base type of OSM primitives
  * @param <N> type representing OSM nodes
  * @param <W> type representing OSM ways
  * @param <R> type representing OSM relations
  * @param <D> The dataset type
+ * @since xxx
  */
 @FunctionalInterface
-public interface IDataSelectionListener<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> {
+public interface IDataSelectionListener<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>,
+       D extends OsmData<O, N, W, R>> {
     /**
      * Called whenever the selection is changed.
      *
@@ -41,14 +42,15 @@ public interface IDataSelectionListener<O extends IPrimitive, N extends INode, W
     /**
      * The event that is fired when the selection changed.
      * @author Michael Zangl
-     * @since xxx generics
      * @param <O> the base type of OSM primitives
      * @param <N> type representing OSM nodes
      * @param <W> type representing OSM ways
      * @param <R> type representing OSM relations
      * @param <D> The dataset type
+     * @since xxx generics
      */
-    interface SelectionChangeEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> {
+    interface SelectionChangeEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>,
+              D extends OsmData<O, N, W, R>> {
         /**
          * Gets the previous selection
          * <p>
@@ -105,14 +107,15 @@ public interface IDataSelectionListener<O extends IPrimitive, N extends INode, W
     /**
      * The base class for selection events
      * @author Michael Zangl
-     * @since 12048, xxx (generics)
      * @param <O> the base type of OSM primitives
      * @param <N> type representing OSM nodes
      * @param <W> type representing OSM ways
      * @param <R> type representing OSM relations
      * @param <D> The dataset type
+     * @since 12048, xxx (generics)
      */
-    abstract class AbstractSelectionEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> implements SelectionChangeEvent<O, N, W, R, D> {
+    abstract class AbstractSelectionEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>,
+             D extends OsmData<O, N, W, R>> implements SelectionChangeEvent<O, N, W, R, D> {
         private final D source;
         private final Set<O> old;
 
@@ -137,14 +140,15 @@ public interface IDataSelectionListener<O extends IPrimitive, N extends INode, W
     /**
      * The selection is replaced by a new selection
      * @author Michael Zangl
-     * @since xxx (generics)
      * @param <O> the base type of OSM primitives
      * @param <N> type representing OSM nodes
      * @param <W> type representing OSM ways
      * @param <R> type representing OSM relations
      * @param <D> The dataset type
+     * @since xxx (generics)
      */
-    class SelectionReplaceEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> extends AbstractSelectionEvent<O, N, W, R, D> {
+    class SelectionReplaceEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>>
+        extends AbstractSelectionEvent<O, N, W, R, D> {
         private final Set<O> current;
         private Set<O> removed;
         private Set<O> added;
@@ -193,14 +197,15 @@ public interface IDataSelectionListener<O extends IPrimitive, N extends INode, W
     /**
      * Primitives are added to the selection
      * @author Michael Zangl
-     * @since xxx (generics)
      * @param <O> the base type of OSM primitives
      * @param <N> type representing OSM nodes
      * @param <W> type representing OSM ways
      * @param <R> type representing OSM relations
      * @param <D> The dataset type
+     * @since xxx (generics)
      */
-    class SelectionAddEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> extends AbstractSelectionEvent<O, N, W, R, D> {
+    class SelectionAddEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>>
+        extends AbstractSelectionEvent<O, N, W, R, D> {
         private final Set<O> add;
         private final Set<O> current;
 
@@ -247,14 +252,15 @@ public interface IDataSelectionListener<O extends IPrimitive, N extends INode, W
     /**
      * Primitives are removed from the selection
      * @author Michael Zangl
-     * @since 12048, xxx (generics)
      * @param <O> the base type of OSM primitives
      * @param <N> type representing OSM nodes
      * @param <W> type representing OSM ways
      * @param <R> type representing OSM relations
      * @param <D> The dataset type
+     * @since 12048, xxx (generics)
      */
-    class SelectionRemoveEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> extends AbstractSelectionEvent<O, N, W, R, D> {
+    class SelectionRemoveEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>>
+        extends AbstractSelectionEvent<O, N, W, R, D> {
         private final Set<O> remove;
         private final Set<O> current;
 
@@ -302,14 +308,15 @@ public interface IDataSelectionListener<O extends IPrimitive, N extends INode, W
     /**
      * Toggle the selected state of a primitive
      * @author Michael Zangl
-     * @since xxx (generics)
      * @param <O> the base type of OSM primitives
      * @param <N> type representing OSM nodes
      * @param <W> type representing OSM ways
      * @param <R> type representing OSM relations
      * @param <D> The dataset type
+     * @since xxx (generics)
      */
-    class SelectionToggleEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>> extends AbstractSelectionEvent<O, N, W, R, D> {
+    class SelectionToggleEvent<O extends IPrimitive, N extends INode, W extends IWay<N>, R extends IRelation<?>, D extends OsmData<O, N, W, R>>
+        extends AbstractSelectionEvent<O, N, W, R, D> {
         private final Set<O> current;
         private final Set<O> remove;
         private final Set<O> add;
diff --git a/src/org/openstreetmap/josm/data/vector/DataStore.java b/src/org/openstreetmap/josm/data/vector/DataStore.java
index d3fb4ac59..9f942a0d6 100644
--- a/src/org/openstreetmap/josm/data/vector/DataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/DataStore.java
@@ -3,7 +3,6 @@ package org.openstreetmap.josm.data.vector;
 
 import java.util.Collection;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.Map;
@@ -44,7 +43,6 @@ class DataStore<O extends IPrimitive, N extends INode, W extends IWay<N>, R exte
         }
     }
 
-    protected final int zoom;
     protected final LocalQuadBucketPrimitiveStore<N, W, R> store = new LocalQuadBucketPrimitiveStore<>();
     protected final Storage<O> allPrimitives = new Storage<>(new Storage.PrimitiveIdHash(), true);
     // TODO what happens when I use hashCode?
@@ -54,14 +52,6 @@ class DataStore<O extends IPrimitive, N extends INode, W extends IWay<N>, R exte
     protected final Collection<DataSource> dataSources = new LinkedList<>();
     private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
 
-    DataStore(int zoom) {
-        this.zoom = zoom;
-    }
-
-    public int getZoom() {
-        return this.zoom;
-    }
-
     public QuadBucketPrimitiveStore<N, W, R> getStore() {
         return this.store;
     }
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index 7e82030eb..55366d555 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -1,6 +1,25 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.vector;
 
+import org.openstreetmap.josm.data.DataSource;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.data.osm.DataSelectionListener;
+import org.openstreetmap.josm.data.osm.DownloadPolicy;
+import org.openstreetmap.josm.data.osm.HighlightUpdateListener;
+import org.openstreetmap.josm.data.osm.IPrimitive;
+import org.openstreetmap.josm.data.osm.OsmData;
+import org.openstreetmap.josm.data.osm.PrimitiveId;
+import org.openstreetmap.josm.data.osm.Storage;
+import org.openstreetmap.josm.data.osm.UploadPolicy;
+import org.openstreetmap.josm.data.osm.WaySegment;
+import org.openstreetmap.josm.data.osm.event.IDataSelectionEventSource;
+import org.openstreetmap.josm.data.osm.event.IDataSelectionListener;
+import org.openstreetmap.josm.gui.mappaint.ElemStyles;
+import org.openstreetmap.josm.tools.ListenerList;
+import org.openstreetmap.josm.tools.Logging;
+import org.openstreetmap.josm.tools.SubclassFilteredCollection;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -19,46 +38,22 @@ import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
-import java.util.stream.IntStream;
 import java.util.stream.Stream;
 
-import org.openstreetmap.gui.jmapviewer.Tile;
-import org.openstreetmap.josm.data.DataSource;
-import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
-import org.openstreetmap.josm.data.osm.BBox;
-import org.openstreetmap.josm.data.osm.DataSelectionListener;
-import org.openstreetmap.josm.data.osm.DataSet;
-import org.openstreetmap.josm.data.osm.DownloadPolicy;
-import org.openstreetmap.josm.data.osm.HighlightUpdateListener;
-import org.openstreetmap.josm.data.osm.IPrimitive;
-import org.openstreetmap.josm.data.osm.OsmData;
-import org.openstreetmap.josm.data.osm.OsmPrimitive;
-import org.openstreetmap.josm.data.osm.PrimitiveId;
-import org.openstreetmap.josm.data.osm.Storage;
-import org.openstreetmap.josm.data.osm.UploadPolicy;
-import org.openstreetmap.josm.data.osm.WaySegment;
-import org.openstreetmap.josm.data.osm.event.IDataSelectionEventSource;
-import org.openstreetmap.josm.data.osm.event.IDataSelectionListener;
-import org.openstreetmap.josm.gui.mappaint.ElemStyles;
-import org.openstreetmap.josm.tools.ListenerList;
-import org.openstreetmap.josm.tools.Logging;
-import org.openstreetmap.josm.tools.SubclassFilteredCollection;
-
 /**
  * A data class for Vector Data
  *
  * @author Taylor Smock
  * @since xxx
  */
-public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, VectorWay, VectorRelation>, IDataSelectionEventSource<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> {
+public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, VectorWay, VectorRelation>,
+       IDataSelectionEventSource<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> {
     // Note: In Java 8, computeIfAbsent is blocking for both pre-existing and new values. In Java 9, it is only blocking
     // for new values (perf increase). See JDK-8161372 for more info.
-    private final Map<Integer, VectorDataStore> dataStoreMap = new ConcurrentHashMap<>();
+    private final Map<Integer, Storage<MVTTile>> dataStoreMap = new ConcurrentHashMap<>();
     // 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 static final String[] NO_INVISIBLE_LAYERS = new String[0];
-    private String[] invisibleLayers = NO_INVISIBLE_LAYERS;
     private boolean lock = true;
     private String name;
     private short mappaintCacheIdx = 1;
@@ -105,20 +100,8 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     @Override
     public Collection<DataSource> getDataSources() {
-        final int currentZoom = this.zoom;
-        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, tZoom -> new VectorDataStore(this, tZoom));
-        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, tZoom -> new VectorDataStore(this, tZoom));
-        dataStore.addDataSource(dataSource);
+        // TODO
+        return Collections.emptyList();
     }
 
     @Override
@@ -153,23 +136,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     @Override
     public void addPrimitive(VectorPrimitive primitive) {
-        primitive.setDataSet(this);
-        final int currentZoom = this.zoom;
-        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, tZoom -> new VectorDataStore(this, tZoom));
-        tryWrite(dataStore, () -> dataStore.addPrimitive(primitive));
-    }
-
-    /**
-     * Remove a primitive from this dataset
-     *
-     * @param primitive The primitive to remove
-     */
-    protected void removePrimitive(VectorPrimitive primitive) {
-        if (primitive.getDataSet() == this) {
-            primitive.setDataSet(null);
-            this.dataStoreMap.values()
-              .forEach(vectorDataStore -> tryWrite(vectorDataStore, () -> vectorDataStore.removePrimitive(primitive)));
-        }
+        throw new UnsupportedOperationException("Custom vector primitives are not currently supported");
     }
 
     @Override
@@ -181,59 +148,108 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     @Override
     public List<VectorNode> searchNodes(BBox bbox) {
-        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.searchNodes(bbox))
-          .orElseGet(Collections::emptyList);
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            if (dataStore != null) {
+                return dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
+                        .flatMap(store -> store.searchNodes(bbox).stream()).collect(Collectors.toList());
+            }
+            return null;
+        }).orElseGet(Collections::emptyList);
     }
 
     @Override
     public boolean containsNode(VectorNode vectorNode) {
-        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.containsNode(vectorNode)).orElse(false);
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            return dataStore != null &&
+                    dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
+                            .anyMatch(store -> store.containsNode(vectorNode));
+        }).orElse(Boolean.FALSE);
     }
 
     @Override
     public List<VectorWay> searchWays(BBox bbox) {
-        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.searchWays(bbox))
-          .orElseGet(Collections::emptyList);
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            if (dataStore != null) {
+                return dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
+                        .flatMap(store -> store.searchWays(bbox).stream()).collect(Collectors.toList());
+            }
+            return null;
+        }).orElseGet(Collections::emptyList);
     }
 
     @Override
     public boolean containsWay(VectorWay vectorWay) {
-        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.containsWay(vectorWay)).orElse(false);
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            return dataStore != null &&
+                    dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
+                            .anyMatch(store -> store.containsWay(vectorWay));
+        }).orElse(Boolean.FALSE);
     }
 
     @Override
     public List<VectorRelation> searchRelations(BBox bbox) {
-        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.searchRelations(bbox))
-          .orElseGet(Collections::emptyList);
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            if (dataStore != null) {
+                return dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
+                        .flatMap(store -> store.searchRelations(bbox).stream()).collect(Collectors.toList());
+            }
+            return null;
+        }).orElseGet(Collections::emptyList);
     }
 
     @Override
     public boolean containsRelation(VectorRelation vectorRelation) {
-        return this.getBestZoomDataStore().map(VectorDataStore::getStore).map(store -> store.containsRelation(vectorRelation)).orElse(false);
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            return dataStore != null &&
+                    dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
+                            .anyMatch(store -> store.containsRelation(vectorRelation));
+        }).orElse(Boolean.FALSE);
     }
 
+    /**
+     * Get a primitive for an id
+     * @param primitiveId type and uniqueId of the primitive. Might be &lt; 0 for newly created primitives
+     * @return The primitive for the id. Please note that since this is vector data, there may be more primitives with this id.
+     * Please use {@link #getPrimitivesById(PrimitiveId...)} to get all primitives for that {@link PrimitiveId}.
+     */
     @Override
     public VectorPrimitive getPrimitiveById(PrimitiveId primitiveId) {
-        return this.getBestZoomDataStore().map(VectorDataStore::getPrimitivesMap).map(m -> m .get(primitiveId)).orElse(null);
+        return this.getPrimitivesById(primitiveId).findFirst().orElse(null);
+    }
+
+    /**
+     * Get all primitives for ids
+     * @param primitiveIds The ids to search for
+     * @return The primitives for the ids (note: as this is vector data, a {@link PrimitiveId} may have multiple associated primitives)
+     */
+    public Stream<VectorPrimitive> getPrimitivesById(PrimitiveId... primitiveIds) {
+        final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+        if (dataStore != null) {
+            return dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getPrimitivesMap)
+                    .flatMap(m -> Stream.of(primitiveIds).map(m::get));
+        }
+        return Stream.empty();
     }
 
-    // The last return statement is "unchecked", even though it is literally the same as the previous return, except
-    // as an optional.
-    @SuppressWarnings("unchecked")
     @Override
     public <T extends VectorPrimitive> Collection<T> getPrimitives(
       Predicate<? super VectorPrimitive> predicate) {
-        final VectorDataStore dataStore = this.getBestZoomDataStore().orElse(null);
-        if (dataStore == null) {
-            return Collections.emptyList();
-        }
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            if (dataStore == null) {
+                return null;
+            }
 
-        if (dataStore.getReadWriteLock().isWriteLocked()) {
-            return new SubclassFilteredCollection<>(new HashSet<>(dataStore.getAllPrimitives()), predicate);
-        }
-        return (Collection<T>) tryRead(dataStore, () -> new SubclassFilteredCollection<>(dataStore.getAllPrimitives(), predicate))
-          // Throw an NPE if we don't have a collection (this should never happen, so if it does, _something_ is wrong)
-          .orElseThrow(NullPointerException::new);
+            // This cast is needed (otherwise, Collections.emptyList doesn't compile)
+            return (Collection<T>) new SubclassFilteredCollection<>(dataStore.stream().map(MVTTile::getData)
+                    .map(VectorDataStore::getAllPrimitives).flatMap(Collection::stream).distinct().collect(Collectors.toList()), predicate);
+        }).orElseGet(Collections::emptyList);
     }
 
     @Override
@@ -278,8 +294,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
      */
     @Override
     public Lock getReadLock() {
-        return getBestZoomDataStore().map(VectorDataStore::getReadWriteLock).map(ReentrantReadWriteLock::readLock)
-          .orElse(this.readWriteLock.readLock());
+        return this.readWriteLock.readLock();
     }
 
     @Override
@@ -336,21 +351,28 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     @Override
     public Collection<VectorPrimitive> getAllSelected() {
-        final Map<PrimitiveId, VectorPrimitive> dataStore = this.getBestZoomDataStore().map(VectorDataStore::getPrimitivesMap).orElse(null);
-        if (dataStore != null) {
-            // The dataStore is a final variable from the VectorDataStore.
-            synchronized (dataStore) {
-                return this.currentSelectedPrimitives.stream().map(dataStore::get).filter(Objects::nonNull).collect(Collectors.toList());
+        return tryRead(this.readWriteLock, () -> {
+            final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
+            if (dataStore != null) {
+                // The dataStore is what we don't want to concurrently modify
+                synchronized (dataStore) {
+                    return dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getPrimitivesMap).flatMap(dataMap -> {
+                        // Synchronize on dataMap to avoid concurrent modification errors
+                        synchronized (dataMap) {
+                            return this.currentSelectedPrimitives.stream().map(dataMap::get).filter(Objects::nonNull);
+                        }
+                    }).collect(Collectors.toList());
+                }
             }
-        }
-        return Collections.emptyList();
+            return null;
+        }).orElseGet(Collections::emptyList);
     }
 
     /**
      * Get the best zoom datastore
      * @return A datastore with data, or {@code null} if no good datastore exists.
      */
-    private Optional<VectorDataStore> getBestZoomDataStore() {
+    private Optional<Storage<MVTTile>> getBestZoomDataStore() {
         final int currentZoom = this.zoom;
         if (this.dataStoreMap.containsKey(currentZoom)) {
             return Optional.of(this.dataStoreMap.get(currentZoom));
@@ -519,11 +541,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
                 nearestZoom[2] = keys[index + 1];
             }
 
-            nearestZoom[3] = this.getBestZoomDataStore().map(VectorDataStore::getZoom).orElse(-1);
-            IntStream.of(keys).filter(key -> IntStream.of(nearestZoom).noneMatch(zoomKey -> zoomKey == key))
-              .mapToObj(this.dataStoreMap::get).forEach(VectorDataStore::destroy);
-            IntStream.of(keys).filter(key -> IntStream.of(nearestZoom).noneMatch(zoomKey -> zoomKey == key))
-              .forEach(this.dataStoreMap::remove);
+            // TODO cleanup zooms for memory
         }
     }
 
@@ -534,13 +552,15 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     /**
      * Add tile data to this dataset
      * @param tile The tile to add
-     * @param <T> The tile type
      */
-    public <T extends Tile & VectorTile> void addTileData(T tile) {
-        final int currentZoom = tile.getZoom();
-        // computeIfAbsent should be thread safe (ConcurrentHashMap indicates it is, anyway)
-        final VectorDataStore dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, tZoom -> new VectorDataStore(this, tZoom));
-        dataStore.addTile(tile, this.invisibleLayers);
+    public void addTileData(MVTTile tile) {
+        tryWrite(this.readWriteLock, () -> {
+            final int currentZoom = tile.getZoom();
+            // computeIfAbsent should be thread safe (ConcurrentHashMap indicates it is, anyway)
+            final Storage<MVTTile> dataStore = this.dataStoreMap.computeIfAbsent(currentZoom, tZoom -> new Storage<>());
+            tile.getData().getAllPrimitives().forEach(primitive -> primitive.setDataSet(this));
+            dataStore.add(tile);
+        });
     }
 
     /**
@@ -550,15 +570,15 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
      * @param <T>      The return type
      * @return The optional return
      */
-    private static <T> Optional<T> tryRead(VectorDataStore dataStore, Supplier<T> supplier) {
+    private static <T> Optional<T> tryRead(ReentrantReadWriteLock lock, Supplier<T> supplier) {
         try {
-            dataStore.getReadWriteLock().readLock().lockInterruptibly();
+            lock.readLock().lockInterruptibly();
             return Optional.ofNullable(supplier.get());
         } catch (InterruptedException e) {
             Logging.error(e);
             Thread.currentThread().interrupt();
         } finally {
-            dataStore.getReadWriteLock().readLock().unlock();
+            lock.readLock().unlock();
         }
         return Optional.empty();
     }
@@ -568,16 +588,16 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
      *
      * @param runnable The writing function
      */
-    private static void tryWrite(VectorDataStore dataStore, Runnable runnable) {
+    private static void tryWrite(ReentrantReadWriteLock lock, Runnable runnable) {
         try {
-            dataStore.getReadWriteLock().writeLock().lockInterruptibly();
+            lock.writeLock().lockInterruptibly();
             runnable.run();
         } catch (InterruptedException e) {
             Logging.error(e);
             Thread.currentThread().interrupt();
         } finally {
-            if (dataStore.getReadWriteLock().isWriteLockedByCurrentThread()) {
-                dataStore.getReadWriteLock().writeLock().unlock();
+            if (lock.isWriteLockedByCurrentThread()) {
+                lock.writeLock().unlock();
             }
         }
     }
@@ -610,16 +630,11 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
      * @param invisibleLayers The layer to not show
      */
     public void setInvisibleLayers(Collection<String> invisibleLayers) {
-        if (invisibleLayers == null || invisibleLayers.isEmpty()
-                || invisibleLayers.stream().filter(Objects::nonNull).allMatch(String::isEmpty)) {
-            this.invisibleLayers = NO_INVISIBLE_LAYERS;
-            return;
-        }
         String[] currentInvisibleLayers = invisibleLayers.stream().filter(Objects::nonNull).toArray(String[]::new);
-        this.invisibleLayers = currentInvisibleLayers;
         List<String> temporaryList = Arrays.asList(currentInvisibleLayers);
-        this.dataStoreMap.values().forEach(dataStore -> dataStore.getAllPrimitives().parallelStream()
-                .forEach(primitive -> primitive.setVisible(!temporaryList.contains(primitive.getLayer()))));
+        this.dataStoreMap.values().stream().flatMap(Collection::stream).map(MVTTile::getData)
+          .forEach(dataStore -> dataStore.getAllPrimitives().parallelStream()
+            .forEach(primitive -> primitive.setVisible(!temporaryList.contains(primitive.getLayer()))));
     }
 
     @Override
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
index 7e48e4313..dceef3b8e 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
@@ -4,6 +4,7 @@ package org.openstreetmap.josm.data.vector;
 import org.openstreetmap.gui.jmapviewer.Coordinate;
 import org.openstreetmap.gui.jmapviewer.Tile;
 import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.josm.data.IQuadBucketType;
 import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
 import org.openstreetmap.josm.data.osm.BBox;
@@ -25,12 +26,10 @@ import java.awt.geom.Ellipse2D;
 import java.awt.geom.Path2D;
 import java.awt.geom.PathIterator;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
-import java.util.Optional;
 import java.util.stream.Collectors;
 
 /**
@@ -38,20 +37,12 @@ import java.util.stream.Collectors;
  * @author Taylor Smock
  * @since xxx
  */
-class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay, VectorRelation> implements Destroyable {
-    private static final String[] EMPTY_STRING_ARRAY = new String[0];
+public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay, VectorRelation> implements Destroyable {
     private static final String JOSM_MERGE_TYPE_KEY = "josm_merge_type";
     private static final String ORIGINAL_ID = "original_id";
-    private final VectorDataSet dataSet;
-
-    VectorDataStore(VectorDataSet dataSet, int zoom) {
-        super(zoom);
-        this.dataSet = dataSet;
-    }
 
     @Override
     protected void addPrimitive(VectorPrimitive primitive) {
-        primitive.setDataSet(this.dataSet);
         // The field is uint64, so we can use negative numbers to indicate that it is a "generated" object (e.g., nodes for ways)
         if (primitive.getUniqueId() == 0) {
             final UniqueIdGenerator generator = primitive.getIdGenerator();
@@ -92,7 +83,6 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
                 temporaryRelation.addRelationMember(new VectorRelationMember("", alreadyAdded));
             }
             temporaryRelation.addRelationMember(new VectorRelationMember("", primitive));
-            temporaryRelation.setDataSet(this.dataSet);
             super.addPrimitive(primitive);
             super.addPrimitive(temporaryRelation);
         }
@@ -161,13 +151,21 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
 
     private synchronized <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 BBox tileBbox;
+        if (tile instanceof IQuadBucketType) {
+            tileBbox = ((IQuadBucketType) tile).getBBox();
+        } else {
+            final ICoordinate upperLeft = tile.getTileSource().tileXYToLatLon(tile);
+            final ICoordinate lowerRight = tile.getTileSource()
+                    .tileXYToLatLon(tile.getXtile() + 1, tile.getYtile() + 1, tile.getZoom());
+
+            tileBbox = new BBox(upperLeft.getLon(), upperLeft.getLat(), lowerRight.getLon(), lowerRight.getLat());
+        }
         final int layerExtent = layer.getExtent();
-        final ICoordinate 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);
+                tileBbox.getMaxLat() - (tileBbox.getMaxLat() - tileBbox.getMinLat()) * y / layerExtent,
+                tileBbox.getMinLon() + (tileBbox.getMaxLon() - tileBbox.getMinLon()) * x / layerExtent
+        );
         final Collection<VectorNode> nodes = this.store
           .searchNodes(new BBox(coords.getLon(), coords.getLat(), VectorDataSet.DUPE_NODE_DISTANCE));
         final VectorNode node;
@@ -273,48 +271,11 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
     }
 
     /**
-     * Add a tile to this data store
-     * @param <T> The tile type
+     * Add the information from a tile to this object
      * @param tile The tile to add
-     */
-    public synchronized <T extends Tile & VectorTile> void addTile(T tile) {
-        addTile(tile, EMPTY_STRING_ARRAY);
-    }
-
-    /**
-     * Add a tile to this data store
      * @param <T> The tile type
-     * @param tile The tile to add
-     * @param invisibleLayers Any invisible current invisible layers
      */
-    public <T extends Tile & VectorTile> void addTile(T tile, String[] invisibleLayers) {
-        List<String> invisibleLayerList = Arrays.asList(invisibleLayers);
-        Optional<Tile> previous;
-        synchronized (this.addedTiles) {
-            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())) {
-            previous.ifPresent(this.addedTiles::remove);
-            this.addedTiles.add(tile);
-            VectorDataStore tStore = new VectorDataStore(this.dataSet, this.zoom);
-            tStore.createDataTile(tile, invisibleLayerList);
-            try {
-                this.getReadWriteLock().writeLock().lockInterruptibly();
-                tStore.getAllPrimitives().forEach(this::addPrimitive);
-            } catch (InterruptedException e) {
-                Logging.error(e);
-                Thread.currentThread().interrupt();
-            } finally {
-                if (this.getReadWriteLock().isWriteLockedByCurrentThread()) {
-                    this.getReadWriteLock().writeLock().unlock();
-                }
-            }
-        }
-    }
-
-    private <T extends Tile & VectorTile> void createDataTile(T tile, List<String> invisibleLayerList) {
+    public <T extends Tile & VectorTile> void addDataTile(T tile) {
         for (Layer layer : tile.getLayers()) {
             layer.getFeatures().forEach(feature -> {
                 org.openstreetmap.josm.data.imagery.vectortile.mapbox.Geometry geometry = feature
@@ -363,11 +324,6 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
                 feature.getTags().forEach(primitive::put);
                 featureObjects.forEach(this::addPrimitive);
                 primaryFeatureObjects.forEach(this::addPrimitive);
-                if (invisibleLayerList.contains(primitive.getLayer())) {
-                    primitive.setVisible(false);
-                    featureObjects.forEach(p -> p.setVisible(false));
-                    primaryFeatureObjects.forEach(p -> p.setVisible(false));
-                }
                 try {
                     this.addPrimitive(primitive);
                 } catch (JosmRuntimeException e) {
@@ -377,8 +333,8 @@ class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay,
             });
         }
         // Replace original_ids with the same object (reduce memory usage)
-        // Strings aren't interned automatically (see
-        Collection<IPrimitive> primitives = this.dataSet.allPrimitives().stream().filter(p -> p.hasKey(ORIGINAL_ID))
+        // Strings aren't interned automatically in some GC implementations
+        Collection<IPrimitive> primitives = this.getAllPrimitives().stream().filter(p -> p.hasKey(ORIGINAL_ID))
                 .collect(Collectors.toList());
         List<String> toReplace = primitives.stream().map(p -> p.get(ORIGINAL_ID)).filter(Objects::nonNull).collect(Collectors.toList());
         primitives.stream().filter(p -> toReplace.contains(p.get(ORIGINAL_ID)))
-- 
GitLab


From cf36cba786589d699b6b859b173f730fcf2d945f Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 29 Apr 2021 13:21:57 -0600
Subject: [PATCH 25/50] FIXUP: NPE would occur if a null object was passed to
 in a selection for Vector data

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 src/org/openstreetmap/josm/data/vector/VectorDataSet.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index 55366d555..dc79255e7 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -435,7 +435,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     private void setSelectedImpl(Stream<? extends PrimitiveId> osm) {
         this.doSelectionChange(old -> new IDataSelectionListener.SelectionReplaceEvent<>(this, old,
-                osm.map(this::getPrimitiveById).filter(Objects::nonNull)));
+                osm.filter(Objects::nonNull).map(this::getPrimitiveById).filter(Objects::nonNull)));
     }
 
     @Override
-- 
GitLab


From aca31077b866efff187fdf61848fdd73a8722519 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 29 Apr 2021 14:50:46 -0600
Subject: [PATCH 26/50] VectorDataSet: Fix an NPE

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 src/org/openstreetmap/josm/data/vector/VectorDataSet.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index dc79255e7..1541d169b 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -232,7 +232,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
         final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
         if (dataStore != null) {
             return dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getPrimitivesMap)
-                    .flatMap(m -> Stream.of(primitiveIds).map(m::get));
+                    .flatMap(m -> Stream.of(primitiveIds).map(m::get)).filter(Objects::nonNull);
         }
         return Stream.empty();
     }
-- 
GitLab


From 260113af133abd968e0af19821cb082c7490ba94 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 29 Apr 2021 15:35:26 -0600
Subject: [PATCH 27/50] OsmData: Use I<OsmType>.class for get<OsmType> instead
 of <OsmType>.class

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 src/org/openstreetmap/josm/data/osm/OsmData.java | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/osm/OsmData.java b/src/org/openstreetmap/josm/data/osm/OsmData.java
index a96515a53..a5be8be9a 100644
--- a/src/org/openstreetmap/josm/data/osm/OsmData.java
+++ b/src/org/openstreetmap/josm/data/osm/OsmData.java
@@ -350,7 +350,7 @@ public interface OsmData<O extends IPrimitive, N extends INode, W extends IWay<N
      * @return selected nodes
      */
     default Collection<N> getSelectedNodes() {
-        return new SubclassFilteredCollection<>(getSelected(), Node.class::isInstance);
+        return new SubclassFilteredCollection<>(getSelected(), INode.class::isInstance);
     }
 
     /**
@@ -358,7 +358,7 @@ public interface OsmData<O extends IPrimitive, N extends INode, W extends IWay<N
      * @return selected ways
      */
     default Collection<W> getSelectedWays() {
-        return new SubclassFilteredCollection<>(getSelected(), Way.class::isInstance);
+        return new SubclassFilteredCollection<>(getSelected(), IWay.class::isInstance);
     }
 
     /**
@@ -366,7 +366,7 @@ public interface OsmData<O extends IPrimitive, N extends INode, W extends IWay<N
      * @return selected relations
      */
     default Collection<R> getSelectedRelations() {
-        return new SubclassFilteredCollection<>(getSelected(), Relation.class::isInstance);
+        return new SubclassFilteredCollection<>(getSelected(), IRelation.class::isInstance);
     }
 
     /**
-- 
GitLab


From 6f411f7993cdfd4809c9015bdec17fdaf40c7009 Mon Sep 17 00:00:00 2001
From: Simon Legner <simon.legner@gmail.com>
Date: Sat, 1 May 2021 22:36:37 +0000
Subject: [PATCH 28/50] MapBox -> Mapbox (official spelling) by simon04

---
 src/org/openstreetmap/josm/data/imagery/ImageryInfo.java        | 2 +-
 .../josm/data/imagery/vectortile/mapbox/style/Expression.java   | 2 +-
 .../data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java b/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java
index 32b1055ed..8a93da2c2 100644
--- a/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java
+++ b/src/org/openstreetmap/josm/data/imagery/ImageryInfo.java
@@ -62,7 +62,7 @@ public class ImageryInfo extends
         WMS_ENDPOINT("wms_endpoint"),
         /** WMTS stores GetCapabilities URL. Does not store any information about the layer **/
         WMTS("wmts"),
-        /** MapBox Vector Tiles entry*/
+        /** Mapbox Vector Tiles entry*/
         MVT("mvt");
 
         private final String typeString;
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Expression.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Expression.java
index a7f677755..e22f02a45 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Expression.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Expression.java
@@ -11,7 +11,7 @@ import javax.json.JsonString;
 import javax.json.JsonValue;
 
 /**
- * A MapBox vector style expression (immutable)
+ * A Mapbox vector style expression (immutable)
  * @author Taylor Smock
  * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/">https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/</a>
  * @since xxx
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java
index ec24ee5cf..a945cbf57 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java
@@ -45,7 +45,7 @@ import org.openstreetmap.josm.tools.Logging;
  * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/">https://docs.mapbox.com/mapbox-gl-js/style-spec/</a>
  * @since xxx
  */
-public class MapBoxVectorStyle {
+public class MapboxVectorStyle {
 
     private static final ConcurrentHashMap<String, MapBoxVectorStyle> STYLE_MAPPING = new ConcurrentHashMap<>();
 
-- 
GitLab


From 82292ff8eee5cbc40887c389c86ce1e401ffda43 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Mon, 3 May 2021 07:54:35 -0600
Subject: [PATCH 29/50] FIXUP: MapBox -> Mapbox

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../data/imagery/vectortile/mapbox/Layer.java |  4 +--
 .../imagery/vectortile/mapbox/MVTTile.java    |  2 +-
 ...java => MapboxVectorCachedTileLoader.java} | 12 ++++-----
 ...a => MapboxVectorCachedTileLoaderJob.java} |  8 +++---
 .../mapbox/MapboxVectorTileSource.java        | 10 +++----
 .../vectortile/mapbox/style/Layers.java       |  2 +-
 ...ectorStyle.java => MapboxVectorStyle.java} | 18 ++++++-------
 .../vectortile/mapbox/style/Source.java       |  2 +-
 .../vectortile/mapbox/style/SourceType.java   |  2 +-
 .../josm/gui/layer/imagery/MVTLayer.java      |  8 +++---
 .../preferences/imagery/AddMVTLayerPanel.java |  2 +-
 .../vectortile/mapbox/MVTTileTest.java        |  4 +--
 .../mapbox/MapboxVectorTileSourceTest.java    |  4 +--
 ...leTest.java => MapboxVectorStyleTest.java} | 26 +++++++++----------
 .../josm/data/vector/VectorDataSetTest.java   | 10 +++----
 15 files changed, 57 insertions(+), 57 deletions(-)
 rename src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/{MapBoxVectorCachedTileLoader.java => MapboxVectorCachedTileLoader.java} (88%)
 rename src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/{MapBoxVectorCachedTileLoaderJob.java => MapboxVectorCachedTileLoaderJob.java} (68%)
 rename src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/{MapBoxVectorStyle.java => MapboxVectorStyle.java} (95%)
 rename test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/{MapBoxVectorStyleTest.java => MapboxVectorStyleTest.java} (95%)

diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
index 0a6bb073e..b0b344da1 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
@@ -218,8 +218,8 @@ public final class Layer implements Destroyable {
     }
 
     /**
-     * Get the MapBox Vector Tile version specification for this layer
-     * @return The version of the MapBox Vector Tile specification
+     * 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;
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
index ab77c43f4..0743bec34 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
@@ -22,7 +22,7 @@ import java.util.List;
 import java.util.stream.Collectors;
 
 /**
- * A class for MapBox Vector Tiles
+ * A class for Mapbox Vector Tiles
  *
  * @author Taylor Smock
  * @since xxx
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoader.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoader.java
similarity index 88%
rename from src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoader.java
rename to src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoader.java
index bf1b368d9..abe8d5992 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoader.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoader.java
@@ -23,7 +23,7 @@ import org.apache.commons.jcs3.access.behavior.ICacheAccess;
  * @author Taylor Smock
  * @since xxx
  */
-public class MapBoxVectorCachedTileLoader implements TileLoader, CachedTileLoader {
+public class MapboxVectorCachedTileLoader implements TileLoader, CachedTileLoader {
     protected final ICacheAccess<String, BufferedImageCacheEntry> cache;
     protected final TileLoaderListener listener;
     protected final TileJobOptions options;
@@ -38,8 +38,8 @@ public class MapBoxVectorCachedTileLoader implements TileLoader, CachedTileLoade
      * @param cache             of the cache
      * @param options           tile job options
      */
-    public MapBoxVectorCachedTileLoader(TileLoaderListener listener, ICacheAccess<String, BufferedImageCacheEntry> cache,
-           TileJobOptions options) {
+    public MapboxVectorCachedTileLoader(TileLoaderListener listener, ICacheAccess<String, BufferedImageCacheEntry> cache,
+                                        TileJobOptions options) {
         CheckParameterUtil.ensureParameterNotNull(cache, "cache");
         this.cache = cache;
         this.options = options;
@@ -53,7 +53,7 @@ public class MapBoxVectorCachedTileLoader implements TileLoader, CachedTileLoade
 
     @Override
     public TileJob createTileLoaderJob(Tile tile) {
-        return new MapBoxVectorCachedTileLoaderJob(
+        return new MapboxVectorCachedTileLoaderJob(
                 listener,
                 tile,
                 cache,
@@ -64,8 +64,8 @@ public class MapBoxVectorCachedTileLoader implements TileLoader, CachedTileLoade
     @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);
+        executor.getQueue().stream().filter(executor::remove).filter(MapboxVectorCachedTileLoaderJob.class::isInstance)
+                .map(MapboxVectorCachedTileLoaderJob.class::cast).forEach(JCSCachedTileLoaderJob::handleJobCancellation);
     }
 
     @Override
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoaderJob.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoaderJob.java
similarity index 68%
rename from src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoaderJob.java
rename to src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoaderJob.java
index 748172f5f..a6395cf61 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapBoxVectorCachedTileLoaderJob.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorCachedTileLoaderJob.java
@@ -16,11 +16,11 @@ import org.apache.commons.jcs3.access.behavior.ICacheAccess;
  * @author Taylor Smock
  * @since xxx
  */
-public class MapBoxVectorCachedTileLoaderJob extends TMSCachedTileLoaderJob {
+public class MapboxVectorCachedTileLoaderJob extends TMSCachedTileLoaderJob {
 
-    public MapBoxVectorCachedTileLoaderJob(TileLoaderListener listener, Tile tile,
-            ICacheAccess<String, BufferedImageCacheEntry> cache, TileJobOptions options,
-            ThreadPoolExecutor downloadExecutor) {
+    public MapboxVectorCachedTileLoaderJob(TileLoaderListener listener, Tile tile,
+                                           ICacheAccess<String, BufferedImageCacheEntry> cache, TileJobOptions options,
+                                           ThreadPoolExecutor downloadExecutor) {
         super(listener, tile, cache, options, downloadExecutor);
     }
 }
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java
index 413c7b32b..62647d1bb 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java
@@ -14,7 +14,7 @@ import javax.json.JsonReader;
 
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
 import org.openstreetmap.josm.data.imagery.JosmTemplatedTMSTileSource;
-import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.MapBoxVectorStyle;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.MapboxVectorStyle;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.Source;
 import org.openstreetmap.josm.gui.ExtendedDialog;
 import org.openstreetmap.josm.gui.MainApplication;
@@ -29,7 +29,7 @@ import org.openstreetmap.josm.tools.Logging;
  * @since xxx
  */
 public class MapboxVectorTileSource extends JosmTemplatedTMSTileSource {
-    private final MapBoxVectorStyle styleSource;
+    private final MapboxVectorStyle styleSource;
 
     /**
      * Create a new {@link MapboxVectorTileSource} from an {@link ImageryInfo}
@@ -37,13 +37,13 @@ public class MapboxVectorTileSource extends JosmTemplatedTMSTileSource {
      */
     public MapboxVectorTileSource(ImageryInfo info) {
         super(info);
-        MapBoxVectorStyle mapBoxVectorStyle = null;
+        MapboxVectorStyle mapBoxVectorStyle = null;
         try (CachedFile style = new CachedFile(info.getUrl());
           InputStream inputStream = style.getInputStream();
           JsonReader reader = Json.createReader(inputStream)) {
             reader.readObject();
             // OK, we have a stylesheet
-            mapBoxVectorStyle = MapBoxVectorStyle.getMapBoxVectorStyle(info.getUrl());
+            mapBoxVectorStyle = MapboxVectorStyle.getMapboxVectorStyle(info.getUrl());
         } catch (IOException | JsonException e) {
             Logging.trace(e);
         }
@@ -86,7 +86,7 @@ public class MapboxVectorTileSource extends JosmTemplatedTMSTileSource {
      * Get the style source for this Vector Tile source
      * @return The source to use for styling
      */
-    public MapBoxVectorStyle getStyleSource() {
+    public MapboxVectorStyle getStyleSource() {
         return this.styleSource;
     }
 }
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
index 9488c3d19..d6e55972a 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
@@ -21,7 +21,7 @@ import javax.json.JsonString;
 import javax.json.JsonValue;
 
 /**
- * MapBox style layers
+ * Mapbox style layers
  * @author Taylor Smock
  * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/">https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/</a>
  * @since xxx
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyle.java
similarity index 95%
rename from src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java
rename to src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyle.java
index a945cbf57..68ca3dc52 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyle.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyle.java
@@ -47,20 +47,20 @@ import org.openstreetmap.josm.tools.Logging;
  */
 public class MapboxVectorStyle {
 
-    private static final ConcurrentHashMap<String, MapBoxVectorStyle> STYLE_MAPPING = new ConcurrentHashMap<>();
+    private static final ConcurrentHashMap<String, MapboxVectorStyle> STYLE_MAPPING = new ConcurrentHashMap<>();
 
     /**
-     * Get a MapBoxVector style for a URL
+     * Get a MapboxVector style for a URL
      * @param url The url to get
-     * @return The MapBox Vector Style. May be {@code null} if there was an error.
+     * @return The Mapbox Vector Style. May be {@code null} if there was an error.
      */
-    public static MapBoxVectorStyle getMapBoxVectorStyle(String url) {
+    public static MapboxVectorStyle getMapboxVectorStyle(String url) {
         return STYLE_MAPPING.computeIfAbsent(url, key -> {
             try (CachedFile style = new CachedFile(url);
                     BufferedReader reader = style.getContentReader();
                     JsonReader jsonReader = Json.createReader(reader)) {
                 JsonStructure structure = jsonReader.read();
-                return new MapBoxVectorStyle(structure.asJsonObject());
+                return new MapboxVectorStyle(structure.asJsonObject());
             } catch (IOException e) {
                 Logging.error(e);
             }
@@ -82,13 +82,13 @@ public class MapboxVectorStyle {
     private final Map<Source, ElemStyles> sources;
 
     /**
-     * Create a new MapBoxVector style. You should prefer {@link #getMapBoxVectorStyle(String)}
+     * Create a new MapboxVector style. You should prefer {@link #getMapboxVectorStyle(String)}
      * for deduplication purposes.
      *
      * @param jsonObject The object to create the style from
-     * @see #getMapBoxVectorStyle(String)
+     * @see #getMapboxVectorStyle(String)
      */
-    public MapBoxVectorStyle(JsonObject jsonObject) {
+    public MapboxVectorStyle(JsonObject jsonObject) {
         // There should be a version specifier. We currently only support version 8.
         // This can throw an NPE when there is no version number.
         this.version = jsonObject.getInt("version");
@@ -261,7 +261,7 @@ public class MapboxVectorStyle {
     @Override
     public boolean equals(Object other) {
         if (other != null && other.getClass() == this.getClass()) {
-            MapBoxVectorStyle o = (MapBoxVectorStyle) other;
+            MapboxVectorStyle o = (MapboxVectorStyle) other;
             return this.version == o.version
               && Objects.equals(this.name, o.name)
               && Objects.equals(this.glyphUrl, o.glyphUrl)
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java
index dd41da72f..4e97e3c5d 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Source.java
@@ -20,7 +20,7 @@ import org.openstreetmap.josm.data.Bounds;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.InvalidMapboxVectorTileException;
 
 /**
- * A source from a MapBox Vector Style
+ * A source from a Mapbox Vector Style
  *
  * @author Taylor Smock
  * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/">https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/</a>
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceType.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceType.java
index a086289d6..35a6114d2 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceType.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/SourceType.java
@@ -2,7 +2,7 @@
 package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
 
 /**
- * The "source type" for the data (MapBox Vector Style specification)
+ * The "source type" for the data (Mapbox Vector Style specification)
  *
  * @author Taylor Smock
  * @since xxx
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
index 2e7aac3e6..6079b24ce 100644
--- a/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/MVTLayer.java
@@ -32,7 +32,7 @@ import org.openstreetmap.josm.data.imagery.vectortile.mapbox.Layer;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTFile;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile.TileListener;
-import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapBoxVectorCachedTileLoader;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorCachedTileLoader;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorTileSource;
 import org.openstreetmap.josm.data.osm.DataSet;
 import org.openstreetmap.josm.data.osm.Node;
@@ -57,7 +57,7 @@ import org.openstreetmap.josm.gui.mappaint.ElemStyles;
 import org.openstreetmap.josm.gui.mappaint.StyleSource;
 
 /**
- * A layer for MapBox Vector Tiles
+ * A layer for Mapbox Vector Tiles
  * @author Taylor Smock
  * @since xxx
  */
@@ -79,7 +79,7 @@ public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSour
 
     @Override
     protected Class<? extends TileLoader> getTileLoaderClass() {
-        return MapBoxVectorCachedTileLoader.class;
+        return MapboxVectorCachedTileLoader.class;
     }
 
     @Override
@@ -89,7 +89,7 @@ public class MVTLayer extends AbstractCachedTileSourceLayer<MapboxVectorTileSour
 
     @Override
     public Collection<String> getNativeProjections() {
-        // MapBox Vector Tiles <i>specifically</i> only support EPSG:3857
+        // 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);
     }
diff --git a/src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java b/src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java
index 99bbd058d..b1f7a1653 100644
--- a/src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java
+++ b/src/org/openstreetmap/josm/gui/preferences/imagery/AddMVTLayerPanel.java
@@ -17,7 +17,7 @@ import org.openstreetmap.josm.tools.GBC;
 import org.openstreetmap.josm.tools.Utils;
 
 /**
- * A panel for adding MapBox Vector Tile layers
+ * A panel for adding Mapbox Vector Tile layers
  * @author Taylor Smock
  * @since xxx
  */
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java
index 12b86ebc7..f76f016cf 100644
--- a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTileTest.java
@@ -29,14 +29,14 @@ import org.junit.jupiter.params.provider.MethodSource;
  */
 public class MVTTileTest {
     private MapboxVectorTileSource tileSource;
-    private MapBoxVectorCachedTileLoader loader;
+    private MapboxVectorCachedTileLoader loader;
     @RegisterExtension
     JOSMTestRules rule = new JOSMTestRules();
     @BeforeEach
     void setup() {
         tileSource = new MapboxVectorTileSource(new ImageryInfo("Test Mapillary", "file:/" + TestUtils.getTestDataRoot()
           + "pbf/mapillary/{z}/{x}/{y}.mvt"));
-        loader = new MapBoxVectorCachedTileLoader(null,
+        loader = new MapboxVectorCachedTileLoader(null,
           JCSCacheManager.getCache("testMapillaryCache"), new TileJobOptions(1, 1, Collections
           .emptyMap(), 3600));
     }
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSourceTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSourceTest.java
index 5b9f16842..95a9874fe 100644
--- a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSourceTest.java
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSourceTest.java
@@ -11,7 +11,7 @@ import java.util.stream.Stream;
 import org.junit.jupiter.api.extension.RegisterExtension;
 import org.openstreetmap.josm.TestUtils;
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
-import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.MapBoxVectorStyle;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.MapboxVectorStyle;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.style.Source;
 import org.openstreetmap.josm.gui.ExtendedDialog;
 import org.openstreetmap.josm.gui.widgets.JosmComboBox;
@@ -70,7 +70,7 @@ class MapboxVectorTileSourceTest {
         extendedDialogMocker.getMockResultMap().put(dialogMockerText, "Add layers");
         MapboxVectorTileSource tileSource = new MapboxVectorTileSource(
           new ImageryInfo("Test Mapillary", "file:/" + TestUtils.getTestDataRoot() + "mapillary.json"));
-        MapBoxVectorStyle styleSource = tileSource.getStyleSource();
+        MapboxVectorStyle styleSource = tileSource.getStyleSource();
         assertNotNull(styleSource);
         assertEquals(expected, tileSource.toString());
     }
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyleTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyleTest.java
similarity index 95%
rename from test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyleTest.java
rename to test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyleTest.java
index 1fcb7bfe8..461feffa0 100644
--- a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapBoxVectorStyleTest.java
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/MapboxVectorStyleTest.java
@@ -56,10 +56,10 @@ import org.junit.jupiter.api.extension.RegisterExtension;
 import org.junit.jupiter.api.io.TempDir;
 
 /**
- * Test class for {@link MapBoxVectorStyle}
+ * Test class for {@link MapboxVectorStyle}
  * @author Taylor Smock
  */
-public class MapBoxVectorStyleTest {
+public class MapboxVectorStyleTest {
     /** Used to store sprite files (specifically, sprite{,@2x}.{png,json}) */
     @TempDir
     File spritesDirectory;
@@ -86,21 +86,21 @@ public class MapBoxVectorStyleTest {
      */
     @Test
     void testVersionChecks() {
-        assertThrows(NullPointerException.class, () -> new MapBoxVectorStyle(JsonValue.EMPTY_JSON_OBJECT));
+        assertThrows(NullPointerException.class, () -> new MapboxVectorStyle(JsonValue.EMPTY_JSON_OBJECT));
         IllegalArgumentException badVersion = assertThrows(IllegalArgumentException.class,
-          () -> new MapBoxVectorStyle(Json.createObjectBuilder().add("version", 7).build()));
+          () -> new MapboxVectorStyle(Json.createObjectBuilder().add("version", 7).build()));
         assertEquals("Vector Tile Style Version not understood: version 7 (json: {\"version\":7})", badVersion.getMessage());
         badVersion = assertThrows(IllegalArgumentException.class,
-          () -> new MapBoxVectorStyle(Json.createObjectBuilder().add("version", 9).build()));
+          () -> new MapboxVectorStyle(Json.createObjectBuilder().add("version", 9).build()));
         assertEquals("Vector Tile Style Version not understood: version 9 (json: {\"version\":9})", badVersion.getMessage());
-        assertDoesNotThrow(() -> new MapBoxVectorStyle(Json.createObjectBuilder().add("version", 8).build()));
+        assertDoesNotThrow(() -> new MapboxVectorStyle(Json.createObjectBuilder().add("version", 8).build()));
     }
 
     @Test
     void testSources() {
         // Check with an invalid sources list
-        assertTrue(new MapBoxVectorStyle(getJson(JsonObject.class, "{\"version\":8,\"sources\":[\"s1\",\"s2\"]}")).getSources().isEmpty());
-        Map<Source, ElemStyles> sources = new MapBoxVectorStyle(getJson(JsonObject.class, MessageFormat.format(BASE_STYLE, "test",
+        assertTrue(new MapboxVectorStyle(getJson(JsonObject.class, "{\"version\":8,\"sources\":[\"s1\",\"s2\"]}")).getSources().isEmpty());
+        Map<Source, ElemStyles> sources = new MapboxVectorStyle(getJson(JsonObject.class, MessageFormat.format(BASE_STYLE, "test",
           MessageFormat.format("\"sources\":'{'{0},{1},\"source3\":[\"bad source\"]'}',\"layers\":[{2},{3},{4}]",
             SOURCE1, SOURCE2, LAYER1, LAYER2, LAYER2.replace('2', '3'))))).getSources();
         assertEquals(3, sources.size());
@@ -113,8 +113,8 @@ public class MapBoxVectorStyleTest {
 
     @Test
     void testSavedFiles() {
-        assertTrue(new MapBoxVectorStyle(getJson(JsonObject.class, "{\"version\":8,\"sources\":[\"s1\",\"s2\"]}")).getSources().isEmpty());
-        Map<Source, ElemStyles> sources = new MapBoxVectorStyle(getJson(JsonObject.class, MessageFormat.format(BASE_STYLE, "test",
+        assertTrue(new MapboxVectorStyle(getJson(JsonObject.class, "{\"version\":8,\"sources\":[\"s1\",\"s2\"]}")).getSources().isEmpty());
+        Map<Source, ElemStyles> sources = new MapboxVectorStyle(getJson(JsonObject.class, MessageFormat.format(BASE_STYLE, "test",
           MessageFormat.format("\"sources\":'{'{0},{1}'}',\"layers\":[{2},{3}]", SOURCE1, SOURCE2, LAYER1, LAYER2)))).getSources();
         assertEquals(2, sources.size());
         // For various reasons, the map _must_ be reliably ordered in the order of encounter
@@ -162,7 +162,7 @@ public class MapBoxVectorStyleTest {
         ImageProvider.clearCache();
         int hiDpiScalar = hiDpi ? 2 : 1;
         String spritePath = new File(this.spritesDirectory, "sprite").getPath();
-        MapBoxVectorStyle style = new MapBoxVectorStyle(getJson(JsonObject.class,
+        MapboxVectorStyle style = new MapboxVectorStyle(getJson(JsonObject.class,
           MessageFormat.format(BASE_STYLE, "sprite_test", "\"sprite\":\"file:/" + spritePath + "\"")));
         assertEquals("file:/" + spritePath, style.getSpriteUrl());
 
@@ -238,7 +238,7 @@ public class MapBoxVectorStyleTest {
     @Test
     void testMapillaryStyle() {
         final String file = Paths.get("file:", TestUtils.getTestDataRoot(), "mapillary.json").toString();
-        final MapBoxVectorStyle style = MapBoxVectorStyle.getMapBoxVectorStyle(file);
+        final MapboxVectorStyle style = MapboxVectorStyle.getMapboxVectorStyle(file);
         assertNotNull(style);
         // There are three "sources" in the mapillary.json file
         assertEquals(3, style.getSources().size());
@@ -266,7 +266,7 @@ public class MapBoxVectorStyleTest {
         StyleSource node = new MapCSSStyleSource("meta{title:\"node\";}node{text:ref;}");
         node.loadStyleSource();
         canvas.loadStyleSource();
-        EqualsVerifier.forClass(MapBoxVectorStyle.class)
+        EqualsVerifier.forClass(MapboxVectorStyle.class)
           .withPrefabValues(ImageProvider.class, new ImageProvider("cancel"), new ImageProvider("ok"))
           .withPrefabValues(StyleSource.class, canvas, node)
           .usingGetClass().verify();
diff --git a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
index 35e208979..0e7a572d5 100644
--- a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
+++ b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
@@ -18,7 +18,7 @@ import java.util.stream.Collectors;
 import org.openstreetmap.josm.TestUtils;
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
-import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapBoxVectorCachedTileLoader;
+import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorCachedTileLoader;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorTileSource;
 import org.openstreetmap.josm.gui.layer.imagery.MVTLayer;
 import org.openstreetmap.josm.testutils.JOSMTestRules;
@@ -48,12 +48,12 @@ class VectorDataSetTest {
             return super.getTileSource();
         }
 
-        protected MapBoxVectorCachedTileLoader getTileLoader() {
+        protected MapboxVectorCachedTileLoader getTileLoader() {
             if (this.tileLoader == null) {
                 this.tileLoader = this.getTileLoaderFactory().makeTileLoader(this, Collections.emptyMap(), 7200);
             }
-            if (this.tileLoader instanceof MapBoxVectorCachedTileLoader) {
-                return (MapBoxVectorCachedTileLoader) this.tileLoader;
+            if (this.tileLoader instanceof MapboxVectorCachedTileLoader) {
+                return (MapboxVectorCachedTileLoader) this.tileLoader;
             }
             return null;
         }
@@ -82,7 +82,7 @@ class VectorDataSetTest {
             throw new IllegalArgumentException("Tiles come with a {z}, {x}, and {y} component");
         }
         final MapboxVectorTileSource tileSource = layer.getTileSource();
-        MapBoxVectorCachedTileLoader tileLoader = layer.getTileLoader();
+        MapboxVectorCachedTileLoader tileLoader = layer.getTileLoader();
         Collection<MVTTile> tilesCollection = new ArrayList<>();
         for (int i = 0; i < tiles.length / 3; i++) {
             final MVTTile tile = (MVTTile) layer.createTile(tileSource, tiles[3 * i + 1], tiles[3 * i + 2], tiles[3 * i]);
-- 
GitLab


From bca406ffcbebc18ee6bc9e5fd25a720aa6e8a799 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Mon, 3 May 2021 07:19:34 -0600
Subject: [PATCH 30/50] JCSCachedTileLoader: Use Utils.readBytesFromStream
 (patch by simon04)

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java   | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java b/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
index eeac761c6..d07dff6d1 100644
--- a/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
+++ b/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
@@ -321,7 +321,7 @@ public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements
             file = new File(fileName.substring("file://".length() - 1));
         }
         try (InputStream fileInputStream = Files.newInputStream(file.toPath())) {
-            cacheData = createCacheEntry(IOUtils.toByteArray(fileInputStream));
+            cacheData = createCacheEntry(Utils.readBytesFromStream(fileInputStream));
             cache.put(getCacheKey(), cacheData, attributes);
             return true;
         } catch (IOException e) {
-- 
GitLab


From 1ff96e03474a501025e871db61c5eb72bbf65c8f Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Mon, 3 May 2021 07:28:12 -0600
Subject: [PATCH 31/50] WaySegment: Add methods for binary compatibility

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/osm/WaySegment.java             | 50 ++++++++++++++++++-
 1 file changed, 48 insertions(+), 2 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/osm/WaySegment.java b/src/org/openstreetmap/josm/data/osm/WaySegment.java
index 302f82842..a419980b5 100644
--- a/src/org/openstreetmap/josm/data/osm/WaySegment.java
+++ b/src/org/openstreetmap/josm/data/osm/WaySegment.java
@@ -21,8 +21,8 @@ public final class WaySegment extends IWaySegment<Node, Way> {
      * 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 way    way
+     * @param first  first node
      * @param second second node
      * @return way segment
      * @throws IllegalArgumentException if the node pair is not part of way
@@ -39,6 +39,19 @@ public final class WaySegment extends IWaySegment<Node, Way> {
         throw new IllegalArgumentException("Node pair is not part of way!");
     }
 
+    @Override
+    public Node getFirstNode() {
+        // This is kept for binary compatibility
+        return super.getFirstNode();
+    }
+
+    @Override
+    public Node getSecondNode() {
+        // This is kept for binary compatibility
+        return super.getSecondNode();
+    }
+
+
     /**
      * Returns this way segment as complete way.
      * @return the way segment as {@code Way}
@@ -51,6 +64,39 @@ public final class WaySegment extends IWaySegment<Node, Way> {
         return w;
     }
 
+    @Override
+    public boolean equals(Object o) {
+        // This is kept for binary compatibility
+        return super.equals(o);
+    }
+
+    @Override
+    public int hashCode() {
+        // This is kept for binary compatibility
+        return super.hashCode();
+    }
+
+    /**
+     * Checks whether this segment crosses other segment
+     *
+     * @param s2 The other segment
+     * @return true if both segments crosses
+     */
+    public boolean intersects(WaySegment s2) {
+        // This is kept for binary compatibility
+        return super.intersects(s2);
+    }
+
+    /**
+     * 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) {
+        // This is kept for binary compatibility
+        return super.isSimilar(s2);
+    }
+
     @Override
     public String toString() {
         return "WaySegment [way=" + way.getUniqueId() + ", lowerIndex=" + lowerIndex + ']';
-- 
GitLab


From b24efb00bb6569ba63214ea73ae843248d582ec9 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Mon, 3 May 2021 07:31:28 -0600
Subject: [PATCH 32/50] ProtoBuf -> Protobuf

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../imagery/vectortile/mapbox/Feature.java    | 18 +++----
 .../data/imagery/vectortile/mapbox/Layer.java | 48 ++++++++---------
 .../imagery/vectortile/mapbox/MVTTile.java    | 12 ++---
 ...rotoBufPacked.java => ProtobufPacked.java} | 14 ++---
 ...rotoBufParser.java => ProtobufParser.java} | 12 ++---
 ...rotoBufRecord.java => ProtobufRecord.java} | 14 ++---
 .../imagery/vectortile/mapbox/LayerTest.java  | 12 ++---
 .../data/protobuf/ProtoBufParserTest.java     | 51 -------------------
 .../josm/data/protobuf/ProtoBufTest.java      | 36 ++++++-------
 .../data/protobuf/ProtobufParserTest.java     | 51 +++++++++++++++++++
 ...ecordTest.java => ProtobufRecordTest.java} | 12 ++---
 11 files changed, 140 insertions(+), 140 deletions(-)
 rename src/org/openstreetmap/josm/data/protobuf/{ProtoBufPacked.java => ProtobufPacked.java} (79%)
 rename src/org/openstreetmap/josm/data/protobuf/{ProtoBufParser.java => ProtobufParser.java} (95%)
 rename src/org/openstreetmap/josm/data/protobuf/{ProtoBufRecord.java => ProtobufRecord.java} (86%)
 delete mode 100644 test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufParserTest.java
 create mode 100644 test/unit/org/openstreetmap/josm/data/protobuf/ProtobufParserTest.java
 rename test/unit/org/openstreetmap/josm/data/protobuf/{ProtoBufRecordTest.java => ProtobufRecordTest.java} (67%)

diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java
index df194cc00..f0778b250 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Feature.java
@@ -7,9 +7,9 @@ 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.data.protobuf.ProtobufPacked;
+import org.openstreetmap.josm.data.protobuf.ProtobufParser;
+import org.openstreetmap.josm.data.protobuf.ProtobufRecord;
 import org.openstreetmap.josm.tools.Utils;
 
 /**
@@ -50,25 +50,25 @@ public class Feature {
      * @param record The record to create the feature from
      * @throws IOException - if an IO error occurs
      */
-    public Feature(Layer layer, ProtoBufRecord record) throws IOException {
+    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())) {
+        try (ProtobufParser parser = new ProtobufParser(record.getBytes())) {
             while (parser.hasNext()) {
-                try (ProtoBufRecord next = new ProtoBufRecord(parser)) {
+                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());
+                        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());
+                        ProtobufPacked packed = new ProtobufPacked(next.getBytes());
                         CommandInteger currentCommand = null;
                         for (Number number : packed.getArray()) {
                             if (currentCommand != null && currentCommand.hasAllExpectedParameters()) {
@@ -78,7 +78,7 @@ public class Feature {
                                 currentCommand = new CommandInteger(number.intValue());
                                 this.geometry.add(currentCommand);
                             } else {
-                                currentCommand.addParameter(ProtoBufParser.decodeZigZag(number));
+                                currentCommand.addParameter(ProtobufParser.decodeZigZag(number));
                             }
                         }
                         // TODO fallback to non-packed
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
index b0b344da1..ed36061d1 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/Layer.java
@@ -14,8 +14,8 @@ 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.data.protobuf.ProtobufParser;
+import org.openstreetmap.josm.data.protobuf.ProtobufRecord;
 import org.openstreetmap.josm.tools.Destroyable;
 import org.openstreetmap.josm.tools.Logging;
 
@@ -26,13 +26,13 @@ import org.openstreetmap.josm.tools.Logging;
  */
 public final class Layer implements Destroyable {
     private static final class ValueFields<T> {
-        static final ValueFields<String> STRING = new ValueFields<>(1, ProtoBufRecord::asString);
-        static final ValueFields<Float> FLOAT = new ValueFields<>(2, ProtoBufRecord::asFloat);
-        static final ValueFields<Double> DOUBLE = new ValueFields<>(3, ProtoBufRecord::asDouble);
-        static final ValueFields<Number> INT64 = new ValueFields<>(4, ProtoBufRecord::asUnsignedVarInt);
+        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<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);
 
         /**
@@ -42,8 +42,8 @@ public final class Layer implements Destroyable {
           Collections.unmodifiableList(Arrays.asList(STRING, FLOAT, DOUBLE, INT64, UINT64, SINT64, BOOL));
 
         private final byte field;
-        private final Function<ProtoBufRecord, T> conversion;
-        private ValueFields(int field, Function<ProtoBufRecord, T> conversion) {
+        private final Function<ProtobufRecord, T> conversion;
+        private ValueFields(int field, Function<ProtobufRecord, T> conversion) {
             this.field = (byte) field;
             this.conversion = conversion;
         }
@@ -61,12 +61,12 @@ public final class Layer implements Destroyable {
          * @param protobufRecord The record to convert
          * @return the converted value
          */
-        public T convertValue(ProtoBufRecord protobufRecord) {
+        public T convertValue(ProtobufRecord protobufRecord) {
             return this.conversion.apply(protobufRecord);
         }
     }
 
-    /** The field value for a layer (in {@link ProtoBufRecord#getField}) */
+    /** 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;
@@ -97,26 +97,26 @@ public final class Layer implements Destroyable {
      * @param records The records to convert to a layer
      * @throws IOException - if an IO error occurs
      */
-    public Layer(Collection<ProtoBufRecord> records) throws IOException {
+    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));
+        Map<Integer, List<ProtobufRecord>> sorted = records.stream().collect(Collectors.groupingBy(ProtobufRecord::getField));
         this.version = sorted.getOrDefault((int) VERSION_FIELD, Collections.emptyList()).parallelStream()
-          .map(ProtoBufRecord::asUnsignedVarInt).map(Number::byteValue).findFirst().orElse(DEFAULT_VERSION);
+          .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()
+        this.name = sorted.getOrDefault((int) NAME_FIELD, Collections.emptyList()).parallelStream().map(ProtobufRecord::asString).findFirst()
                 .orElseThrow(() -> new IllegalArgumentException(tr("Vector tile layers must have a layer name")));
-        this.extent = sorted.getOrDefault((int) EXTENT_FIELD, Collections.emptyList()).parallelStream().map(ProtoBufRecord::asUnsignedVarInt)
+        this.extent = sorted.getOrDefault((int) EXTENT_FIELD, Collections.emptyList()).parallelStream().map(ProtobufRecord::asUnsignedVarInt)
                 .map(Number::intValue).findAny().orElse(DEFAULT_EXTENT);
 
-        sorted.getOrDefault((int) KEY_FIELD, Collections.emptyList()).parallelStream().map(ProtoBufRecord::asString)
+        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 -> {
+        sorted.getOrDefault((int) VALUE_FIELD, Collections.emptyList()).parallelStream().map(ProtobufRecord::getBytes)
+                .map(ProtobufParser::new).map(parser1 -> {
                     try {
-                        return new ProtoBufRecord(parser1);
+                        return new ProtobufRecord(parser1);
                     } catch (IOException e) {
                         Logging.error(e);
                         return null;
@@ -141,7 +141,7 @@ public final class Layer implements Destroyable {
             throw exceptions.iterator().next();
         }
         // Cleanup bytes (for memory)
-        for (ProtoBufRecord record : records) {
+        for (ProtobufRecord record : records) {
             record.close();
         }
     }
@@ -152,8 +152,8 @@ public final class Layer implements Destroyable {
      * @return All the protobuf records
      * @throws IOException If there was an error reading the bytes (unlikely)
      */
-    private static Collection<ProtoBufRecord> getAllRecords(byte[] bytes) throws IOException {
-        try (ProtoBufParser parser = new ProtoBufParser(bytes)) {
+    private static Collection<ProtobufRecord> getAllRecords(byte[] bytes) throws IOException {
+        try (ProtobufParser parser = new ProtobufParser(bytes)) {
             return parser.allRecords();
         }
     }
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
index 0743bec34..522038c77 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTTile.java
@@ -7,8 +7,8 @@ import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
 import org.openstreetmap.josm.data.IQuadBucketType;
 import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
 import org.openstreetmap.josm.data.osm.BBox;
-import org.openstreetmap.josm.data.protobuf.ProtoBufParser;
-import org.openstreetmap.josm.data.protobuf.ProtoBufRecord;
+import org.openstreetmap.josm.data.protobuf.ProtobufParser;
+import org.openstreetmap.josm.data.protobuf.ProtobufRecord;
 import org.openstreetmap.josm.data.vector.VectorDataStore;
 import org.openstreetmap.josm.tools.ListenerList;
 import org.openstreetmap.josm.tools.Logging;
@@ -50,13 +50,13 @@ public class MVTTile extends Tile implements VectorTile, IQuadBucketType {
     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();
+            ProtobufParser parser = new ProtobufParser(inputStream);
+            Collection<ProtobufRecord> protobufRecords = parser.allRecords();
             this.layers = new HashSet<>();
-            this.layers = protoBufRecords.stream().map(protoBufRecord -> {
+            this.layers = protobufRecords.stream().map(protoBufRecord -> {
                 Layer mvtLayer = null;
                 if (protoBufRecord.getField() == Layer.LAYER_FIELD) {
-                    try (ProtoBufParser tParser = new ProtoBufParser(protoBufRecord.getBytes())) {
+                    try (ProtobufParser tParser = new ProtobufParser(protoBufRecord.getBytes())) {
                         mvtLayer = new Layer(tParser.allRecords());
                     } catch (IOException e) {
                         Logging.error(e);
diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtoBufPacked.java b/src/org/openstreetmap/josm/data/protobuf/ProtobufPacked.java
similarity index 79%
rename from src/org/openstreetmap/josm/data/protobuf/ProtoBufPacked.java
rename to src/org/openstreetmap/josm/data/protobuf/ProtobufPacked.java
index 109f8915a..a12bbe6b8 100644
--- a/src/org/openstreetmap/josm/data/protobuf/ProtoBufPacked.java
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtobufPacked.java
@@ -10,22 +10,22 @@ import java.util.List;
  * @author Taylor Smock
  * @since xxx
  */
-public class ProtoBufPacked {
+public class ProtobufPacked {
     private final byte[] bytes;
     private final Number[] numbers;
     private int location;
 
     /**
-     * Create a new ProtoBufPacked object
+     * Create a new ProtobufPacked object
      *
      * @param bytes The packed bytes
      */
-    public ProtoBufPacked(byte[] 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));
+            numbersT.add(ProtobufParser.convertByteArray(this.nextVarInt(), ProtobufParser.VAR_INT_BYTE_SIZE));
         }
 
         this.numbers = new Number[numbersT.size()];
@@ -45,10 +45,10 @@ public class ProtoBufPacked {
 
     private byte[] nextVarInt() {
         List<Byte> byteList = new ArrayList<>();
-        while ((this.bytes[this.location] & ProtoBufParser.MOST_SIGNIFICANT_BYTE)
-          == ProtoBufParser.MOST_SIGNIFICANT_BYTE) {
+        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));
+            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++]);
diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtoBufParser.java b/src/org/openstreetmap/josm/data/protobuf/ProtobufParser.java
similarity index 95%
rename from src/org/openstreetmap/josm/data/protobuf/ProtoBufParser.java
rename to src/org/openstreetmap/josm/data/protobuf/ProtobufParser.java
index 18059e339..8364cc3b7 100644
--- a/src/org/openstreetmap/josm/data/protobuf/ProtoBufParser.java
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtobufParser.java
@@ -17,7 +17,7 @@ import org.openstreetmap.josm.tools.Logging;
  * @author Taylor Smock
  * @since xxx
  */
-public class ProtoBufParser implements AutoCloseable {
+public class ProtobufParser implements AutoCloseable {
     /**
      * The default byte size (see {@link #VAR_INT_BYTE_SIZE} for var ints)
      */
@@ -96,7 +96,7 @@ public class ProtoBufParser implements AutoCloseable {
      *
      * @param bytes The bytes to parse
      */
-    public ProtoBufParser(byte[] bytes) {
+    public ProtobufParser(byte[] bytes) {
         this(new ByteArrayInputStream(bytes));
     }
 
@@ -105,7 +105,7 @@ public class ProtoBufParser implements AutoCloseable {
      *
      * @param inputStream The InputStream (will be fully read at this time)
      */
-    public ProtoBufParser(InputStream inputStream) {
+    public ProtobufParser(InputStream inputStream) {
         if (inputStream.markSupported()) {
             this.inputStream = inputStream;
         } else {
@@ -119,10 +119,10 @@ public class ProtoBufParser implements AutoCloseable {
      * @return A collection of all records
      * @throws IOException - if an IO error occurs
      */
-    public Collection<ProtoBufRecord> allRecords() throws IOException {
-        Collection<ProtoBufRecord> records = new ArrayList<>();
+    public Collection<ProtobufRecord> allRecords() throws IOException {
+        Collection<ProtobufRecord> records = new ArrayList<>();
         while (this.hasNext()) {
-            records.add(new ProtoBufRecord(this));
+            records.add(new ProtobufRecord(this));
         }
         return records;
     }
diff --git a/src/org/openstreetmap/josm/data/protobuf/ProtoBufRecord.java b/src/org/openstreetmap/josm/data/protobuf/ProtobufRecord.java
similarity index 86%
rename from src/org/openstreetmap/josm/data/protobuf/ProtoBufRecord.java
rename to src/org/openstreetmap/josm/data/protobuf/ProtobufRecord.java
index 1eb5d38a6..e760996d4 100644
--- a/src/org/openstreetmap/josm/data/protobuf/ProtoBufRecord.java
+++ b/src/org/openstreetmap/josm/data/protobuf/ProtobufRecord.java
@@ -13,7 +13,7 @@ import org.openstreetmap.josm.tools.Utils;
  * @author Taylor Smock
  * @since xxx
  */
-public class ProtoBufRecord implements AutoCloseable {
+public class ProtobufRecord implements AutoCloseable {
     private static final byte[] EMPTY_BYTES = {};
     private final WireType type;
     private final int field;
@@ -25,8 +25,8 @@ public class ProtoBufRecord implements AutoCloseable {
      * @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);
+    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)
@@ -53,7 +53,7 @@ public class ProtoBufRecord implements AutoCloseable {
      * @return the double
      */
     public double asDouble() {
-        long doubleNumber = ProtoBufParser.convertByteArray(asFixed64(), ProtoBufParser.BYTE_SIZE).longValue();
+        long doubleNumber = ProtobufParser.convertByteArray(asFixed64(), ProtobufParser.BYTE_SIZE).longValue();
         return Double.longBitsToDouble(doubleNumber);
     }
 
@@ -85,7 +85,7 @@ public class ProtoBufRecord implements AutoCloseable {
      * @return the float
      */
     public float asFloat() {
-        int floatNumber = ProtoBufParser.convertByteArray(asFixed32(), ProtoBufParser.BYTE_SIZE).intValue();
+        int floatNumber = ProtobufParser.convertByteArray(asFixed32(), ProtobufParser.BYTE_SIZE).intValue();
         return Float.intBitsToFloat(floatNumber);
     }
 
@@ -97,7 +97,7 @@ public class ProtoBufRecord implements AutoCloseable {
      */
     public Number asSignedVarInt() {
         final Number signed = this.asUnsignedVarInt();
-        return ProtoBufParser.decodeZigZag(signed);
+        return ProtobufParser.decodeZigZag(signed);
     }
 
     /**
@@ -115,7 +115,7 @@ public class ProtoBufRecord implements AutoCloseable {
      * @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);
+        return ProtobufParser.convertByteArray(this.bytes, ProtobufParser.VAR_INT_BYTE_SIZE);
     }
 
     @Override
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java
index 61e21dc60..e9b5bae8d 100644
--- a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/LayerTest.java
@@ -11,8 +11,8 @@ import java.util.Arrays;
 import java.util.List;
 
 import org.openstreetmap.josm.TestUtils;
-import org.openstreetmap.josm.data.protobuf.ProtoBufParser;
-import org.openstreetmap.josm.data.protobuf.ProtoBufRecord;
+import org.openstreetmap.josm.data.protobuf.ProtobufParser;
+import org.openstreetmap.josm.data.protobuf.ProtobufRecord;
 
 import nl.jqno.equalsverifier.EqualsVerifier;
 import org.junit.jupiter.api.Test;
@@ -66,14 +66,14 @@ public class LayerTest {
      * @throws IOException If something happened (should never trigger)
      */
     static Layer getLayer(byte[] bytes) throws IOException {
-        List<ProtoBufRecord> records = (List<ProtoBufRecord>) new ProtoBufParser(bytes).allRecords();
+        List<ProtobufRecord> records = (List<ProtobufRecord>) new ProtobufParser(bytes).allRecords();
         assertEquals(1, records.size());
-        return new Layer(new ProtoBufParser(records.get(0).getBytes()).allRecords());
+        return new Layer(new ProtobufParser(records.get(0).getBytes()).allRecords());
     }
 
     @Test
     void testLayerCreation() throws IOException {
-        List<ProtoBufRecord> layers = (List<ProtoBufRecord>) new ProtoBufParser(new FileInputStream(TestUtils.getTestDataRoot()
+        List<ProtobufRecord> layers = (List<ProtobufRecord>) new ProtobufParser(new FileInputStream(TestUtils.getTestDataRoot()
           + "pbf/mapillary/14/3249/6258.mvt")).allRecords();
         Layer sequenceLayer = new Layer(layers.get(0).getBytes());
         assertEquals("mapillary-sequences", sequenceLayer.getName());
@@ -92,7 +92,7 @@ public class LayerTest {
 
     @Test
     void testLayerEqualsHashCode() throws IOException {
-        List<ProtoBufRecord> layers = (List<ProtoBufRecord>) new ProtoBufParser(new FileInputStream(TestUtils.getTestDataRoot()
+        List<ProtobufRecord> layers = (List<ProtobufRecord>) new ProtobufParser(new FileInputStream(TestUtils.getTestDataRoot()
           + "pbf/mapillary/14/3249/6258.mvt")).allRecords();
         EqualsVerifier.forClass(Layer.class).withPrefabValues(byte[].class, layers.get(0).getBytes(), layers.get(1).getBytes())
           .verify();
diff --git a/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufParserTest.java b/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufParserTest.java
deleted file mode 100644
index bdfdf86b7..000000000
--- a/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufParserTest.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// License: GPL. For details, see LICENSE file.
-package org.openstreetmap.josm.data.protobuf;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-import org.junit.jupiter.api.Test;
-
-/**
- * Test class for {@link ProtoBufParser}
- * @author Taylor Smock
- * @since xxx
- */
-class ProtoBufParserTest {
-    /**
-     * Check that we are appropriately converting values to the "smallest" type
-     */
-    @Test
-    void testConvertLong() {
-        // No casting due to auto conversions
-        assertEquals(Byte.MAX_VALUE, ProtoBufParser.convertLong(Byte.MAX_VALUE));
-        assertEquals(Byte.MIN_VALUE, ProtoBufParser.convertLong(Byte.MIN_VALUE));
-        assertEquals(Short.MIN_VALUE, ProtoBufParser.convertLong(Short.MIN_VALUE));
-        assertEquals(Short.MAX_VALUE, ProtoBufParser.convertLong(Short.MAX_VALUE));
-        assertEquals(Integer.MAX_VALUE, ProtoBufParser.convertLong(Integer.MAX_VALUE));
-        assertEquals(Integer.MIN_VALUE, ProtoBufParser.convertLong(Integer.MIN_VALUE));
-        assertEquals(Long.MIN_VALUE, ProtoBufParser.convertLong(Long.MIN_VALUE));
-        assertEquals(Long.MAX_VALUE, ProtoBufParser.convertLong(Long.MAX_VALUE));
-    }
-
-    /**
-     * Check that zig zags are appropriately encoded.
-     */
-    @Test
-    void testEncodeZigZag() {
-        assertEquals(0, ProtoBufParser.encodeZigZag(0).byteValue());
-        assertEquals(1, ProtoBufParser.encodeZigZag(-1).byteValue());
-        assertEquals(2, ProtoBufParser.encodeZigZag(1).byteValue());
-        assertEquals(3, ProtoBufParser.encodeZigZag(-2).byteValue());
-        assertEquals(254, ProtoBufParser.encodeZigZag(Byte.MAX_VALUE).shortValue());
-        assertEquals(255, ProtoBufParser.encodeZigZag(Byte.MIN_VALUE).shortValue());
-        assertEquals(65_534, ProtoBufParser.encodeZigZag(Short.MAX_VALUE).intValue());
-        assertEquals(65_535, ProtoBufParser.encodeZigZag(Short.MIN_VALUE).intValue());
-        // These integers check a possible boundary condition (the boundary between using the 32/64 bit encoding methods)
-        assertEquals(4_294_967_292L, ProtoBufParser.encodeZigZag(Integer.MAX_VALUE - 1).longValue());
-        assertEquals(4_294_967_293L, ProtoBufParser.encodeZigZag(Integer.MIN_VALUE + 1).longValue());
-        assertEquals(4_294_967_294L, ProtoBufParser.encodeZigZag(Integer.MAX_VALUE).longValue());
-        assertEquals(4_294_967_295L, ProtoBufParser.encodeZigZag(Integer.MIN_VALUE).longValue());
-        assertEquals(4_294_967_296L, ProtoBufParser.encodeZigZag(Integer.MAX_VALUE + 1L).longValue());
-        assertEquals(4_294_967_297L, ProtoBufParser.encodeZigZag(Integer.MIN_VALUE - 1L).longValue());
-    }
-}
diff --git a/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java b/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java
index 043481efe..e5cd8c738 100644
--- a/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java
+++ b/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java
@@ -37,7 +37,7 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 
 /**
- * Test class for {@link ProtoBufParser} and {@link ProtoBufRecord}
+ * Test class for {@link ProtobufParser} and {@link ProtobufRecord}
  *
  * @author Taylor Smock
  * @since xxx
@@ -67,7 +67,7 @@ class ProtoBufTest {
         for (int i = 0; i < bytes.length; i++) {
             byteArray[i] = (byte) bytes[i];
         }
-        return ProtoBufParser.convertByteArray(byteArray, ProtoBufParser.VAR_INT_BYTE_SIZE);
+        return ProtobufParser.convertByteArray(byteArray, ProtobufParser.VAR_INT_BYTE_SIZE);
     }
 
     /**
@@ -79,10 +79,10 @@ class ProtoBufTest {
     void testRead_14_3248_6258() throws IOException {
         File vectorTile = Paths.get(TestUtils.getTestDataRoot(), "pbf", "mapillary", "14", "3248", "6258.mvt").toFile();
         InputStream inputStream = Compression.getUncompressedFileInputStream(vectorTile);
-        Collection<ProtoBufRecord> records = new ProtoBufParser(inputStream).allRecords();
+        Collection<ProtobufRecord> records = new ProtobufParser(inputStream).allRecords();
         assertEquals(2, records.size());
         List<Layer> layers = new ArrayList<>();
-        for (ProtoBufRecord record : records) {
+        for (ProtobufRecord record : records) {
             if (record.getField() == Layer.LAYER_FIELD) {
                 layers.add(new Layer(record.getBytes()));
             } else {
@@ -112,9 +112,9 @@ class ProtoBufTest {
         File vectorTile = Paths.get(TestUtils.getTestDataRoot(), "pbf", "openinframap", "17", "26028", "50060.pbf")
                 .toFile();
         InputStream inputStream = Compression.getUncompressedFileInputStream(vectorTile);
-        Collection<ProtoBufRecord> records = new ProtoBufParser(inputStream).allRecords();
+        Collection<ProtobufRecord> records = new ProtobufParser(inputStream).allRecords();
         List<Layer> layers = new ArrayList<>();
-        for (ProtoBufRecord record : records) {
+        for (ProtobufRecord record : records) {
             if (record.getField() == Layer.LAYER_FIELD) {
                 layers.add(new Layer(record.getBytes()));
             } else {
@@ -155,12 +155,12 @@ class ProtoBufTest {
 
     @Test
     void testReadVarInt() {
-        assertEquals(ProtoBufParser.convertLong(0), bytesToVarInt(0x0));
-        assertEquals(ProtoBufParser.convertLong(1), bytesToVarInt(0x1));
-        assertEquals(ProtoBufParser.convertLong(127), bytesToVarInt(0x7f));
+        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,
+        assertEquals(ProtobufParser.convertLong(Integer.MAX_VALUE), actual,
                 MessageFormat.format("Expected {0} but got {1}", Integer.toBinaryString(Integer.MAX_VALUE),
                         Long.toBinaryString(actual.longValue())));
     }
@@ -173,21 +173,21 @@ class ProtoBufTest {
      */
     @Test
     void testSimpleMessage() throws IOException {
-        ProtoBufParser parser = new ProtoBufParser(new byte[] {(byte) 0x08, (byte) 0x96, (byte) 0x01});
-        ProtoBufRecord record = new ProtoBufRecord(parser);
+        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
     void testSingletonMultiPoint() throws IOException {
-        Collection<ProtoBufRecord> records = new ProtoBufParser(new ByteArrayInputStream(toByteArray(
+        Collection<ProtobufRecord> records = new ProtobufParser(new ByteArrayInputStream(toByteArray(
                 new int[] {0x1a, 0x2c, 0x78, 0x02, 0x0a, 0x03, 0x74, 0x6d, 0x70, 0x28, 0x80, 0x20, 0x1a, 0x04, 0x6e,
                         0x61, 0x6d, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x54, 0x65, 0x73, 0x74, 0x20, 0x6e, 0x61, 0x6d, 0x65,
                         0x12, 0x0d, 0x18, 0x01, 0x12, 0x02, 0x00, 0x00, 0x22, 0x05, 0x09, 0xe0, 0x3e, 0x84, 0x27})))
                                 .allRecords();
         List<Layer> layers = new ArrayList<>();
-        for (ProtoBufRecord record : records) {
+        for (ProtobufRecord record : records) {
             if (record.getField() == Layer.LAYER_FIELD) {
                 layers.add(new Layer(record.getBytes()));
             } else {
@@ -203,9 +203,9 @@ class ProtoBufTest {
 
     @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());
+        assertEquals(0, ProtobufParser.decodeZigZag(0).intValue());
+        assertEquals(-1, ProtobufParser.decodeZigZag(1).intValue());
+        assertEquals(1, ProtobufParser.decodeZigZag(2).intValue());
+        assertEquals(-2, ProtobufParser.decodeZigZag(3).intValue());
     }
 }
diff --git a/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufParserTest.java b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufParserTest.java
new file mode 100644
index 000000000..201644076
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufParserTest.java
@@ -0,0 +1,51 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.protobuf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test class for {@link ProtobufParser}
+ * @author Taylor Smock
+ * @since xxx
+ */
+class ProtobufParserTest {
+    /**
+     * Check that we are appropriately converting values to the "smallest" type
+     */
+    @Test
+    void testConvertLong() {
+        // No casting due to auto conversions
+        assertEquals(Byte.MAX_VALUE, ProtobufParser.convertLong(Byte.MAX_VALUE));
+        assertEquals(Byte.MIN_VALUE, ProtobufParser.convertLong(Byte.MIN_VALUE));
+        assertEquals(Short.MIN_VALUE, ProtobufParser.convertLong(Short.MIN_VALUE));
+        assertEquals(Short.MAX_VALUE, ProtobufParser.convertLong(Short.MAX_VALUE));
+        assertEquals(Integer.MAX_VALUE, ProtobufParser.convertLong(Integer.MAX_VALUE));
+        assertEquals(Integer.MIN_VALUE, ProtobufParser.convertLong(Integer.MIN_VALUE));
+        assertEquals(Long.MIN_VALUE, ProtobufParser.convertLong(Long.MIN_VALUE));
+        assertEquals(Long.MAX_VALUE, ProtobufParser.convertLong(Long.MAX_VALUE));
+    }
+
+    /**
+     * Check that zig zags are appropriately encoded.
+     */
+    @Test
+    void testEncodeZigZag() {
+        assertEquals(0, ProtobufParser.encodeZigZag(0).byteValue());
+        assertEquals(1, ProtobufParser.encodeZigZag(-1).byteValue());
+        assertEquals(2, ProtobufParser.encodeZigZag(1).byteValue());
+        assertEquals(3, ProtobufParser.encodeZigZag(-2).byteValue());
+        assertEquals(254, ProtobufParser.encodeZigZag(Byte.MAX_VALUE).shortValue());
+        assertEquals(255, ProtobufParser.encodeZigZag(Byte.MIN_VALUE).shortValue());
+        assertEquals(65_534, ProtobufParser.encodeZigZag(Short.MAX_VALUE).intValue());
+        assertEquals(65_535, ProtobufParser.encodeZigZag(Short.MIN_VALUE).intValue());
+        // These integers check a possible boundary condition (the boundary between using the 32/64 bit encoding methods)
+        assertEquals(4_294_967_292L, ProtobufParser.encodeZigZag(Integer.MAX_VALUE - 1).longValue());
+        assertEquals(4_294_967_293L, ProtobufParser.encodeZigZag(Integer.MIN_VALUE + 1).longValue());
+        assertEquals(4_294_967_294L, ProtobufParser.encodeZigZag(Integer.MAX_VALUE).longValue());
+        assertEquals(4_294_967_295L, ProtobufParser.encodeZigZag(Integer.MIN_VALUE).longValue());
+        assertEquals(4_294_967_296L, ProtobufParser.encodeZigZag(Integer.MAX_VALUE + 1L).longValue());
+        assertEquals(4_294_967_297L, ProtobufParser.encodeZigZag(Integer.MIN_VALUE - 1L).longValue());
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufRecordTest.java b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java
similarity index 67%
rename from test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufRecordTest.java
rename to test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java
index d0e204c6a..6573d36fc 100644
--- a/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufRecordTest.java
+++ b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java
@@ -9,21 +9,21 @@ import java.io.IOException;
 import org.junit.jupiter.api.Test;
 
 /**
- * Test class for specific {@link ProtoBufRecord} functionality
+ * Test class for specific {@link ProtobufRecord} functionality
  */
-class ProtoBufRecordTest {
+class ProtobufRecordTest {
     @Test
     void testFixed32() throws IOException {
-        ProtoBufParser parser = new ProtoBufParser(ProtoBufTest.toByteArray(new int[] {0x0d, 0x00, 0x00, 0x80, 0x3f}));
-        ProtoBufRecord thirtyTwoBit = new ProtoBufRecord(parser);
+        ProtobufParser parser = new ProtobufParser(ProtoBufTest.toByteArray(new int[] {0x0d, 0x00, 0x00, 0x80, 0x3f}));
+        ProtobufRecord thirtyTwoBit = new ProtobufRecord(parser);
         assertEquals(WireType.THIRTY_TWO_BIT, thirtyTwoBit.getType());
         assertEquals(1f, thirtyTwoBit.asFloat());
     }
 
     @Test
     void testUnknown() throws IOException {
-        ProtoBufParser parser = new ProtoBufParser(ProtoBufTest.toByteArray(new int[] {0x0f, 0x00, 0x00, 0x80, 0x3f}));
-        ProtoBufRecord unknown = new ProtoBufRecord(parser);
+        ProtobufParser parser = new ProtobufParser(ProtoBufTest.toByteArray(new int[] {0x0f, 0x00, 0x00, 0x80, 0x3f}));
+        ProtobufRecord unknown = new ProtobufRecord(parser);
         assertEquals(WireType.UNKNOWN, unknown.getType());
         assertEquals(0, unknown.getBytes().length);
     }
-- 
GitLab


From 451f7da6f1ebe81be20a7055b0f91635ed35173d Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Mon, 3 May 2021 07:34:45 -0600
Subject: [PATCH 33/50] Vector Primitives: Return immutable bboxes

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 src/org/openstreetmap/josm/data/vector/VectorNode.java   | 2 +-
 .../openstreetmap/josm/data/vector/VectorRelation.java   | 9 +++++----
 src/org/openstreetmap/josm/data/vector/VectorWay.java    | 9 +++++----
 3 files changed, 11 insertions(+), 9 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorNode.java b/src/org/openstreetmap/josm/data/vector/VectorNode.java
index 60aecd8ff..07e9e512f 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorNode.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorNode.java
@@ -103,7 +103,7 @@ public class VectorNode extends VectorPrimitive implements INode {
 
     @Override
     public BBox getBBox() {
-        return new BBox(this.lon, this.lat);
+        return new BBox(this.lon, this.lat).toImmutable();
     }
 
     @Override
diff --git a/src/org/openstreetmap/josm/data/vector/VectorRelation.java b/src/org/openstreetmap/josm/data/vector/VectorRelation.java
index 0deb57e57..bf30365a9 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorRelation.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorRelation.java
@@ -43,13 +43,14 @@ public class VectorRelation extends VectorPrimitive implements IRelation<VectorR
 
     @Override
     public BBox getBBox() {
-        if (cachedBBox == null) {
-            cachedBBox = new BBox();
+        if (this.cachedBBox == null) {
+            BBox tBBox = new BBox();
             for (IPrimitive member : this.getMemberPrimitivesList()) {
-                cachedBBox.add(member.getBBox());
+                tBBox.add(member.getBBox());
             }
+            this.cachedBBox = tBBox.toImmutable();
         }
-        return cachedBBox;
+        return this.cachedBBox;
     }
 
     protected void addRelationMember(VectorRelationMember member) {
diff --git a/src/org/openstreetmap/josm/data/vector/VectorWay.java b/src/org/openstreetmap/josm/data/vector/VectorWay.java
index 582fca2d4..98fe9aa22 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorWay.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorWay.java
@@ -44,13 +44,14 @@ public class VectorWay extends VectorPrimitive implements IWay<VectorNode> {
 
     @Override
     public BBox getBBox() {
-        if (cachedBBox == null) {
-            cachedBBox = new BBox();
+        if (this.cachedBBox == null) {
+            BBox tBBox = new BBox();
             for (INode node : this.getNodes()) {
-                cachedBBox.add(node.getBBox());
+                tBBox.add(node.getBBox());
             }
+            this.cachedBBox = tBBox.toImmutable();
         }
-        return cachedBBox;
+        return this.cachedBBox;
     }
 
     @Override
-- 
GitLab


From 6856853d21541edf436d11683590dfd84f6fe08e Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Mon, 3 May 2021 07:49:21 -0600
Subject: [PATCH 34/50] VectorTile Style: Layers: Use StyleKeys for common
 constants

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../vectortile/mapbox/style/Layers.java       | 51 ++++++++++---------
 1 file changed, 27 insertions(+), 24 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
index d6e55972a..b76389cf8 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
@@ -1,6 +1,8 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
 
+import org.openstreetmap.josm.gui.mappaint.StyleKeys;
+
 import java.awt.Font;
 import java.awt.GraphicsEnvironment;
 import java.text.MessageFormat;
@@ -58,6 +60,7 @@ public class Layers {
     private static final String EMPTY_STRING = "";
     private static final char SEMI_COLON = ';';
     private static final Pattern CURLY_BRACES = Pattern.compile("(\\{(.*?)})");
+    private static final String PAINT = "paint";
 
     /** A required unique layer name */
     private final String id;
@@ -110,8 +113,8 @@ public class Layers {
         } else {
             this.source = layerInfo.getString("source");
         }
-        if (layerInfo.containsKey("paint") && layerInfo.get("paint").getValueType() == JsonValue.ValueType.OBJECT) {
-            final JsonObject paintObject = layerInfo.getJsonObject("paint");
+        if (layerInfo.containsKey(PAINT) && layerInfo.get(PAINT).getValueType() == JsonValue.ValueType.OBJECT) {
+            final JsonObject paintObject = layerInfo.getJsonObject(PAINT);
             final JsonObject layoutObject = layerInfo.getOrDefault("layout", JsonValue.EMPTY_JSON_OBJECT).asJsonObject();
             // Don't throw exceptions here, since we may just point at the styling
             if ("visible".equalsIgnoreCase(layoutObject.getString("visibility", "visible"))) {
@@ -177,15 +180,15 @@ public class Layers {
         // line-blur, default 0 (px)
         // line-color, default #000000, disabled by line-pattern
         final String color = paintObject.getString("line-color", "#000000");
-        sb.append("color:").append(color).append(SEMI_COLON);
+        sb.append(StyleKeys.COLOR).append(':').append(color).append(SEMI_COLON);
         // line-opacity, default 1 (0-1)
         final JsonNumber opacity = paintObject.getJsonNumber("line-opacity");
         if (opacity != null) {
-            sb.append("opacity:").append(opacity.numberValue().doubleValue()).append(SEMI_COLON);
+            sb.append(StyleKeys.OPACITY).append(':').append(opacity.numberValue().doubleValue()).append(SEMI_COLON);
         }
         // line-cap, default butt (butt|round|square)
         final String cap = layoutObject.getString("line-cap", "butt");
-        sb.append("linecap:");
+        sb.append(StyleKeys.LINECAP).append(':');
         switch (cap) {
         case "round":
         case "square":
@@ -200,7 +203,7 @@ public class Layers {
         // line-dasharray, array of number >= 0, units in line widths, disabled by line-pattern
         if (paintObject.containsKey("line-dasharray")) {
             final JsonArray dashArray = paintObject.getJsonArray("line-dasharray");
-            sb.append("dashes:");
+            sb.append(StyleKeys.DASHES).append(':');
             sb.append(dashArray.stream().filter(JsonNumber.class::isInstance).map(JsonNumber.class::cast)
               .map(JsonNumber::toString).collect(Collectors.joining(",")));
             sb.append(SEMI_COLON);
@@ -217,7 +220,7 @@ public class Layers {
         // line-translate-anchor
         // line-width
         final JsonNumber width = paintObject.getJsonNumber("line-width");
-        sb.append("width:").append(width == null ? 1 : width.toString()).append(SEMI_COLON);
+        sb.append(StyleKeys.WIDTH).append(':').append(width == null ? 1 : width.toString()).append(SEMI_COLON);
         return sb.toString();
     }
 
@@ -341,12 +344,12 @@ public class Layers {
         // text-allow-overlap
         // text-anchor
         // text-color
-        if (paintObject.containsKey("text-color")) {
-            sb.append("text-color:").append(paintObject.getString("text-color")).append(SEMI_COLON);
+        if (paintObject.containsKey(StyleKeys.TEXT_COLOR)) {
+            sb.append(StyleKeys.TEXT_COLOR).append(':').append(paintObject.getString(StyleKeys.TEXT_COLOR)).append(SEMI_COLON);
         }
         // text-field
         if (layoutObject.containsKey("text-field")) {
-            sb.append("text:")
+            sb.append(StyleKeys.TEXT).append(':')
               .append(layoutObject.getString("text-field").replace("}", EMPTY_STRING).replace("{", EMPTY_STRING))
               .append(SEMI_COLON);
         }
@@ -365,9 +368,9 @@ public class Layers {
                         .orElseGet(() -> fontMatches.stream().filter(font -> font.getPSName().equals(fontString)).findAny()
                         .orElseGet(() -> fontMatches.stream().filter(font -> font.getFamily().equals(fontString)).findAny().orElse(null))));
                     if (setFont != null) {
-                        sb.append("font-family:\"").append(setFont.getFamily()).append('"').append(SEMI_COLON);
-                        sb.append("font-weight:").append(setFont.isBold() ? "bold" : "normal").append(SEMI_COLON);
-                        sb.append("font-style:").append(setFont.isItalic() ? "italic" : "normal").append(SEMI_COLON);
+                        sb.append(StyleKeys.FONT_FAMILY).append(':').append('"').append(setFont.getFamily()).append('"').append(SEMI_COLON);
+                        sb.append(StyleKeys.FONT_WEIGHT).append(':').append(setFont.isBold() ? "bold" : "normal").append(SEMI_COLON);
+                        sb.append(StyleKeys.FONT_STYLE).append(':').append(setFont.isItalic() ? "italic" : "normal").append(SEMI_COLON);
                         break;
                     }
                 }
@@ -375,12 +378,12 @@ public class Layers {
         }
         // text-halo-blur
         // text-halo-color
-        if (paintObject.containsKey("text-halo-color")) {
-            sb.append("text-halo-color:").append(paintObject.getString("text-halo-color")).append(SEMI_COLON);
+        if (paintObject.containsKey(StyleKeys.TEXT_HALO_COLOR)) {
+            sb.append(StyleKeys.TEXT_HALO_COLOR).append(':').append(paintObject.getString(StyleKeys.TEXT_HALO_COLOR)).append(SEMI_COLON);
         }
         // text-halo-width
         if (paintObject.containsKey("text-halo-width")) {
-            sb.append("text-halo-radius:").append(paintObject.getJsonNumber("text-halo-width").intValue()).append(SEMI_COLON);
+            sb.append(StyleKeys.TEXT_HALO_RADIUS).append(':').append(2 * paintObject.getJsonNumber("text-halo-width").intValue()).append(SEMI_COLON);
         }
         // text-ignore-placement
         // text-justify
@@ -391,8 +394,8 @@ public class Layers {
         // text-max-width
         // text-offset
         // text-opacity
-        if (paintObject.containsKey("text-opacity")) {
-            sb.append("text-opacity:").append(paintObject.getJsonNumber("text-opacity").doubleValue()).append(SEMI_COLON);
+        if (paintObject.containsKey(StyleKeys.TEXT_OPACITY)) {
+            sb.append(StyleKeys.TEXT_OPACITY).append(':').append(paintObject.getJsonNumber(StyleKeys.TEXT_OPACITY).doubleValue()).append(SEMI_COLON);
         }
         // text-optional
         // text-padding
@@ -402,7 +405,7 @@ public class Layers {
         // text-rotation-alignment
         // text-size
         final JsonNumber textSize = layoutObject.getJsonNumber("text-size");
-        sb.append("font-size:").append(textSize != null ? textSize.numberValue().toString() : "16").append(SEMI_COLON);
+        sb.append(StyleKeys.FONT_SIZE).append(':').append(textSize != null ? textSize.numberValue().toString() : "16").append(SEMI_COLON);
         // text-transform
         // text-translate
         // text-translate-anchor
@@ -416,7 +419,7 @@ public class Layers {
         // background-color
         final String bgColor = paintObject.getString("background-color", null);
         if (bgColor != null) {
-            sb.append("fill-color:").append(bgColor).append(SEMI_COLON);
+            sb.append(StyleKeys.FILL_COLOR).append(':').append(bgColor).append(SEMI_COLON);
         }
         // background-opacity
         // background-pattern
@@ -427,12 +430,12 @@ public class Layers {
         StringBuilder sb = new StringBuilder(50)
           // fill-antialias
           // fill-color
-          .append("fill-color:").append(paintObject.getString("fill-color", "#000000")).append(SEMI_COLON);
+          .append(StyleKeys.FILL_COLOR).append(':').append(paintObject.getString(StyleKeys.FILL_COLOR, "#000000")).append(SEMI_COLON);
         // fill-opacity
-        final JsonNumber opacity = paintObject.getJsonNumber("fill-opacity");
-        sb.append("fill-opacity:").append(opacity != null ? opacity.numberValue().toString() : "1").append(SEMI_COLON)
+        final JsonNumber opacity = paintObject.getJsonNumber(StyleKeys.FILL_OPACITY);
+        sb.append(StyleKeys.FILL_OPACITY).append(':').append(opacity != null ? opacity.numberValue().toString() : "1").append(SEMI_COLON)
           // fill-outline-color
-          .append("color:").append(paintObject.getString("fill-outline-color",
+          .append(StyleKeys.COLOR).append(':').append(paintObject.getString("fill-outline-color",
           paintObject.getString("fill-color", "#000000"))).append(SEMI_COLON);
         // fill-pattern
         // fill-sort-key
-- 
GitLab


From 6241b4fe6c0530ea855655551da194c16401344b Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Mon, 3 May 2021 08:14:14 -0600
Subject: [PATCH 35/50] VectorDataSet: Remove cast in getPrimitives

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../openstreetmap/josm/data/vector/VectorDataSet.java | 11 +++++------
 1 file changed, 5 insertions(+), 6 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index 1541d169b..9ea4aaa73 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -238,18 +238,17 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     }
 
     @Override
-    public <T extends VectorPrimitive> Collection<T> getPrimitives(
-      Predicate<? super VectorPrimitive> predicate) {
-        return tryRead(this.readWriteLock, () -> {
+    public <T extends VectorPrimitive> Collection<T> getPrimitives(Predicate<? super VectorPrimitive> predicate) {
+        Collection<VectorPrimitive> primitives = tryRead(this.readWriteLock, () -> {
             final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
             if (dataStore == null) {
                 return null;
             }
+            return dataStore.stream().map(MVTTile::getData)
+                    .map(VectorDataStore::getAllPrimitives).flatMap(Collection::stream).distinct().collect(Collectors.toList());
 
-            // This cast is needed (otherwise, Collections.emptyList doesn't compile)
-            return (Collection<T>) new SubclassFilteredCollection<>(dataStore.stream().map(MVTTile::getData)
-                    .map(VectorDataStore::getAllPrimitives).flatMap(Collection::stream).distinct().collect(Collectors.toList()), predicate);
         }).orElseGet(Collections::emptyList);
+        return new SubclassFilteredCollection<>(primitives, predicate);
     }
 
     @Override
-- 
GitLab


From 5d2e8ae0d89e0a2c21ac76a9eee5fbe5bc09985d Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Mon, 3 May 2021 08:42:19 -0600
Subject: [PATCH 36/50] FIXUP: Line length and some IDE issues

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/cache/JCSCachedTileLoaderJob.java            | 1 -
 .../josm/data/imagery/vectortile/mapbox/style/Layers.java  | 6 ++++--
 .../openstreetmap/josm/data/vector/VectorPrimitive.java    | 7 +++----
 3 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java b/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
index d07dff6d1..f8603239a 100644
--- a/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
+++ b/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
@@ -28,7 +28,6 @@ import org.openstreetmap.josm.tools.HttpClient;
 import org.openstreetmap.josm.tools.Logging;
 import org.openstreetmap.josm.tools.Utils;
 
-import org.apache.commons.compress.utils.IOUtils;
 import org.apache.commons.jcs3.access.behavior.ICacheAccess;
 import org.apache.commons.jcs3.engine.behavior.ICacheElement;
 
diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
index b76389cf8..ef6bae625 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
@@ -383,7 +383,8 @@ public class Layers {
         }
         // text-halo-width
         if (paintObject.containsKey("text-halo-width")) {
-            sb.append(StyleKeys.TEXT_HALO_RADIUS).append(':').append(2 * paintObject.getJsonNumber("text-halo-width").intValue()).append(SEMI_COLON);
+            sb.append(StyleKeys.TEXT_HALO_RADIUS).append(':').append(2 * paintObject.getJsonNumber("text-halo-width").intValue())
+                    .append(SEMI_COLON);
         }
         // text-ignore-placement
         // text-justify
@@ -395,7 +396,8 @@ public class Layers {
         // text-offset
         // text-opacity
         if (paintObject.containsKey(StyleKeys.TEXT_OPACITY)) {
-            sb.append(StyleKeys.TEXT_OPACITY).append(':').append(paintObject.getJsonNumber(StyleKeys.TEXT_OPACITY).doubleValue()).append(SEMI_COLON);
+            sb.append(StyleKeys.TEXT_OPACITY).append(':').append(paintObject.getJsonNumber(StyleKeys.TEXT_OPACITY).doubleValue())
+                    .append(SEMI_COLON);
         }
         // text-optional
         // text-padding
diff --git a/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java b/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
index ed9c93937..114b61730 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorPrimitive.java
@@ -67,17 +67,16 @@ public abstract class VectorPrimitive extends AbstractPrimitive implements DataL
 
     @Override
     public VectorDataSet getDataSet() {
-        return this.dataSet;
+        return dataSet;
     }
 
-    protected void setDataSet(VectorDataSet dataSet) {
-        this.dataSet = dataSet;
+    protected void setDataSet(VectorDataSet newDataSet) {
+        dataSet = newDataSet;
     }
 
     /*----------
      * MAPPAINT
      *--------*/
-    private short mappaintCacheIdx;
 
     @Override
     public final StyleCache getCachedStyle() {
-- 
GitLab


From 6314c7c5bccaf2f9b6a6f20ed95a15c9cf1a9602 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Tue, 4 May 2021 09:50:02 -0600
Subject: [PATCH 37/50] FilterWorker: Make it generic

Currently, Filters do not appear to be used in any plugin (sans
Mapillary). This means that now is a good time to generify the filters,
as we add VectorPrimitives, which we will eventually want to filter.

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/osm/AbstractPrimitive.java      | 50 ++++++++++++++++-
 .../josm/data/osm/FilterMatcher.java          | 42 ++++++++-------
 .../josm/data/osm/FilterWorker.java           | 31 ++++++-----
 .../josm/data/osm/IFilterablePrimitive.java   | 51 ++++++++++++++++++
 .../josm/data/osm/OsmPrimitive.java           | 53 ++-----------------
 5 files changed, 146 insertions(+), 81 deletions(-)
 create mode 100644 src/org/openstreetmap/josm/data/osm/IFilterablePrimitive.java

diff --git a/src/org/openstreetmap/josm/data/osm/AbstractPrimitive.java b/src/org/openstreetmap/josm/data/osm/AbstractPrimitive.java
index e4ffa73e0..c7da8ecc3 100644
--- a/src/org/openstreetmap/josm/data/osm/AbstractPrimitive.java
+++ b/src/org/openstreetmap/josm/data/osm/AbstractPrimitive.java
@@ -31,7 +31,7 @@ import org.openstreetmap.josm.tools.Utils;
  *
  * @since 4099
  */
-public abstract class AbstractPrimitive implements IPrimitive {
+public abstract class AbstractPrimitive implements IPrimitive, IFilterablePrimitive {
 
     /**
      * This flag shows, that the properties have been changed by the user
@@ -352,6 +352,18 @@ public abstract class AbstractPrimitive implements IPrimitive {
         }
     }
 
+    /**
+     * Update flags
+     * @param flag The flag to update
+     * @param value The value to set
+     * @return {@code true} if the flags have changed
+     */
+    protected boolean updateFlagsChanged(short flag, boolean value) {
+        int oldFlags = flags;
+        updateFlags(flag, value);
+        return oldFlags != flags;
+    }
+
     @Override
     public void setModified(boolean modified) {
         updateFlags(FLAG_MODIFIED, modified);
@@ -409,6 +421,42 @@ public abstract class AbstractPrimitive implements IPrimitive {
         return (flags & FLAG_INCOMPLETE) != 0;
     }
 
+    @Override
+    public boolean getHiddenType() {
+        return (flags & FLAG_HIDDEN_TYPE) != 0;
+    }
+
+    @Override
+    public boolean getDisabledType() {
+        return (flags & FLAG_DISABLED_TYPE) != 0;
+    }
+
+    @Override
+    public boolean setDisabledState(boolean hidden) {
+        // Store as variables to avoid short circuit boolean return
+        final boolean flagDisabled = updateFlagsChanged(FLAG_DISABLED, true);
+        final boolean flagHideIfDisabled = updateFlagsChanged(FLAG_HIDE_IF_DISABLED, hidden);
+        return flagDisabled || flagHideIfDisabled;
+    }
+
+    @Override
+    public boolean unsetDisabledState() {
+        // Store as variables to avoid short circuit boolean return
+        final boolean flagDisabled = updateFlagsChanged(FLAG_DISABLED, false);
+        final boolean flagHideIfDisabled = updateFlagsChanged(FLAG_HIDE_IF_DISABLED, false);
+        return flagDisabled || flagHideIfDisabled;
+    }
+
+    @Override
+    public void setDisabledType(boolean isExplicit) {
+        updateFlags(FLAG_DISABLED_TYPE, isExplicit);
+    }
+
+    @Override
+    public void setHiddenType(boolean isExplicit) {
+        updateFlags(FLAG_HIDDEN_TYPE, isExplicit);
+    }
+
     protected String getFlagsAsString() {
         StringBuilder builder = new StringBuilder();
 
diff --git a/src/org/openstreetmap/josm/data/osm/FilterMatcher.java b/src/org/openstreetmap/josm/data/osm/FilterMatcher.java
index a7cb334ee..5cdb25a90 100644
--- a/src/org/openstreetmap/josm/data/osm/FilterMatcher.java
+++ b/src/org/openstreetmap/josm/data/osm/FilterMatcher.java
@@ -161,23 +161,25 @@ public class FilterMatcher {
      * @return when hidden is true, returns whether the primitive is hidden
      * when hidden is false, returns whether the primitive is disabled or hidden
      */
-    private static boolean isFiltered(OsmPrimitive primitive, boolean hidden) {
+    private static boolean isFiltered(IPrimitive primitive, boolean hidden) {
         return hidden ? primitive.isDisabledAndHidden() : primitive.isDisabled();
     }
 
     /**
      * Check if primitive is hidden explicitly.
      * Only used for ways and relations.
+     * @param <T> The primitive type
      * @param primitive the primitive to check
      * @param hidden the level where the check is performed
      * @return true, if at least one non-inverted filter applies to the primitive
      */
-    private static boolean isFilterExplicit(OsmPrimitive primitive, boolean hidden) {
+    private static <T extends IFilterablePrimitive> boolean isFilterExplicit(T primitive, boolean hidden) {
         return hidden ? primitive.getHiddenType() : primitive.getDisabledType();
     }
 
     /**
      * Check if all parent ways are filtered.
+     * @param <T> The primitive type
      * @param primitive the primitive to check
      * @param hidden parameter that indicates the minimum level of filtering:
      * true when objects need to be hidden to count as filtered and
@@ -187,28 +189,28 @@ public class FilterMatcher {
      * parameter <code>hidden</code> and
      * (c) at least one of the parent ways is explicitly filtered
      */
-    private static boolean allParentWaysFiltered(OsmPrimitive primitive, boolean hidden) {
-        List<OsmPrimitive> refs = primitive.getReferrers();
+    private static <T extends IPrimitive & IFilterablePrimitive> boolean allParentWaysFiltered(T primitive, boolean hidden) {
+        List<? extends IPrimitive> refs = primitive.getReferrers();
         boolean isExplicit = false;
-        for (OsmPrimitive p: refs) {
-            if (p instanceof Way) {
+        for (IPrimitive p: refs) {
+            if (p instanceof IWay && p instanceof IFilterablePrimitive) {
                 if (!isFiltered(p, hidden))
                     return false;
-                isExplicit |= isFilterExplicit(p, hidden);
+                isExplicit |= isFilterExplicit((IFilterablePrimitive) p, hidden);
             }
         }
         return isExplicit;
     }
 
-    private static boolean oneParentWayNotFiltered(OsmPrimitive primitive, boolean hidden) {
-        return primitive.referrers(Way.class)
+    private static boolean oneParentWayNotFiltered(IPrimitive primitive, boolean hidden) {
+        return primitive.getReferrers().stream().filter(IWay.class::isInstance).map(IWay.class::cast)
                 .anyMatch(p -> !isFiltered(p, hidden));
     }
 
-    private static boolean allParentMultipolygonsFiltered(OsmPrimitive primitive, boolean hidden) {
+    private static boolean allParentMultipolygonsFiltered(IPrimitive primitive, boolean hidden) {
         boolean isExplicit = false;
-        for (Relation r : new SubclassFilteredCollection<OsmPrimitive, Relation>(
-                primitive.getReferrers(), OsmPrimitive::isMultipolygon)) {
+        for (Relation r : new SubclassFilteredCollection<IPrimitive, Relation>(
+                primitive.getReferrers(), IPrimitive::isMultipolygon)) {
             if (!isFiltered(r, hidden))
                 return false;
             isExplicit |= isFilterExplicit(r, hidden);
@@ -216,12 +218,12 @@ public class FilterMatcher {
         return isExplicit;
     }
 
-    private static boolean oneParentMultipolygonNotFiltered(OsmPrimitive primitive, boolean hidden) {
-        return new SubclassFilteredCollection<OsmPrimitive, Relation>(primitive.getReferrers(), OsmPrimitive::isMultipolygon).stream()
+    private static boolean oneParentMultipolygonNotFiltered(IPrimitive primitive, boolean hidden) {
+        return new SubclassFilteredCollection<IPrimitive, IRelation>(primitive.getReferrers(), IPrimitive::isMultipolygon).stream()
                 .anyMatch(r -> !isFiltered(r, hidden));
     }
 
-    private static FilterType test(List<FilterInfo> filters, OsmPrimitive primitive, boolean hidden) {
+    private static <T extends IPrimitive & IFilterablePrimitive> FilterType test(List<FilterInfo> filters, T primitive, boolean hidden) {
         if (primitive.isIncomplete() || primitive.isPreserved())
             return FilterType.NOT_FILTERED;
 
@@ -245,7 +247,7 @@ public class FilterMatcher {
             }
         }
 
-        if (primitive instanceof Node) {
+        if (primitive instanceof INode) {
             if (filtered) {
                 // If there is a parent way, that is not hidden, we  show the
                 // node anyway, unless there is no non-inverted filter that
@@ -266,7 +268,7 @@ public class FilterMatcher {
                 else
                     return FilterType.NOT_FILTERED;
             }
-        } else if (primitive instanceof Way) {
+        } else if (primitive instanceof IWay) {
             if (filtered) {
                 if (explicitlyFiltered)
                     return FilterType.EXPLICIT;
@@ -295,6 +297,7 @@ public class FilterMatcher {
      * Check if primitive is hidden.
      * The filter flags for all parent objects must be set correctly, when
      * calling this method.
+     * @param <T> The primitive type
      * @param primitive the primitive
      * @return FilterType.NOT_FILTERED when primitive is not hidden;
      * FilterType.EXPLICIT when primitive is hidden and there is a non-inverted
@@ -302,7 +305,7 @@ public class FilterMatcher {
      * FilterType.PASSIV when primitive is hidden and all filters that apply
      * are inverted
      */
-    public FilterType isHidden(OsmPrimitive primitive) {
+    public <T extends IPrimitive & IFilterablePrimitive> FilterType isHidden(T primitive) {
         return test(hiddenFilters, primitive, true);
     }
 
@@ -310,6 +313,7 @@ public class FilterMatcher {
      * Check if primitive is disabled.
      * The filter flags for all parent objects must be set correctly, when
      * calling this method.
+     * @param <T> The primitive type
      * @param primitive the primitive
      * @return FilterType.NOT_FILTERED when primitive is not disabled;
      * FilterType.EXPLICIT when primitive is disabled and there is a non-inverted
@@ -317,7 +321,7 @@ public class FilterMatcher {
      * FilterType.PASSIV when primitive is disabled and all filters that apply
      * are inverted
      */
-    public FilterType isDisabled(OsmPrimitive primitive) {
+    public <T extends IPrimitive & IFilterablePrimitive> FilterType isDisabled(T primitive) {
         return test(disabledFilters, primitive, false);
     }
 
diff --git a/src/org/openstreetmap/josm/data/osm/FilterWorker.java b/src/org/openstreetmap/josm/data/osm/FilterWorker.java
index 6850c47fc..89118bdd0 100644
--- a/src/org/openstreetmap/josm/data/osm/FilterWorker.java
+++ b/src/org/openstreetmap/josm/data/osm/FilterWorker.java
@@ -9,7 +9,7 @@ import org.openstreetmap.josm.data.osm.search.SearchParseError;
 import org.openstreetmap.josm.tools.SubclassFilteredCollection;
 
 /**
- * Class for applying {@link Filter}s to {@link OsmPrimitive}s.
+ * Class for applying {@link Filter}s to {@link IPrimitive}s.
  *
  * Provides a bridge between Filter GUI and the data.
  *
@@ -24,37 +24,41 @@ public final class FilterWorker {
     /**
      * Apply the filters to the primitives of the data set.
      *
+     * @param <T> The primitive type
      * @param all the collection of primitives for that the filter state should be updated
      * @param filters the filters
      * @return true, if the filter state (normal / disabled / hidden) of any primitive has changed in the process
      * @throws SearchParseError if the search expression in a filter cannot be parsed
-     * @since 12383
+     * @since 12383, xxx (generics)
      */
-    public static boolean executeFilters(Collection<OsmPrimitive> all, Filter... filters) throws SearchParseError {
+    public static <T extends IPrimitive & IFilterablePrimitive> boolean executeFilters(Collection<T> all, Filter... filters)
+            throws SearchParseError {
         return executeFilters(all, FilterMatcher.of(filters));
     }
 
     /**
      * Apply the filters to the primitives of the data set.
      *
+     * @param <T> The primitive type
      * @param all the collection of primitives for that the filter state should be updated
      * @param filterMatcher the FilterMatcher
      * @return true, if the filter state (normal / disabled / hidden) of any primitive has changed in the process
+     * @since xxx (generics)
      */
-    public static boolean executeFilters(Collection<OsmPrimitive> all, FilterMatcher filterMatcher) {
+    public static <T extends IPrimitive & IFilterablePrimitive> boolean executeFilters(Collection<T> all, FilterMatcher filterMatcher) {
         boolean changed;
         // first relations, then ways and nodes last; this is required to resolve dependencies
-        changed = doExecuteFilters(SubclassFilteredCollection.filter(all, Relation.class::isInstance), filterMatcher);
-        changed |= doExecuteFilters(SubclassFilteredCollection.filter(all, Way.class::isInstance), filterMatcher);
-        changed |= doExecuteFilters(SubclassFilteredCollection.filter(all, Node.class::isInstance), filterMatcher);
+        changed = doExecuteFilters(SubclassFilteredCollection.filter(all, IRelation.class::isInstance), filterMatcher);
+        changed |= doExecuteFilters(SubclassFilteredCollection.filter(all, IWay.class::isInstance), filterMatcher);
+        changed |= doExecuteFilters(SubclassFilteredCollection.filter(all, INode.class::isInstance), filterMatcher);
         return changed;
     }
 
-    private static boolean doExecuteFilters(Collection<OsmPrimitive> all, FilterMatcher filterMatcher) {
+    private static <T extends IPrimitive & IFilterablePrimitive> boolean doExecuteFilters(Collection<T> all, FilterMatcher filterMatcher) {
 
         boolean changed = false;
 
-        for (OsmPrimitive primitive: all) {
+        for (T primitive : all) {
             FilterType hiddenType = filterMatcher.isHidden(primitive);
             if (hiddenType != FilterType.NOT_FILTERED) {
                 changed |= primitive.setDisabledState(true);
@@ -75,24 +79,27 @@ public final class FilterWorker {
     /**
      * Apply the filters to a single primitive.
      *
+     * @param <T> the primitive type
      * @param primitive the primitive
      * @param filterMatcher the FilterMatcher
      * @return true, if the filter state (normal / disabled / hidden)
      * of the primitive has changed in the process
+     * @since xxx (generics)
      */
-    public static boolean executeFilters(OsmPrimitive primitive, FilterMatcher filterMatcher) {
+    public static <T extends IPrimitive & IFilterablePrimitive> boolean executeFilters(T primitive, FilterMatcher filterMatcher) {
         return doExecuteFilters(Collections.singleton(primitive), filterMatcher);
     }
 
     /**
      * Clear all filter flags, i.e.&nbsp;turn off filters.
+     * @param <T> the primitive type
      * @param prims the primitives
      * @return true, if the filter state (normal / disabled / hidden) of any primitive has changed in the process
      * @since 12388 (signature)
      */
-    public static boolean clearFilterFlags(Collection<OsmPrimitive> prims) {
+    public static <T extends IPrimitive & IFilterablePrimitive> boolean clearFilterFlags(Collection<T> prims) {
         boolean changed = false;
-        for (OsmPrimitive osm : prims) {
+        for (T osm : prims) {
             changed |= osm.unsetDisabledState();
         }
         return changed;
diff --git a/src/org/openstreetmap/josm/data/osm/IFilterablePrimitive.java b/src/org/openstreetmap/josm/data/osm/IFilterablePrimitive.java
new file mode 100644
index 000000000..f8ba55cfc
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/osm/IFilterablePrimitive.java
@@ -0,0 +1,51 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm;
+
+/**
+ * An interface used to indicate that a primitive is filterable
+ * @author Taylor Smock
+ * @since xxx
+ */
+public interface IFilterablePrimitive {
+    /**
+     * Get binary property used internally by the filter mechanism.
+     * @return {@code true} if this object has the "hidden type" flag enabled
+     */
+    boolean getHiddenType();
+
+    /**
+     * Get binary property used internally by the filter mechanism.
+     * @return {@code true} if this object has the "disabled type" flag enabled
+     */
+    boolean getDisabledType();
+
+    /**
+     * Make the primitive disabled (e.g.&nbsp;if a filter applies).
+     *
+     * To enable the primitive again, use unsetDisabledState.
+     * @param hidden if the primitive should be completely hidden from view or
+     *             just shown in gray color.
+     * @return true, any flag has changed; false if you try to set the disabled
+     * state to the value that is already preset
+     */
+    boolean setDisabledState(boolean hidden);
+
+    /**
+     * Remove the disabled flag from the primitive.
+     * Afterwards, the primitive is displayed normally and can be selected again.
+     * @return {@code true} if a change occurred
+     */
+    boolean unsetDisabledState();
+
+    /**
+     * Set binary property used internally by the filter mechanism.
+     * @param isExplicit new "disabled type" flag value
+     */
+    void setDisabledType(boolean isExplicit);
+
+    /**
+     * Set binary property used internally by the filter mechanism.
+     * @param isExplicit new "hidden type" flag value
+     */
+    void setHiddenType(boolean isExplicit);
+}
diff --git a/src/org/openstreetmap/josm/data/osm/OsmPrimitive.java b/src/org/openstreetmap/josm/data/osm/OsmPrimitive.java
index 496751f98..3db783968 100644
--- a/src/org/openstreetmap/josm/data/osm/OsmPrimitive.java
+++ b/src/org/openstreetmap/josm/data/osm/OsmPrimitive.java
@@ -329,22 +329,11 @@ public abstract class OsmPrimitive extends AbstractPrimitive implements Template
         }
     }
 
-    /**
-     * Make the primitive disabled (e.g.&nbsp;if a filter applies).
-     *
-     * To enable the primitive again, use unsetDisabledState.
-     * @param hidden if the primitive should be completely hidden from view or
-     *             just shown in gray color.
-     * @return true, any flag has changed; false if you try to set the disabled
-     * state to the value that is already preset
-     */
+    @Override
     public boolean setDisabledState(boolean hidden) {
         boolean locked = writeLock();
         try {
-            int oldFlags = flags;
-            updateFlagsNoLock(FLAG_DISABLED, true);
-            updateFlagsNoLock(FLAG_HIDE_IF_DISABLED, hidden);
-            return oldFlags != flags;
+            return super.setDisabledState(hidden);
         } finally {
             writeUnlock(locked);
         }
@@ -355,34 +344,16 @@ public abstract class OsmPrimitive extends AbstractPrimitive implements Template
      * Afterwards, the primitive is displayed normally and can be selected again.
      * @return {@code true} if a change occurred
      */
+    @Override
     public boolean unsetDisabledState() {
         boolean locked = writeLock();
         try {
-            int oldFlags = flags;
-            updateFlagsNoLock(FLAG_DISABLED, false);
-            updateFlagsNoLock(FLAG_HIDE_IF_DISABLED, false);
-            return oldFlags != flags;
+            return super.unsetDisabledState();
         } finally {
             writeUnlock(locked);
         }
     }
 
-    /**
-     * Set binary property used internally by the filter mechanism.
-     * @param isExplicit new "disabled type" flag value
-     */
-    public void setDisabledType(boolean isExplicit) {
-        updateFlags(FLAG_DISABLED_TYPE, isExplicit);
-    }
-
-    /**
-     * Set binary property used internally by the filter mechanism.
-     * @param isExplicit new "hidden type" flag value
-     */
-    public void setHiddenType(boolean isExplicit) {
-        updateFlags(FLAG_HIDDEN_TYPE, isExplicit);
-    }
-
     /**
      * Set binary property used internally by the filter mechanism.
      * @param isPreserved new "preserved" flag value
@@ -402,22 +373,6 @@ public abstract class OsmPrimitive extends AbstractPrimitive implements Template
         return ((flags & FLAG_DISABLED) != 0) && ((flags & FLAG_HIDE_IF_DISABLED) != 0);
     }
 
-    /**
-     * Get binary property used internally by the filter mechanism.
-     * @return {@code true} if this object has the "hidden type" flag enabled
-     */
-    public boolean getHiddenType() {
-        return (flags & FLAG_HIDDEN_TYPE) != 0;
-    }
-
-    /**
-     * Get binary property used internally by the filter mechanism.
-     * @return {@code true} if this object has the "disabled type" flag enabled
-     */
-    public boolean getDisabledType() {
-        return (flags & FLAG_DISABLED_TYPE) != 0;
-    }
-
     @Override
     public boolean isPreserved() {
         return (flags & FLAG_PRESERVED) != 0;
-- 
GitLab


From 337febd24fb7be52bd38cd9135815991e8b3d800 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Tue, 4 May 2021 10:02:28 -0600
Subject: [PATCH 38/50] MapboxVectorTileSource: Check that a json has required
 keys

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../vectortile/mapbox/MapboxVectorTileSource.java        | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java
index 62647d1bb..18a4911a7 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MapboxVectorTileSource.java
@@ -10,6 +10,7 @@ import java.util.stream.Collectors;
 
 import javax.json.Json;
 import javax.json.JsonException;
+import javax.json.JsonObject;
 import javax.json.JsonReader;
 
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
@@ -41,9 +42,11 @@ public class MapboxVectorTileSource extends JosmTemplatedTMSTileSource {
         try (CachedFile style = new CachedFile(info.getUrl());
           InputStream inputStream = style.getInputStream();
           JsonReader reader = Json.createReader(inputStream)) {
-            reader.readObject();
-            // OK, we have a stylesheet
-            mapBoxVectorStyle = MapboxVectorStyle.getMapboxVectorStyle(info.getUrl());
+            JsonObject object = reader.readObject();
+            // OK, we may have a stylesheet. "version", "layers", and "sources" are all required.
+            if (object.containsKey("version") && object.containsKey("layers") && object.containsKey("sources")) {
+                mapBoxVectorStyle = MapboxVectorStyle.getMapboxVectorStyle(info.getUrl());
+            }
         } catch (IOException | JsonException e) {
             Logging.trace(e);
         }
-- 
GitLab


From b6ea0250e2c114923a218c3641d301092f97680e Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Tue, 4 May 2021 10:47:57 -0600
Subject: [PATCH 39/50] Selection Listener: Rename interface methods

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/osm/event/IDataSelectionEventSource.java        | 4 ++--
 src/org/openstreetmap/josm/data/vector/VectorDataSet.java     | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java b/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java
index 4f1d75d18..14fe8eee5 100644
--- a/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java
+++ b/src/org/openstreetmap/josm/data/osm/event/IDataSelectionEventSource.java
@@ -24,12 +24,12 @@ public interface IDataSelectionEventSource<O extends IPrimitive, N extends INode
      * @param listener The listener to add
      * @return {@code true} if the listener was added
      */
-    boolean addListener(IDataSelectionListener<O, N, W, R, D> listener);
+    boolean addSelectionListener(IDataSelectionListener<O, N, W, R, D> listener);
 
     /**
      * Remove a listener
      * @param listener The listener to remove
      * @return {@code true} if the listener was removed
      */
-    boolean removeListener(IDataSelectionListener<O, N, W, R, D> listener);
+    boolean removeSelectionListener(IDataSelectionListener<O, N, W, R, D> listener);
 }
diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index 9ea4aaa73..b377065af 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -637,7 +637,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     }
 
     @Override
-    public boolean addListener(IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> listener) {
+    public boolean addSelectionListener(IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> listener) {
         if (!this.listeners.containsListener(listener)) {
             this.listeners.addListener(listener);
         }
@@ -645,7 +645,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     }
 
     @Override
-    public boolean removeListener(IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> listener) {
+    public boolean removeSelectionListener(IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> listener) {
         if (this.listeners.containsListener(listener)) {
             this.listeners.removeListener(listener);
         }
-- 
GitLab


From 01cf96f513a58e0c9bf429046231b7d34b0e6c09 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Tue, 4 May 2021 13:28:52 -0600
Subject: [PATCH 40/50] Mapbox Style: Fix text-halo-width

* JOSM equivalent is text-halo-radius, which means text-halo-width
  needed to be divided by 2

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/imagery/vectortile/mapbox/style/Layers.java     | 2 +-
 .../josm/data/imagery/vectortile/mapbox/style/LayersTest.java | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
index ef6bae625..157e48961 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/Layers.java
@@ -383,7 +383,7 @@ public class Layers {
         }
         // text-halo-width
         if (paintObject.containsKey("text-halo-width")) {
-            sb.append(StyleKeys.TEXT_HALO_RADIUS).append(':').append(2 * paintObject.getJsonNumber("text-halo-width").intValue())
+            sb.append(StyleKeys.TEXT_HALO_RADIUS).append(':').append(paintObject.getJsonNumber("text-halo-width").intValue() / 2)
                     .append(SEMI_COLON);
         }
         // text-ignore-placement
diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java
index 28b09b950..5db4d5a3d 100644
--- a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java
@@ -248,7 +248,7 @@ class LayersTest {
         assertEquals(Layers.Type.SYMBOL, fullLineLayer.getType());
         assertEquals("node::random-layer-id{icon-image:concat(\"random-image\");icon-offset-x:2.0;icon-offset-y:3.0;"
           + "icon-opacity:0.5;icon-rotation:30.0;text-color:#fffff0;text:something;font-family:\"SansSerif\";font-weight:normal;"
-          + "font-style:normal;text-halo-color:#ffffff;text-halo-radius:16;text-opacity:0.6;font-size:25;}", fullLineLayer.toString());
+          + "font-style:normal;text-halo-color:#ffffff;text-halo-radius:8;text-opacity:0.6;font-size:25;}", fullLineLayer.toString());
 
         // Test an invisible symbol
         Layers fullLineInvisibleLayer = new Layers(Json.createObjectBuilder()
@@ -272,7 +272,7 @@ class LayersTest {
           .build());
         assertEquals("node::random-layer-id{icon-image:concat(tag(\"value\"));icon-offset-x:2.0;icon-offset-y:3.0;"
           + "icon-opacity:0.5;icon-rotation:30.0;text-color:#fffff0;text:something;font-family:\"SansSerif\";font-weight:normal;"
-          + "font-style:normal;text-halo-color:#ffffff;text-halo-radius:16;text-opacity:0.6;font-size:25;}",
+          + "font-style:normal;text-halo-color:#ffffff;text-halo-radius:8;text-opacity:0.6;font-size:25;}",
           fullOneIconImagePlaceholderLineLayer.toString());
 
         // Test with placeholders in icon-image
-- 
GitLab


From b05ac219b7fbaafd891b55924122fce4694bf589 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Tue, 4 May 2021 13:31:00 -0600
Subject: [PATCH 41/50] ProtoBufTest -> ProtobufTest and 2048 -> 4096

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/protobuf/ProtobufRecordTest.java              | 4 ++--
 .../data/protobuf/{ProtoBufTest.java => ProtobufTest.java}  | 6 +++---
 2 files changed, 5 insertions(+), 5 deletions(-)
 rename test/unit/org/openstreetmap/josm/data/protobuf/{ProtoBufTest.java => ProtobufTest.java} (98%)

diff --git a/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java
index 6573d36fc..f99aa7e2e 100644
--- a/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java
+++ b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufRecordTest.java
@@ -14,7 +14,7 @@ import org.junit.jupiter.api.Test;
 class ProtobufRecordTest {
     @Test
     void testFixed32() throws IOException {
-        ProtobufParser parser = new ProtobufParser(ProtoBufTest.toByteArray(new int[] {0x0d, 0x00, 0x00, 0x80, 0x3f}));
+        ProtobufParser parser = new ProtobufParser(ProtobufTest.toByteArray(new int[] {0x0d, 0x00, 0x00, 0x80, 0x3f}));
         ProtobufRecord thirtyTwoBit = new ProtobufRecord(parser);
         assertEquals(WireType.THIRTY_TWO_BIT, thirtyTwoBit.getType());
         assertEquals(1f, thirtyTwoBit.asFloat());
@@ -22,7 +22,7 @@ class ProtobufRecordTest {
 
     @Test
     void testUnknown() throws IOException {
-        ProtobufParser parser = new ProtobufParser(ProtoBufTest.toByteArray(new int[] {0x0f, 0x00, 0x00, 0x80, 0x3f}));
+        ProtobufParser parser = new ProtobufParser(ProtobufTest.toByteArray(new int[] {0x0f, 0x00, 0x00, 0x80, 0x3f}));
         ProtobufRecord unknown = new ProtobufRecord(parser);
         assertEquals(WireType.UNKNOWN, unknown.getType());
         assertEquals(0, unknown.getBytes().length);
diff --git a/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufTest.java
similarity index 98%
rename from test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java
rename to test/unit/org/openstreetmap/josm/data/protobuf/ProtobufTest.java
index e5cd8c738..d6f42cca8 100644
--- a/test/unit/org/openstreetmap/josm/data/protobuf/ProtoBufTest.java
+++ b/test/unit/org/openstreetmap/josm/data/protobuf/ProtobufTest.java
@@ -42,7 +42,7 @@ import org.junit.jupiter.api.extension.RegisterExtension;
  * @author Taylor Smock
  * @since xxx
  */
-class ProtoBufTest {
+class ProtobufTest {
     /**
      * Convert an int array into a byte array
      * @param intArray The int array to convert (NOTE: numbers must be below 255)
@@ -93,8 +93,8 @@ class ProtoBufTest {
         Layer mapillaryPictures = layers.get(1);
         assertEquals("mapillary-sequences", mapillarySequences.getName());
         assertEquals("mapillary-images", mapillaryPictures.getName());
-        assertEquals(2048, mapillarySequences.getExtent());
-        assertEquals(2048, mapillaryPictures.getExtent());
+        assertEquals(4096, mapillarySequences.getExtent());
+        assertEquals(4096, mapillaryPictures.getExtent());
 
         assertEquals(1,
                 mapillarySequences.getFeatures().stream().filter(feature -> feature.getId() == 233760500).count());
-- 
GitLab


From 88d0f9994728aced4b449bff4db974a143c43edf Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Tue, 4 May 2021 14:10:22 -0600
Subject: [PATCH 42/50] FIXUP: tests were broken due to removal of dedup
 functionality

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/vector/VectorDataSet.java       | 23 +++++++-
 .../josm/data/vector/VectorDataSetTest.java   | 52 ++++++-------------
 2 files changed, 37 insertions(+), 38 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index b377065af..d626156d4 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -51,6 +51,8 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     // Note: In Java 8, computeIfAbsent is blocking for both pre-existing and new values. In Java 9, it is only blocking
     // for new values (perf increase). See JDK-8161372 for more info.
     private final Map<Integer, Storage<MVTTile>> dataStoreMap = new ConcurrentHashMap<>();
+    // This is for "custom" data
+    private final VectorDataStore customDataStore = new VectorDataStore();
     // Both of these listener lists are useless, since they expect OsmPrimitives at this time
     private final ListenerList<HighlightUpdateListener> highlightUpdateListenerListenerList = ListenerList.create();
     private final ListenerList<DataSelectionListener> dataSelectionListenerListenerList = ListenerList.create();
@@ -134,9 +136,25 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
         this.name = name;
     }
 
+    /**
+     * Add a primitive to the custom data store
+     * @param primitive the primitive to add
+     */
     @Override
     public void addPrimitive(VectorPrimitive primitive) {
-        throw new UnsupportedOperationException("Custom vector primitives are not currently supported");
+        tryWrite(this.readWriteLock, () -> {
+            this.customDataStore.addPrimitive(primitive);
+            primitive.setDataSet(this);
+        });
+    }
+
+    /**
+     * Remove a primitive from the custom data store
+     * @param primitive The primitive to add to the custom data store
+     */
+    public void removePrimitive(VectorPrimitive primitive) {
+        this.customDataStore.removePrimitive(primitive);
+        primitive.setDataSet(null);
     }
 
     @Override
@@ -645,7 +663,8 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     }
 
     @Override
-    public boolean removeSelectionListener(IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> listener) {
+    public boolean removeSelectionListener(
+            IDataSelectionListener<VectorPrimitive, VectorNode, VectorWay, VectorRelation, VectorDataSet> listener) {
         if (this.listeners.containsListener(listener)) {
             this.listeners.removeListener(listener);
         }
diff --git a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
index 0e7a572d5..c0a1c8532 100644
--- a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
+++ b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
@@ -1,20 +1,11 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.data.vector;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-
-import java.nio.file.Paths;
-import java.text.MessageFormat;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
+import org.awaitility.Awaitility;
+import org.awaitility.Durations;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
 import org.openstreetmap.josm.TestUtils;
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
 import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTTile;
@@ -23,11 +14,14 @@ import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MapboxVectorTileSou
 import org.openstreetmap.josm.gui.layer.imagery.MVTLayer;
 import org.openstreetmap.josm.testutils.JOSMTestRules;
 
-import org.awaitility.Awaitility;
-import org.awaitility.Durations;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.RegisterExtension;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
  * A test for {@link VectorDataSet}
@@ -122,24 +116,10 @@ class VectorDataSetTest {
         assertEquals(55, dataSet.getNodes().stream().filter(node -> "mapillary-images".equals(node.getLayer())).count());
         // Please note that this dataset originally had the <i>same</i> id for all the images
         // (MVT v2 explicitly said that ids had to be unique in a layer, MVT v1 did not)
-        assertEquals(55, dataSet.getNodes().stream().map(node -> node.get("original_id")).count());
-        assertEquals(1, dataSet.getNodes().stream().map(node -> node.get("original_id")).distinct().count());
+        // This number is from the 56 nodes - original node with id - single node on mapillary-sequences layer = 54
+        assertEquals(54, dataSet.getNodes().stream().filter(node -> node.hasKey("original_id")).count());
+        assertEquals(1, dataSet.getNodes().stream().filter(node -> node.hasKey("original_id")).map(node -> node.get("original_id")).distinct().count());
         assertEquals(1, dataSet.getWays().size());
         assertEquals(0, dataSet.getRelations().size());
     }
-
-    @Test
-    void testWayDeduplicationSimple() {
-        final VectorDataSet dataSet = this.layer.getData();
-        assertTrue(dataSet.allPrimitives().isEmpty());
-
-        // Set the zoom to 14, as that is the tile we are checking
-        dataSet.setZoom(14);
-        // Load tiles that are next to each other
-        loadTile(this.layer, 14, 3248, 6258, 14, 3249, 6258);
-
-        Map<Long, List<VectorWay>> wayGroups = dataSet.getWays().stream()
-          .collect(Collectors.groupingBy(VectorWay::getId));
-        wayGroups.forEach((id, ways) -> assertEquals(1, ways.size(), MessageFormat.format("{0} was not deduplicated", id)));
-    }
 }
-- 
GitLab


From 48a0c94f8a31ca8e57e3b3dce677fc034d1d8303 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Tue, 4 May 2021 14:51:45 -0600
Subject: [PATCH 43/50] VectorDataSet: Enable custom data for layers

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/vector/VectorDataSet.java       | 50 ++++++++-----------
 1 file changed, 21 insertions(+), 29 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index d626156d4..afb958232 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -168,11 +168,9 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     public List<VectorNode> searchNodes(BBox bbox) {
         return tryRead(this.readWriteLock, () -> {
             final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
-            if (dataStore != null) {
-                return dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
-                        .flatMap(store -> store.searchNodes(bbox).stream()).collect(Collectors.toList());
-            }
-            return null;
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .flatMap(store -> store.searchNodes(bbox).stream()).collect(Collectors.toList());
         }).orElseGet(Collections::emptyList);
     }
 
@@ -180,9 +178,9 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     public boolean containsNode(VectorNode vectorNode) {
         return tryRead(this.readWriteLock, () -> {
             final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
-            return dataStore != null &&
-                    dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
-                            .anyMatch(store -> store.containsNode(vectorNode));
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .anyMatch(store -> store.containsNode(vectorNode));
         }).orElse(Boolean.FALSE);
     }
 
@@ -190,11 +188,9 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     public List<VectorWay> searchWays(BBox bbox) {
         return tryRead(this.readWriteLock, () -> {
             final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
-            if (dataStore != null) {
-                return dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
-                        .flatMap(store -> store.searchWays(bbox).stream()).collect(Collectors.toList());
-            }
-            return null;
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .flatMap(store -> store.searchWays(bbox).stream()).collect(Collectors.toList());
         }).orElseGet(Collections::emptyList);
     }
 
@@ -202,9 +198,9 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     public boolean containsWay(VectorWay vectorWay) {
         return tryRead(this.readWriteLock, () -> {
             final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
-            return dataStore != null &&
-                    dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
-                            .anyMatch(store -> store.containsWay(vectorWay));
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .anyMatch(store -> store.containsWay(vectorWay));
         }).orElse(Boolean.FALSE);
     }
 
@@ -212,11 +208,9 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     public List<VectorRelation> searchRelations(BBox bbox) {
         return tryRead(this.readWriteLock, () -> {
             final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
-            if (dataStore != null) {
-                return dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
-                        .flatMap(store -> store.searchRelations(bbox).stream()).collect(Collectors.toList());
-            }
-            return null;
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .flatMap(store -> store.searchRelations(bbox).stream()).collect(Collectors.toList());
         }).orElseGet(Collections::emptyList);
     }
 
@@ -224,9 +218,9 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     public boolean containsRelation(VectorRelation vectorRelation) {
         return tryRead(this.readWriteLock, () -> {
             final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
-            return dataStore != null &&
-                    dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getStore)
-                            .anyMatch(store -> store.containsRelation(vectorRelation));
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getStore)
+                    .anyMatch(store -> store.containsRelation(vectorRelation));
         }).orElse(Boolean.FALSE);
     }
 
@@ -248,11 +242,9 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
      */
     public Stream<VectorPrimitive> getPrimitivesById(PrimitiveId... primitiveIds) {
         final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
-        if (dataStore != null) {
-            return dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getPrimitivesMap)
-                    .flatMap(m -> Stream.of(primitiveIds).map(m::get)).filter(Objects::nonNull);
-        }
-        return Stream.empty();
+        final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+        return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getPrimitivesMap)
+                .flatMap(m -> Stream.of(primitiveIds).map(m::get)).filter(Objects::nonNull);
     }
 
     @Override
-- 
GitLab


From 556c1c75df8fdef7fbf1e749a4b4d1e3a62b5aac Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Tue, 4 May 2021 16:04:46 -0600
Subject: [PATCH 44/50] VectorDataSet: Get all primitives with the id

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 src/org/openstreetmap/josm/data/vector/VectorDataSet.java | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index afb958232..0e8b02ea5 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -429,7 +429,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     private void toggleSelectedImpl(Stream<? extends PrimitiveId> osm) {
         this.doSelectionChange(old -> new IDataSelectionListener.SelectionToggleEvent<>(this, old,
-                osm.map(this::getPrimitiveById).filter(Objects::nonNull)));
+                osm.flatMap(this::getPrimitivesById).filter(Objects::nonNull)));
     }
 
     @Override
@@ -444,7 +444,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     private void setSelectedImpl(Stream<? extends PrimitiveId> osm) {
         this.doSelectionChange(old -> new IDataSelectionListener.SelectionReplaceEvent<>(this, old,
-                osm.filter(Objects::nonNull).map(this::getPrimitiveById).filter(Objects::nonNull)));
+                osm.filter(Objects::nonNull).flatMap(this::getPrimitivesById).filter(Objects::nonNull)));
     }
 
     @Override
@@ -459,7 +459,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     private void addSelectedImpl(Stream<? extends PrimitiveId> osm) {
         this.doSelectionChange(old -> new IDataSelectionListener.SelectionAddEvent<>(this, old,
-                osm.map(this::getPrimitiveById).filter(Objects::nonNull)));
+                osm.flatMap(this::getPrimitivesById).filter(Objects::nonNull)));
     }
 
     @Override
@@ -479,7 +479,7 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
 
     private void clearSelectionImpl(Stream<? extends PrimitiveId> osm) {
         this.doSelectionChange(old -> new IDataSelectionListener.SelectionRemoveEvent<>(this, old,
-                osm.map(this::getPrimitiveById).filter(Objects::nonNull)));
+                osm.flatMap(this::getPrimitivesById).filter(Objects::nonNull)));
     }
 
     /**
-- 
GitLab


From 710dd4df7644917bc8072f674265e36ccac3b56a Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Wed, 5 May 2021 09:08:59 -0600
Subject: [PATCH 45/50] LayersTest: Fix width/radius issue

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/imagery/vectortile/mapbox/style/LayersTest.java   | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java
index 5db4d5a3d..d4b87daa0 100644
--- a/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java
+++ b/test/unit/org/openstreetmap/josm/data/imagery/vectortile/mapbox/style/LayersTest.java
@@ -285,7 +285,7 @@ class LayersTest {
           .build());
         assertEquals("node::random-layer-id{icon-image:concat(\"something/\",tag(\"value\"),\"/random\");icon-offset-x:2.0;"
           + "icon-offset-y:3.0;icon-opacity:0.5;icon-rotation:30.0;text-color:#fffff0;text:something;font-family:\"SansSerif\";"
-          + "font-weight:normal;font-style:normal;text-halo-color:#ffffff;text-halo-radius:16;text-opacity:0.6;font-size:25;}",
+          + "font-weight:normal;font-style:normal;text-halo-color:#ffffff;text-halo-radius:8;text-opacity:0.6;font-size:25;}",
           fullOneIconImagePlaceholderExtraLineLayer.toString());
 
         // Test with placeholders in icon-image
-- 
GitLab


From d836e105168cfb32444ff44296bffe17ab1a1436 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Wed, 5 May 2021 09:19:11 -0600
Subject: [PATCH 46/50] VectorDataSet: Use custom data layer when getting all
 primitives

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 src/org/openstreetmap/josm/data/vector/VectorDataSet.java | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index 0e8b02ea5..1851603a4 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -251,10 +251,8 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     public <T extends VectorPrimitive> Collection<T> getPrimitives(Predicate<? super VectorPrimitive> predicate) {
         Collection<VectorPrimitive> primitives = tryRead(this.readWriteLock, () -> {
             final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
-            if (dataStore == null) {
-                return null;
-            }
-            return dataStore.stream().map(MVTTile::getData)
+            final Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+            return Stream.concat(dataStoreStream, Stream.of(this.customDataStore))
                     .map(VectorDataStore::getAllPrimitives).flatMap(Collection::stream).distinct().collect(Collectors.toList());
 
         }).orElseGet(Collections::emptyList);
-- 
GitLab


From c3f785230a09868681023c4ff953b69ea25cf2c8 Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Wed, 5 May 2021 16:38:17 -0600
Subject: [PATCH 47/50] VectorDataSet: Get selection from custom data as well

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/vector/VectorDataSet.java       | 20 ++++++++-----------
 1 file changed, 8 insertions(+), 12 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
index 1851603a4..a87fd34fa 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataSet.java
@@ -360,18 +360,14 @@ public class VectorDataSet implements OsmData<VectorPrimitive, VectorNode, Vecto
     public Collection<VectorPrimitive> getAllSelected() {
         return tryRead(this.readWriteLock, () -> {
             final Storage<MVTTile> dataStore = this.getBestZoomDataStore().orElse(null);
-            if (dataStore != null) {
-                // The dataStore is what we don't want to concurrently modify
-                synchronized (dataStore) {
-                    return dataStore.stream().map(MVTTile::getData).map(VectorDataStore::getPrimitivesMap).flatMap(dataMap -> {
-                        // Synchronize on dataMap to avoid concurrent modification errors
-                        synchronized (dataMap) {
-                            return this.currentSelectedPrimitives.stream().map(dataMap::get).filter(Objects::nonNull);
-                        }
-                    }).collect(Collectors.toList());
-                }
-            }
-            return null;
+            Stream<VectorDataStore> dataStoreStream = dataStore != null ? dataStore.stream().map(MVTTile::getData) : Stream.empty();
+                return Stream.concat(dataStoreStream, Stream.of(this.customDataStore)).map(VectorDataStore::getPrimitivesMap)
+                  .flatMap(dataMap -> {
+                    // Synchronize on dataMap to avoid concurrent modification errors
+                    synchronized (dataMap) {
+                        return this.currentSelectedPrimitives.stream().map(dataMap::get).filter(Objects::nonNull);
+                    }
+                }).collect(Collectors.toList());
         }).orElseGet(Collections::emptyList);
     }
 
-- 
GitLab


From d4ea49dea0b95392a86e66798b65fbfd206197fc Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Wed, 5 May 2021 16:38:33 -0600
Subject: [PATCH 48/50] VectorDataSetTest: PMD/checkstyle

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 src/org/openstreetmap/josm/data/vector/VectorDataStore.java   | 4 +++-
 .../org/openstreetmap/josm/data/vector/VectorDataSetTest.java | 3 ++-
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
index dceef3b8e..2d1cd17d5 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
@@ -40,6 +40,8 @@ import java.util.stream.Collectors;
 public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, VectorWay, VectorRelation> implements Destroyable {
     private static final String JOSM_MERGE_TYPE_KEY = "josm_merge_type";
     private static final String ORIGINAL_ID = "original_id";
+    private static final String MULTIPOLYGON_TYPE = "multipolygon";
+    private static final String RELATION_TYPE = "type";
 
     @Override
     protected void addPrimitive(VectorPrimitive primitive) {
@@ -292,7 +294,7 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
                           .orElse(null);
                     } else if (shape instanceof Area) {
                         primitive = areaToRelation(tile, layer, featureObjects, (Area) shape);
-                        primitive.put("type", "multipolygon");
+                        primitive.put(RELATION_TYPE, MULTIPOLYGON_TYPE);
                     } else {
                         // We shouldn't hit this, but just in case
                         throw new UnsupportedOperationException();
diff --git a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
index c0a1c8532..69035abc0 100644
--- a/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
+++ b/test/unit/org/openstreetmap/josm/data/vector/VectorDataSetTest.java
@@ -118,7 +118,8 @@ class VectorDataSetTest {
         // (MVT v2 explicitly said that ids had to be unique in a layer, MVT v1 did not)
         // This number is from the 56 nodes - original node with id - single node on mapillary-sequences layer = 54
         assertEquals(54, dataSet.getNodes().stream().filter(node -> node.hasKey("original_id")).count());
-        assertEquals(1, dataSet.getNodes().stream().filter(node -> node.hasKey("original_id")).map(node -> node.get("original_id")).distinct().count());
+        assertEquals(1, dataSet.getNodes().stream().filter(node -> node.hasKey("original_id")).map(node -> node.get("original_id"))
+            .distinct().count());
         assertEquals(1, dataSet.getWays().size());
         assertEquals(0, dataSet.getRelations().size());
     }
-- 
GitLab


From a1387d79c5fa547b0b4ba401cf54b7cc2659f55b Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 6 May 2021 07:14:06 -0600
Subject: [PATCH 49/50] Mapbox Vector Tiles: Modify file formats and their
 documentation

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 .../josm/data/imagery/vectortile/mapbox/MVTFile.java     | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java
index 84ac8ae89..7a398537b 100644
--- a/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java
+++ b/src/org/openstreetmap/josm/data/imagery/vectortile/mapbox/MVTFile.java
@@ -13,15 +13,16 @@ import java.util.List;
 public final class MVTFile {
     /**
      * Extensions for Mapbox Vector Tiles.
-     * This is a SHOULD, <i>not</i> a MUST.
+     * {@code mvt} is a SHOULD, <i>not</i> a MUST.
      */
-    public static final List<String> EXTENSION = Collections.unmodifiableList(Arrays.asList("mvt"));
+    public static final List<String> EXTENSION = Collections.unmodifiableList(Arrays.asList("mvt", "pbf"));
 
     /**
      * mimetypes for Mapbox Vector Tiles
-     * This is a SHOULD, <i>not</i> a MUST.
+     * This {@code application/vnd.mapbox-vector-tile}is a SHOULD, <i>not</i> a MUST.
      */
-    public static final List<String> MIMETYPE = Collections.unmodifiableList(Arrays.asList("application/vnd.mapbox-vector-tile"));
+    public static final List<String> MIMETYPE = Collections.unmodifiableList(Arrays.asList("application/vnd.mapbox-vector-tile",
+            "application/x-protobuf"));
 
     /**
      * The default projection. This is Web Mercator, per specification.
-- 
GitLab


From 3ee61eadda7b58999c17d5f8bd3706cada90c8ba Mon Sep 17 00:00:00 2001
From: Taylor Smock <tsmock@fb.com>
Date: Thu, 6 May 2021 07:19:10 -0600
Subject: [PATCH 50/50] Vector Tiles: Account for features with no tags

Signed-off-by: Taylor Smock <tsmock@fb.com>
---
 src/org/openstreetmap/josm/data/vector/VectorDataStore.java | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
index 2d1cd17d5..793bbf34a 100644
--- a/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
+++ b/src/org/openstreetmap/josm/data/vector/VectorDataStore.java
@@ -323,7 +323,9 @@ public class VectorDataStore extends DataStore<VectorPrimitive, VectorNode, Vect
                     primitive.put(ORIGINAL_ID, Long.toString(feature.getId()));
                     primitive.setId(primitive.getIdGenerator().generateUniqueId());
                 }
-                feature.getTags().forEach(primitive::put);
+                if (feature.getTags() != null) {
+                    feature.getTags().forEach(primitive::put);
+                }
                 featureObjects.forEach(this::addPrimitive);
                 primaryFeatureObjects.forEach(this::addPrimitive);
                 try {
-- 
GitLab

