You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by ma...@apache.org on 2022/03/26 12:22:01 UTC

[commons-geometry] branch master updated: GEOMETRY-142: adding PointMap and PointSet implementations

This is an automated email from the ASF dual-hosted git repository.

mattjuntunen pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-geometry.git


The following commit(s) were added to refs/heads/master by this push:
     new 6c6d046  GEOMETRY-142: adding PointMap and PointSet implementations
6c6d046 is described below

commit 6c6d046532ab6c0c5cd670aaa4dc9ad384190d97
Author: Matt <ma...@apache.org>
AuthorDate: Thu Mar 10 22:33:07 2022 -0500

    GEOMETRY-142: adding PointMap and PointSet implementations
---
 .../commons/geometry/core/collection/PointMap.java |   41 +
 .../PointSet.java}                                 |   33 +-
 .../package-info.java}                             |   23 +-
 .../core/internal/AbstractBucketPointMap.java      | 1034 +++++++++++
 .../geometry/core/internal/AbstractPointMap1D.java |  171 ++
 .../core/internal/GeometryInternalUtils.java       |   18 +
 .../core/internal/PointMapAsSetAdapter.java        |   96 ++
 .../core/collection/PointCollectionTestBase.java   |  112 ++
 .../geometry/core/collection/PointMapTestBase.java | 1809 ++++++++++++++++++++
 .../geometry/core/collection/PointSetTestBase.java |  801 +++++++++
 .../core/internal/AbstractBucketPointMapTest.java  |  121 ++
 .../core/internal/AbstractPointMap1DTest.java      |  152 ++
 .../core/internal/PointMapAsSetAdapterTest.java    |   90 +
 .../core/internal/TestBucketPointMap1D.java        |   98 ++
 .../geometry/euclidean/EuclideanCollections.java   |  102 ++
 .../commons/geometry/euclidean/PointMap1DImpl.java |   95 +
 .../commons/geometry/euclidean/PointMap2DImpl.java |  151 ++
 .../commons/geometry/euclidean/PointMap3DImpl.java |  175 ++
 .../euclidean/threed/mesh/SimpleTriangleMesh.java  |   44 +-
 .../euclidean/DocumentationExamplesTest.java       |   20 +
 .../geometry/euclidean/oned/PointMap1DTest.java    |  120 ++
 .../geometry/euclidean/oned/PointSet1DTest.java    |   87 +
 .../geometry/euclidean/threed/PointMap3DTest.java  |  175 ++
 .../geometry/euclidean/threed/PointSet3DTest.java  |  118 ++
 .../geometry/euclidean/twod/PointMap2DTest.java    |  166 ++
 .../geometry/euclidean/twod/PointSet2DTest.java    |  109 ++
 commons-geometry-examples/examples-jmh/pom.xml     |    9 +
 .../jmh/euclidean/PointMap3DPerformance.java       |  339 ++++
 .../jmh/euclidean/pointmap/BucketKDTree.java       |  984 +++++++++++
 .../examples/jmh/euclidean/pointmap/KDTree.java    |  576 +++++++
 .../pointmap/PointMapDataStructurePerformance.java |  326 ++++
 .../jmh/euclidean/pointmap/RebuildingKDTree.java   |  248 +++
 .../euclidean/pointmap/VariableSplitOctree.java    |  449 +++++
 .../jmh/euclidean/pointmap/Vector3DEntry.java      |   29 +-
 .../jmh/euclidean/pointmap/package-info.java       |   25 +-
 .../pointmap/PointMapDataStructureTest.java        |  235 +++
 .../commons/geometry/spherical/PointMap1SImpl.java |  299 ++++
 .../commons/geometry/spherical/PointMap2SImpl.java |  181 ++
 .../geometry/spherical/SphericalCollections.java   |   78 +
 .../commons/geometry/spherical/package-info.java   |   26 +-
 .../spherical/DocumentationExamplesTest.java       |   20 +
 .../geometry/spherical/oned/PointMap1STest.java    |  132 ++
 .../geometry/spherical/oned/PointSet1STest.java    |   84 +
 .../geometry/spherical/twod/PointMap2STest.java    |  189 ++
 .../geometry/spherical/twod/PointSet2STest.java    |  117 ++
 src/site/xdoc/userguide/index.xml                  |   65 +-
 46 files changed, 10242 insertions(+), 130 deletions(-)

diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/collection/PointMap.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/collection/PointMap.java
new file mode 100644
index 0000000..d8b16ab
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/collection/PointMap.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.core.collection;
+
+import java.util.Map;
+
+import org.apache.commons.geometry.core.Point;
+
+/** {@link Map} type that uses points as keys. This interface is intended for
+ * use in cases where effectively equivalent (but not necessarily equal) points must
+ * map to the same entry. As such, this interface breaks the strict contract for
+ * {@link Map} where key equality is consistent with {@link Object#equals(Object)}.
+ * @param <P> Point type
+ * @param <V> Value type
+ */
+public interface PointMap<P extends Point<P>, V> extends Map<P, V> {
+
+    /** Get the map entry with a key equivalent to {@code pt} or {@code null}
+     * if no such entry exists. The returned instance supports use of
+     * the {@link Map.Entry#setValue(Object)} method to modify the
+     * mapping.
+     * @param pt point to fetch the map entry for
+     * @return map entry for the given point or null if no such entry
+     *      exists
+     */
+    Map.Entry<P, V> getEntry(P pt);
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/collection/PointSet.java
similarity index 50%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java
copy to commons-geometry-core/src/main/java/org/apache/commons/geometry/core/collection/PointSet.java
index 8ba46f3..539f635 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/collection/PointSet.java
@@ -14,24 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.internal;
+package org.apache.commons.geometry.core.collection;
 
-/** Internal utility methods for <em>commons-geometry</em>.
- */
-public final class GeometryInternalUtils {
+import java.util.Set;
+
+import org.apache.commons.geometry.core.Point;
 
-    /** Utility class; no instantiation. */
-    private GeometryInternalUtils() {}
+/** {@link Set} containing {@link Point} values. This interface is intended for
+ * use in cases where effectively equivalent (but not necessarily equal) points must
+ * be considered as equal by the set. As such, this interface breaks the strict contract
+ * for {@link Set} where membership is consistent with {@link Object#equals(Object)}.
+ * @param <P> Point type
+ */
+public interface PointSet<P extends Point<P>> extends Set<P> {
 
-    /** Return {@code true} if {@code a} is the same instance as {@code b}, as
-     * determined by the {@code ==} operator. This method exists primarily to
-     * document the fact that reference equality was intended and is not a
-     * programming error.
-     * @param a first instance
-     * @param b second instance
-     * @return {@code true} if the arguments are the exact same instance
+    /** Get the set entry equivalent to {@code pt} or null if no
+     * such entry exists.
+     * @param pt point to find an equivalent for
+     * @return set entry equivalent to {@code pt} or null if
+     *      no such entry exists
      */
-    public static boolean sameInstance(final Object a, final Object b) {
-        return a == b;
-    }
+    P get(P pt);
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/collection/package-info.java
similarity index 51%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java
copy to commons-geometry-core/src/main/java/org/apache/commons/geometry/core/collection/package-info.java
index 8ba46f3..a789a42 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/collection/package-info.java
@@ -14,24 +14,5 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.internal;
-
-/** Internal utility methods for <em>commons-geometry</em>.
- */
-public final class GeometryInternalUtils {
-
-    /** Utility class; no instantiation. */
-    private GeometryInternalUtils() {}
-
-    /** Return {@code true} if {@code a} is the same instance as {@code b}, as
-     * determined by the {@code ==} operator. This method exists primarily to
-     * document the fact that reference equality was intended and is not a
-     * programming error.
-     * @param a first instance
-     * @param b second instance
-     * @return {@code true} if the arguments are the exact same instance
-     */
-    public static boolean sameInstance(final Object a, final Object b) {
-        return a == b;
-    }
-}
+/** This package contains core geometric collection types. */
+package org.apache.commons.geometry.core.collection;
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/AbstractBucketPointMap.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/AbstractBucketPointMap.java
new file mode 100644
index 0000000..cbb19b4
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/AbstractBucketPointMap.java
@@ -0,0 +1,1034 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.core.internal;
+
+import java.util.AbstractMap;
+import java.util.AbstractSet;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.ConcurrentModificationException;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.BiFunction;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.collection.PointMap;
+import org.apache.commons.numbers.core.Precision;
+
+/** Abstract tree-based {@link PointMap} implementation that stores entries in bucket nodes
+ * that are split once a certain entry count threshold is reached. The main goal of this class
+ * is to provide a generic, multidimensional implementation that maintains reasonable performance
+ * regardless of point count and insertion order. Like other tree data structures, performance
+ * is tied closely to tree depth, which can vary depending on insertion order for a given set of
+ * points. In order to help maintain performance in cases of non-optimal point insertion order,
+ * this class uses a strategy of "point folding", implemented as follows:
+ * <ul>
+ *  <li>Two separate tree roots are maintained by the map: a primary root and a secondary root.</li>
+ *  <li>Entries are added to the primary root until the it reaches its capacity and is split using
+ *      an algorithm specific to the space and dimension. At this point, the populated primary root
+ *      becomes the secondary root and a new, empty primary root is created.</li>
+ *  <li>Points are inserted into the new primary root as before. However, for each new point inserted,
+ *      an existing point is removed from the secondary root and inserted into the primary root.</li>
+ *  <li>Points are moved from the secondary root and inserted into the primary root in this way until the
+ *      secondary root is empty. At this point, the primary root becomes the secondary root and another
+ *      primary root is created.</li>
+ * </ul>
+ * In this way, previously inserted points can apply a balancing influence on the low levels of the tree
+ * as new points are inserted.
+ *
+ * <p>This class is <em>not</em> thread-safe.</p>
+ * @param <P> Point type
+ * @param <V> Map value type
+ */
+public abstract class AbstractBucketPointMap<P extends Point<P>, V>
+    extends AbstractMap<P, V>
+    implements PointMap<P, V> {
+
+    /** Function used to construct new node instances. */
+    private final BiFunction<AbstractBucketPointMap<P, V>, BucketNode<P, V>, BucketNode<P, V>> nodeFactory;
+
+    /** Maximum number of entries stored per node before the node is split. */
+    private final int maxNodeEntryCount;
+
+    /** Number of child nodes for each non-leaf node. */
+    private final int nodeChildCount;
+
+    /** Precision context. */
+    private final Precision.DoubleEquivalence precision;
+
+    /** Primary tree root. */
+    private BucketNode<P, V> root;
+
+    /** Secondary tree root. */
+    private BucketNode<P, V> secondaryRoot;
+
+    /** Version counter, used to track tree modifications. */
+    private int version;
+
+    /** Construct a new instance.
+     * @param nodeFactory object used to construct new node instances
+     * @param maxNodeEntryCount maximum number of map entries per node before
+     *      the node is split
+     * @param nodeChildCount number of child nodes per internal node
+     * @param precision precision object used for floating point comparisons
+     */
+    protected AbstractBucketPointMap(
+            final BiFunction<AbstractBucketPointMap<P, V>, BucketNode<P, V>, BucketNode<P, V>> nodeFactory,
+            final int maxNodeEntryCount,
+            final int nodeChildCount,
+            final Precision.DoubleEquivalence precision) {
+        this.nodeFactory = nodeFactory;
+        this.maxNodeEntryCount = maxNodeEntryCount;
+        this.nodeChildCount = nodeChildCount;
+        this.precision = precision;
+        this.root = nodeFactory.apply(this, null);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Map.Entry<P, V> getEntry(final P pt) {
+        return findEntryByPoint(pt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int size() {
+        return root.getEntryCount() +
+                (secondaryRoot != null ? secondaryRoot.getEntryCount() : 0);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V put(final P key, final V value) {
+        GeometryInternalUtils.validatePointMapKey(key);
+
+        Entry<P, V> entry = findEntryByPoint(key);
+        if (entry != null) {
+            return entry.setValue(value);
+        }
+
+        root.insertEntry(new SimpleEntry<>(key, value));
+        entryAdded();
+
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V get(final Object key) {
+        return getValue(findEntry(key));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V remove(final Object key) {
+        @SuppressWarnings("unchecked")
+        final Entry<P, V> entry = removeEntryByPoint((P) key);
+        if (entry != null) {
+            entryRemoved();
+            return entry.getValue();
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean containsKey(final Object key) {
+        return findEntry(key) != null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean containsValue(final Object value) {
+        return root.findEntryByValue(value) != null ||
+                (secondaryRoot != null && secondaryRoot.findEntryByValue(value) != null);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<Entry<P, V>> entrySet() {
+        return new EntrySet<>(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void clear() {
+        root = createNode(null);
+        secondaryRoot = null;
+    }
+
+    /** Return true if the given points are equivalent using the precision
+     * configured for the map.
+     * @param a first point
+     * @param b second point
+     * @return true if the given points are equivalent
+     */
+    protected abstract boolean pointsEq(P a, P b);
+
+    /** Get the configured precision for the instance.
+     * @return precision object
+     */
+    protected Precision.DoubleEquivalence getPrecision() {
+        return precision;
+    }
+
+    /** Construct a new node instance.
+     * @param parent parent node; will be null or the tree root
+     * @return the new node instance
+     */
+    private BucketNode<P, V> createNode(final BucketNode<P, V> parent) {
+        return nodeFactory.apply(this, parent);
+    }
+
+    /** Method called when a new entry is added to the tree.
+     */
+    private void entryAdded() {
+        ++version;
+
+        if (!root.isLeaf() && secondaryRoot == null) {
+            secondaryRoot = root;
+            root = createNode(null);
+        }
+
+        migrateSecondaryEntry();
+        checkSecondaryRoot();
+    }
+
+    /** Method called when an entry is removed from the tree.
+     */
+    private void entryRemoved() {
+        ++version;
+
+        checkSecondaryRoot();
+    }
+
+    /** Create a list for storing map entries.
+     * @return list for storing map entries
+     */
+    private List<Entry<P, V>> createEntryList() {
+        return new ArrayList<>(maxNodeEntryCount);
+    }
+
+    /** Create a list for storing node children. The returned list contains
+     * {@code nodeChildCount} number of {@code null} entries.
+     * @return list for storing node children
+     */
+    @SuppressWarnings("unchecked")
+    private List<BucketNode<P, V>> createNodeChildList() {
+        return Arrays.asList(new BucketNode[nodeChildCount]);
+    }
+
+    /** Get the entry for the given key or null if not found.
+     * @param key key to search for
+     * @return entry for the given key or null if not found
+     */
+    @SuppressWarnings("unchecked")
+    private Entry<P, V> findEntry(final Object key) {
+        return findEntryByPoint((P) key);
+    }
+
+    /** Find the entry for the given point or null if one does not
+     * exist.
+     * @param pt point to find the entry for
+     * @return entry for the given point or null if one does not exist
+     */
+    private Entry<P, V> findEntryByPoint(final P pt) {
+        Entry<P, V> entry = null;
+        if (pt.isFinite()) {
+            entry = root.findEntry(pt);
+            if (entry == null && secondaryRoot != null) {
+                entry = secondaryRoot.findEntry(pt);
+            }
+        }
+        return entry;
+    }
+
+    /** Remove and return the entry for the given point or null
+     * if no such entry exists.
+     * @param pt point to remove the entry for
+     * @return the removed entry or null if not found
+     */
+    private Entry<P, V> removeEntryByPoint(final P pt) {
+        Entry<P, V> entry = null;
+        if (pt.isFinite()) {
+            entry = root.removeEntry(pt);
+            if (entry == null && secondaryRoot != null) {
+                entry = secondaryRoot.removeEntry(pt);
+            }
+        }
+        return entry;
+    }
+
+    /** Move an entry from the secondary root (if present) to the primary root. This process
+     * reintroduces points from a previous insertion back into higher levels of the root tree,
+     * thereby giving the root tree more balanced split points.
+     */
+    private void migrateSecondaryEntry() {
+        if (secondaryRoot != null) {
+            final int offset = version % nodeChildCount;
+            final boolean even = (offset & 1) > 0;
+            final int idx = even ?
+                    offset / 2 :
+                    nodeChildCount - 1 - (offset / 2);
+
+            final Entry<P, V> entry = secondaryRoot.removeEntryAlongChildIndexPath(idx);
+            if (entry != null) {
+                root.insertEntry(entry);
+            }
+        }
+    }
+
+    /** Remove the secondary root if empty.
+     */
+    private void checkSecondaryRoot() {
+        if (secondaryRoot != null && secondaryRoot.isEmpty()) {
+            secondaryRoot.destroy();
+            secondaryRoot = null;
+        }
+    }
+
+    /** Return the value for the argument or {@code null} if {@code entry}
+     * is {@code null}.
+     * @param <V> Value type
+     * @param entry entry to return the value for; may be null
+     * @return value for the argument or null if the argument is null
+     */
+    private static <V> V getValue(final Entry<?, V> entry) {
+        return entry != null ?
+                entry.getValue() :
+                null;
+    }
+
+    /** Spatial partitioning node type that stores map entries in a list until
+     * a threshold is reached, at which point the node is split.
+     * @param <P> Point type
+     * @param <V> Value type
+     */
+    protected abstract static class BucketNode<P extends Point<P>, V>
+            implements Iterable<Entry<P, V>> {
+
+        /** Owning map. */
+        private AbstractBucketPointMap<P, V> map;
+
+        /** Parent node. */
+        private BucketNode<P, V> parent;
+
+        /** Child nodes. */
+        private List<BucketNode<P, V>> children;
+
+        /** Entries stored in the node; will be null for non-leaf nodes. */
+        private List<Entry<P, V>> entries;
+
+        /** Number of entries in this subtree. */
+        private int entryCount;
+
+        /** Construct a new instance.
+         * @param map owning map
+         * @param parent parent node or null if the tree root
+         */
+        protected BucketNode(
+                final AbstractBucketPointMap<P, V> map,
+                final BucketNode<P, V> parent) {
+            this.map = map;
+            this.parent = parent;
+
+            // pull an entry list from the parent map; this will make
+            // this node a leaf initially
+            this.entries = map.createEntryList();
+        }
+
+        /**
+         * Return true if this node is a leaf node.
+         * @return true if this node is a leaf node
+         */
+        public boolean isLeaf() {
+            return entries != null;
+        }
+
+        /**
+         * Return true if the subtree rooted at this node does not
+         * contain any map entries.
+         * @return true if the subtree root at this node is empty
+         */
+        public boolean isEmpty() {
+            return entryCount < 1;
+        }
+
+        /** Get the number of map entries in the subtree rooted at this node.
+         * @return number of map entries in the subtree rooted at this node
+         */
+        public int getEntryCount() {
+            return entryCount;
+        }
+
+        /** Find and return the map entry matching the given key.
+         * @param key point key
+         * @return entry matching the given key or null if not found
+         */
+        public Entry<P, V> findEntry(final P key) {
+            if (isLeaf()) {
+                // leaf node; check the list of entries for a match
+                for (final Entry<P, V> entry : entries) {
+                    if (map.pointsEq(key, entry.getKey())) {
+                        return entry;
+                    }
+                }
+            } else {
+                // internal node; delegate to each child that could possibly contain
+                // the point or an equivalent point
+                final int loc = getSearchLocation(key);
+                for (int i = 0; i < children.size(); ++i) {
+                    if (testChildLocation(i, loc)) {
+                        final Entry<P, V> entry = getEntryInChild(i, key);
+                        if (entry != null) {
+                            return entry;
+                        }
+                    }
+                }
+            }
+
+            // not found
+            return null;
+        }
+
+        /** Find the first entry in the tree with the given value or null if not found.
+         * @param value value to search for
+         * @return the first entry in the tree with the given value or null if not found
+         */
+        public Entry<P, V> findEntryByValue(final Object value) {
+            if (isLeaf()) {
+                // leaf node; check the list of entries for a match
+                for (final Entry<P, V> entry : entries) {
+                    if (Objects.equals(entry.getValue(), value)) {
+                        return entry;
+                    }
+                }
+            } else {
+                // internal node; delegate to each child
+                for (final BucketNode<P, V> child : children) {
+                    if (child != null) {
+                        final Entry<P, V> childResult = child.findEntryByValue(value);
+                        if (childResult != null) {
+                            return childResult;
+                        }
+                    }
+                }
+            }
+
+            // not found
+            return null;
+        }
+
+        /** Insert a new entry into the subtree, returning the new size of the
+         * subtree. No check is made as to whether or not the entry already exists.
+         * @param entry entry to insert
+         */
+        public void insertEntry(final Map.Entry<P, V> entry) {
+            if (isLeaf()) {
+                if (entries.size() < map.maxNodeEntryCount) {
+                    // we have an open spot here so just add the entry
+                    append(entry);
+                    return;
+                }
+
+                // no available entries; split the node and add the new
+                // entry to a child
+                splitNode();
+            }
+
+            // insert into the first matching child
+            final int loc = getInsertLocation(entry.getKey());
+
+            for (int i = 0; i < children.size(); ++i) {
+                if (testChildLocation(i, loc)) {
+                    getOrCreateChild(i).insertEntry(entry);
+
+                    // update the subtree state
+                    subtreeEntryAdded();
+                    break;
+                }
+            }
+        }
+
+        /** Remove the given key, returning the previously mapped entry.
+         * @param key key to remove
+         * @return the value previously mapped to the key or null if no
+         *       value was mapped
+         */
+        public Entry<P, V> removeEntry(final P key) {
+            if (isLeaf()) {
+                // leaf node; check the existing entries for a match
+                final Iterator<Entry<P, V>> it = entries.iterator();
+                while (it.hasNext()) {
+                    final Entry<P, V> entry = it.next();
+                    if (map.pointsEq(key, entry.getKey())) {
+                        it.remove();
+
+                        // update the subtree state
+                        subtreeEntryRemoved();
+
+                        return entry;
+                    }
+                }
+            } else {
+                // internal node; look through children
+                final int loc = getSearchLocation(key);
+                for (int i = 0; i < children.size(); ++i) {
+                    if (testChildLocation(i, loc)) {
+                        final Entry<P, V> entry = removeFromChild(i, key);
+                        if (entry != null) {
+                            // update the subtree state
+                            subtreeEntryRemoved();
+
+                            return entry;
+                        }
+                    }
+                }
+            }
+
+            // not found
+            return null;
+        }
+
+        /** Append an entry to the entry list for this node. This method must
+         * only be called on leaf nodes.
+         * @param entry entry to append
+         */
+        public void append(final Entry<P, V> entry) {
+            entries.add(entry);
+            subtreeEntryAdded();
+        }
+
+        /** Remove an entry in a leaf node lying on the given child index path.
+         * @param childIdx target child index
+         * @return removed entry
+         */
+        public Entry<P, V> removeEntryAlongChildIndexPath(final int childIdx) {
+            if (isLeaf()) {
+                if (!entries.isEmpty()) {
+                    // remove the last entry in the list
+                    final Entry<P, V> entry = entries.remove(entries.size() - 1);
+                    subtreeEntryRemoved();
+
+                    return entry;
+                }
+            } else {
+                final int childCount = children.size();
+                final int delta = childIdx < (map.nodeChildCount / 2) ?
+                        +1 :
+                        -1;
+
+                for (int n = 0, i = childIdx;
+                        n < childCount;
+                        ++n, i += delta) {
+                    final int adjustedIndex = (i + childCount) % childCount;
+
+                    final BucketNode<P, V> child = children.get(adjustedIndex);
+                    if (child != null) {
+                        final Entry<P, V> entry = child.removeEntryAlongChildIndexPath(childIdx);
+                        if (entry != null) {
+                            // destroy and remove the child if empty
+                            if (child.isEmpty()) {
+                                child.destroy();
+                                children.set(adjustedIndex, null);
+                            }
+
+                            subtreeEntryRemoved();
+
+                            return entry;
+                        }
+                    }
+                }
+            }
+
+            return null;
+        }
+
+        /** Destroy this node. The node must not be used after this method is called.
+         */
+        public void destroy() {
+            this.map = null;
+            this.parent = null;
+            this.children = null;
+            this.entries = null;
+            this.entryCount = 0;
+        }
+
+        /** Return true if this node has been destroyed.
+         * @return true if this node has been destroyed
+         */
+        public boolean isDestroyed() {
+            return map == null;
+        }
+
+        /** Return an iterator for accessing the entries stored in this node. The {@code remove()}
+         * method of the returned iterator correctly updates the tree state. This method must only
+         * be called on leaf nodes.
+         * @return iterator for accessing the entries stored in this node
+         */
+        @Override
+        public Iterator<Map.Entry<P, V>> iterator() {
+            return iterator(0);
+        }
+
+        /** Return an iterator for accessing the entries stored in this node, starting at the given
+         * index. The {@code remove()} method of the returned iterator correctly updates the tree state.
+         * This method must only be called on leaf nodes.
+         * @param idx starting index for the iterator
+         * @return iterator for accessing the entries stored in this node, starting with the entry at
+         *      the given index
+         */
+        private Iterator<Map.Entry<P, V>> iterator(final int idx) {
+            final List<Map.Entry<P, V>> iteratedList = idx == 0 ?
+                    entries :
+                    entries.subList(idx, entries.size());
+
+            final Iterator<Map.Entry<P, V>> it = iteratedList.iterator();
+
+            return new Iterator<Map.Entry<P, V>>() {
+
+                @Override
+                public boolean hasNext() {
+                    return !isDestroyed() && it.hasNext();
+                }
+
+                @Override
+                public Entry<P, V> next() {
+                    return it.next();
+                }
+
+                @Override
+                public void remove() {
+                    it.remove();
+
+                    // store the owning map since we may be destroyed as part of
+                    // entry removal
+                    final AbstractBucketPointMap<P, V> owningMap = map;
+
+                    // navigate up the tree and perform updates
+                    BucketNode<P, V> current = BucketNode.this;
+                    while (current != null) {
+                        current.subtreeEntryRemoved();
+
+                        current = current.parent;
+                    }
+
+                    // notify the owning map
+                    owningMap.entryRemoved();
+                }
+            };
+        }
+
+        /** Compute the split for this node from the current set
+         * of map entries. Subclasses are responsible for managing
+         * the storage of the split.
+         */
+        protected abstract void computeSplit();
+
+        /** Get an int encoding the search locations of {@code pt} relative to the
+         * node split. The return value must include all possible locations of
+         * {@code pt} and equivalent points.
+         * @param pt point to determine the relative location of
+         * @return encoded search location
+         */
+        protected abstract int getSearchLocation(P pt);
+
+        /** Get an int encoding the insert location of {@code pt} relative to the
+         * node split. The return value must be strict and only include the single
+         * location where {@code pt} should be inserted.
+         * @param pt point to determine the insert location of
+         * @return encoded insert location
+         */
+        protected abstract int getInsertLocation(P pt);
+
+        /** Return true if the child node at {@code childIdx} matches the given
+         * encoded point location.
+         * @param childIdx child index to test
+         * @param loc encoded relative point location
+         * @return true if the child node a {@code childIdx} matches the location
+         */
+        protected abstract boolean testChildLocation(int childIdx, int loc);
+
+        /** Make this node a leaf node, using the given list of entries.
+         * @param leafEntries list of map entries to use for the node
+         */
+        protected void makeLeaf(final List<Entry<P, V>> leafEntries) {
+            children = null;
+            entries = leafEntries;
+        }
+
+        /** Split the node and place all entries into the new child nodes.
+         * This node becomes an internal node.
+         */
+        protected void splitNode() {
+            computeSplit();
+
+            children = map.createNodeChildList();
+
+            for (final Entry<P, V> entry : entries) {
+                moveToChild(entry);
+            }
+
+            entries = null;
+        }
+
+        /** Get the precision context for the instance.
+         * @return precision context for the instance
+         */
+        protected Precision.DoubleEquivalence getPrecision() {
+            return map.precision;
+        }
+
+        /** Get the given entry in the child at {@code idx} or null if not found.
+         * @param idx child index
+         * @param key key to search for
+         * @return entry matching {@code key} in child or null if not found
+         */
+        private Entry<P, V> getEntryInChild(final int idx, final P key) {
+            final BucketNode<P, V> child = children.get(idx);
+            if (child != null) {
+                return child.findEntry(key);
+            }
+            return null;
+        }
+
+        /** Remove the given key from the child at {@code idx}.
+         * @param idx index of the child
+         * @param key key to remove
+         * @return entry removed from the child or null if not found
+         */
+        private Entry<P, V> removeFromChild(final int idx, final P key) {
+            final BucketNode<P, V> child = children.get(idx);
+            if (child != null) {
+                return child.removeEntry(key);
+            }
+            return null;
+        }
+
+        /** Move the previously created entry to a child node.
+         * @param entry entry to mode
+         */
+        private void moveToChild(final Entry<P, V> entry) {
+            final int loc = getInsertLocation(entry.getKey());
+
+            final int numChildren = children.size();
+            for (int i = 0; i < numChildren; ++i) {
+                // place the entry in the first child that contains it
+                if (testChildLocation(i, loc)) {
+                    getOrCreateChild(i).append(entry);
+                    break;
+                }
+            }
+        }
+
+        /** Get the child node at the given index, creating it if needed.
+         * @param idx index of the child node
+         * @return child node at the given index
+         */
+        private BucketNode<P, V> getOrCreateChild(final int idx) {
+            BucketNode<P, V> child = children.get(idx);
+            if (child == null) {
+                child = map.createNode(this);
+                children.set(idx, child);
+            }
+            return child;
+        }
+
+        /** Method called when an entry is added to the subtree represented
+         * by this node.
+         */
+        private void subtreeEntryAdded() {
+            ++entryCount;
+        }
+
+        /** Method called when an entry is removed from the subtree represented
+         * by this node. If the subtree is an empty internal node, it is converted
+         * to a leaf node.
+         */
+        private void subtreeEntryRemoved() {
+            --entryCount;
+
+            final int condenseThreshold = map.maxNodeEntryCount / 2;
+
+            if (!isLeaf() && entryCount <= condenseThreshold) {
+                final List<Entry<P, V>> subtreeEntries = map.createEntryList();
+
+                if (!isEmpty() &&
+                        (parent == null || parent.getEntryCount() > condenseThreshold)) {
+                    collectSubtreeEntriesRecursive(subtreeEntries, false);
+                }
+
+                makeLeaf(subtreeEntries);
+            }
+        }
+
+        /** Add all map entries in the subtree rooted at this node to {@code entryList}. If {@code destroyNode}
+         * is true, the node is destroyed after its entries are added to the list.
+         * @param entryList list to add entries to
+         * @param destroyNode if true, the node will be destroyed after its entries are added to the list
+         */
+        private void collectSubtreeEntriesRecursive(final List<Entry<P, V>> entryList, final boolean destroyNode) {
+            if (isLeaf()) {
+                entryList.addAll(entries);
+            } else {
+                for (final BucketNode<P, V> child : children) {
+                    if (child != null) {
+                        child.collectSubtreeEntriesRecursive(entryList, true);
+                    }
+                }
+            }
+
+            if (destroyNode) {
+                destroy();
+            }
+        }
+
+        /** Get an encoded search location value for the given comparison result. If
+         * {@code cmp} is {@code 0}, then the bitwise OR of {@code neg} and {@code pos}
+         * is returned, indicating that both spaces are valid search locations. Otherwise,
+         * {@code neg} is returned for negative {@code cmp} values and {@code pos} for
+         * positive ones. This location value is to be used during entry searches,
+         * when comparisons must be loose and all possible locations included.
+         * @param cmp comparison result
+         * @param neg negative flag
+         * @param pos positive flag
+         * @return encoded search location value
+         */
+        public static int getSearchLocationValue(final int cmp, final int neg, final int pos) {
+            if (cmp < 0) {
+                return neg;
+            } else if (cmp > 0) {
+                return pos;
+            }
+            return neg | pos;
+        }
+
+        /** Get an insert location value for the given comparison result. If {@code cmp}
+         * is less than or equal to {@code 0}, then {@code neg} is returned. Otherwise,
+         * {@code pos} is returned. This location value is to be used during entry inserts,
+         * where comparisons must be strict.
+         * @param cmp comparison result
+         * @param neg negative flag
+         * @param pos positive flag
+         * @return encoded insert location value
+         */
+        public static int getInsertLocationValue(final int cmp, final int neg, final int pos) {
+            return cmp <= 0 ?
+                    neg :
+                    pos;
+        }
+    }
+
+    /** Set view of the map entries.
+     * @param <P> Point type
+     * @param <V> Value type
+     */
+    private static final class EntrySet<P extends Point<P>, V>
+        extends AbstractSet<Map.Entry<P, V>> {
+
+        /** Owning map. */
+        private final AbstractBucketPointMap<P, V> map;
+
+        /** Construct a new instance for the given map.
+         * @param map map instance
+         */
+        EntrySet(final AbstractBucketPointMap<P, V> map) {
+            this.map = map;
+        }
+
+        /** {@inheritDoc} */
+        @SuppressWarnings("unchecked")
+        @Override
+        public boolean contains(final Object obj) {
+            if (obj instanceof Map.Entry) {
+                final Map.Entry<?, ?> search = (Map.Entry<?, ?>) obj;
+                final Object key = search.getKey();
+
+                final Map.Entry<P, V> actual = map.findEntry(key);
+                if (actual != null) {
+                    return map.pointsEq(actual.getKey(), (P) search.getKey()) &&
+                            Objects.equals(actual.getValue(), search.getValue());
+                }
+            }
+            return false;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Iterator<Entry<P, V>> iterator() {
+            return new EntryIterator<>(map);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int size() {
+            return map.size();
+        }
+    }
+
+    /** Iterator for iterating through each entry in the map.
+     * @param <P> Point type
+     * @param <V> Value type
+     */
+    private static final class EntryIterator<P extends Point<P>, V>
+        implements Iterator<Map.Entry<P, V>> {
+
+        /** Owning map. */
+        private final AbstractBucketPointMap<P, V> map;
+
+        /** Size of the owning map. */
+        private int size;
+
+        /** Iterator that produces the next entry to be returned. */
+        private Iterator<Map.Entry<P, V>> nextEntryIterator;
+
+        /** Index of the entry that will be returned next from the iterator. */
+        private int nextIdx;
+
+        /** Expected map modification version. */
+        private int expectedVersion;
+
+        /** Construct a new instance for the given map.
+         * @param map map instance
+         */
+        EntryIterator(final AbstractBucketPointMap<P, V> map) {
+            this.map = map;
+            this.size = map.size();
+
+            updateExpectedVersion();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean hasNext() {
+            return nextIdx < size;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Entry<P, V> next() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+
+            checkVersion();
+
+            if (nextEntryIterator == null ||
+                    !nextEntryIterator.hasNext()) {
+                nextEntryIterator = findIterator();
+            }
+
+            final Map.Entry<P, V> result = nextEntryIterator.next();
+            ++nextIdx;
+
+            return result;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void remove() {
+            if (nextEntryIterator == null) {
+                throw new IllegalStateException("Cannot remove: no entry has yet been returned");
+            }
+
+            nextEntryIterator.remove();
+            --nextIdx;
+            --size;
+
+            updateExpectedVersion();
+        }
+
+        /** Find the next entry iterator in the map.
+         * @return next map entry iterator
+         */
+        private Iterator<Entry<P, V>> findIterator() {
+            int offset = 0;
+            if (map.secondaryRoot != null) {
+                final Iterator<Entry<P, V>> secondaryIt = findIteratorRecursive(map.secondaryRoot, offset);
+                if (secondaryIt != null) {
+                    return secondaryIt;
+                }
+
+                offset += map.secondaryRoot.getEntryCount();
+            }
+
+            return findIteratorRecursive(map.root, offset);
+        }
+
+        /** Find the next map entry iterator recursively in the subtree rooted at {@code node}.
+         * @param node root of the subtree to obtain an iterator in
+         * @param offset index offset of the first entry in the subtree
+         * @return the next map entry iterator or null if no leaf nodes in the subtree contain the
+         *      entry at {@code nextIdx}
+         */
+        private Iterator<Entry<P, V>> findIteratorRecursive(final BucketNode<P, V> node, final int offset) {
+            if (nextIdx >= offset && nextIdx < offset + node.getEntryCount()) {
+                if (node.isLeaf()) {
+                    return node.iterator(nextIdx - offset);
+                } else {
+                    return findIteratorInNodeChildren(node, offset);
+                }
+            }
+
+            return null;
+        }
+
+        /** Find the next map entry iterator in the children of {@code node}.
+         * @param node root of the subtree to obtain an iterator in
+         * @param offset index offset of the first entry in the subtree
+         * @return the next map entry iterator or null if no leaf nodes in the subtree contain the
+         *      entry at {@code nextIdx}
+         */
+        private Iterator<Entry<P, V>> findIteratorInNodeChildren(final BucketNode<P, V> node, final int offset) {
+            final int childCount = node.children.size();
+
+            int currentOffset = offset;
+            for (int i = 0; i < childCount; ++i) {
+                final BucketNode<P, V> child = node.children.get(i);
+                if (child != null) {
+                    Iterator<Entry<P, V>> childIt = findIteratorRecursive(child, currentOffset);
+                    if (childIt != null) {
+                        return childIt;
+                    }
+
+                    currentOffset += child.getEntryCount();
+                }
+            }
+            return null;
+        }
+
+        /** Throw a {@link ConcurrentModificationException} if the map version does
+         * not match the expected version.
+         */
+        private void checkVersion() {
+            if (map.version != expectedVersion) {
+                throw new ConcurrentModificationException();
+            }
+        }
+
+        /** Update the expected modification version of the map. This must be called
+         * whenever the map is changed through this instance.
+         */
+        private void updateExpectedVersion() {
+            expectedVersion = map.version;
+        }
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/AbstractPointMap1D.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/AbstractPointMap1D.java
new file mode 100644
index 0000000..8270c20
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/AbstractPointMap1D.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.core.internal;
+
+import java.util.AbstractMap.SimpleEntry;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.TreeMap;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.collection.PointMap;
+
+/** Abstract base class for 1D {@link PointMap} implementations. This class delegates
+ * entry storage to an internal {@link TreeMap} instance. Simple methods, such as
+ * {@link Map#size()} are directly implemented here but subclasses must provide their
+ * own logic for manipulating the map entries.
+ * @param <P> Point type
+ * @param <V> Value type
+ */
+public abstract class AbstractPointMap1D<P extends Point<P>, V>
+    implements PointMap<P, V> {
+
+    /** Underlying map. */
+    private final NavigableMap<P, V> map;
+
+    /** Construct a new instance that uses the given comparator in the
+     * underlying {@link TreeMap}.
+     * @param cmp tree map comparator
+     */
+    protected AbstractPointMap1D(final Comparator<P> cmp) {
+        this.map = new TreeMap<>(cmp);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int size() {
+        return map.size();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isEmpty() {
+        return map.isEmpty();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean containsValue(final Object value) {
+        return map.containsValue(value);
+    }
+
+
+    /** {@inheritDoc} */
+    @Override
+    public Map.Entry<P, V> getEntry(final P key) {
+        return exportEntry(getEntryInternal(key));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V put(final P key, final V value) {
+        GeometryInternalUtils.validatePointMapKey(key);
+        return putInternal(key, value);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void putAll(final Map<? extends P, ? extends V> m) {
+        for (final Map.Entry<? extends P, ? extends V> entry : m.entrySet()) {
+            put(entry.getKey(), entry.getValue());
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Collection<V> values() {
+        return map.values();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        return map.hashCode();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean equals(final Object obj) {
+        return map.equals(obj);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        return map.toString();
+    }
+
+    /** Get the raw {@link Map.Entry} for the given key from the underlying map.
+     * @param key map key
+     * @return entry for the given key or {@code null} if not found
+     */
+    protected abstract Map.Entry<P, V> getEntryInternal(P key);
+
+    /** Add or update the entry for the given key/value pair.
+     * @param key entry key
+     * @param value entry value
+     * @return the value of the previous entry for {@code key} or null
+     *      if no such entry exists
+     */
+    protected abstract V putInternal(P key, V value);
+
+    /** Get the underlying map instance.
+     * @return map instance
+     */
+    protected NavigableMap<P, V> getMap() {
+        return map;
+    }
+
+    /** Return a {@link Map.Entry} instance containing the values from the
+     * argument and suitable for direct use by external users. The returned
+     * entry supports use of the {@link Map.Entry#setValue(Object)} method.
+     * @param entry entry to export
+     * @return entry instance suitable for direct user by callers
+     */
+    protected Entry<P, V> exportEntry(final Entry<P, V> entry) {
+        return entry != null ?
+                new MutableEntryWrapper(entry) :
+                null;
+    }
+
+    /** {@link Map.Entry} subclass that adds support for the {@link Map.Entry#setValue(Object)}.
+     */
+    private final class MutableEntryWrapper extends SimpleEntry<P, V> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20220317L;
+
+        /** Construct a new instance representing the same mapping as the argument.
+         * @param entry target entry
+         */
+        MutableEntryWrapper(final Entry<? extends P, ? extends V> entry) {
+            super(entry);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public V setValue(final V value) {
+            // replace the value in the map
+            map.replace(getKey(), value);
+
+            // set the local value
+            return super.setValue(value);
+        }
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java
index 8ba46f3..2856fb9 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java
@@ -16,6 +16,10 @@
  */
 package org.apache.commons.geometry.core.internal;
 
+import java.util.Objects;
+
+import org.apache.commons.geometry.core.Point;
+
 /** Internal utility methods for <em>commons-geometry</em>.
  */
 public final class GeometryInternalUtils {
@@ -34,4 +38,18 @@ public final class GeometryInternalUtils {
     public static boolean sameInstance(final Object a, final Object b) {
         return a == b;
     }
+
+    /** Throw an exception if the given point cannot be used as a key in a
+     * {@link org.apache.commons.geometry.core.collection.PointMap PointMap}.
+     * @param <P> Point type
+     * @param pt point to check
+     * @throws NullPointerException if the point is null
+     * @throws IllegalArgumentException if the point is not finite
+     */
+    public static <P extends Point<P>> void validatePointMapKey(final P pt) {
+        Objects.requireNonNull(pt);
+        if (!pt.isFinite()) {
+            throw new IllegalArgumentException("Non-finite map key: " + pt);
+        }
+    }
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/PointMapAsSetAdapter.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/PointMapAsSetAdapter.java
new file mode 100644
index 0000000..a841645
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/PointMapAsSetAdapter.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.core.internal;
+
+import java.util.AbstractSet;
+import java.util.Iterator;
+import java.util.Map;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.collection.PointMap;
+import org.apache.commons.geometry.core.collection.PointSet;
+
+/** Internal utility class that exposes a {@link PointMap} as a {@link PointSet}.
+ * This class is not intended for direct use by users of this library. Users should
+ * instead create {@link PointSet} instances using the factory methods available in
+ * each space.
+ * @param <P> Point type
+ * @param <M> Map type
+ */
+public class PointMapAsSetAdapter<P extends Point<P>, M extends PointMap<P, Object>>
+    extends AbstractSet<P>
+    implements PointSet<P> {
+
+    /** Dummy map value used to indicate presence in the set. */
+    private static final Object PRESENT = new Object();
+
+    /** Backing map. */
+    private final M map;
+
+    /** Construct a new instance that use the argument as its backing map.
+     * @param backingMap backing map
+     */
+    public PointMapAsSetAdapter(final M backingMap) {
+        this.map = backingMap;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public P get(final P pt) {
+        final Map.Entry<P, Object> entry = map.getEntry(pt);
+        return entry != null ?
+                entry.getKey() :
+                null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Iterator<P> iterator() {
+        return map.keySet().iterator();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int size() {
+        return map.size();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean contains(final Object obj) {
+        return map.containsKey(obj);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean add(final P pt) {
+        return map.put(pt, PRESENT) == null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean remove(final Object obj) {
+        final Object prev = map.remove(obj);
+        return GeometryInternalUtils.sameInstance(prev, PRESENT);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void clear() {
+        map.clear();
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/collection/PointCollectionTestBase.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/collection/PointCollectionTestBase.java
new file mode 100644
index 0000000..c7071e4
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/collection/PointCollectionTestBase.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.core.collection;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.numbers.core.Precision;
+
+/** Base test class for point collection types.
+ * @param <P> Point type
+ */
+public abstract class PointCollectionTestBase<P extends Point<P>> {
+
+    public static final double EPS = 1e-10;
+
+    public static final Precision.DoubleEquivalence PRECISION =
+            Precision.doubleEquivalenceOfEpsilon(EPS);
+
+    /** Create an empty array of the target point type.
+     * @return empty array of the target point type
+     */
+    protected abstract P[] createPointArray();
+
+    /** Get a list of points with {@code NaN} coordinates.
+     * @return list of points with {@code NaN} coordinates
+     */
+    protected abstract List<P> getNaNPoints();
+
+    /** Get a list of points with infinite coordinates.
+     * @return list of points with infinite coordinates
+     */
+    protected abstract List<P> getInfPoints();
+
+    /** Get {@code cnt} number of unique test points that differ from each other in
+     * each dimension by <em>at least</em> {@code eps}.
+     * @param cnt number of points to return
+     * @param eps minimum value that each point must differ from other points along
+     *      each dimension
+     * @return list of test points
+     */
+    protected abstract List<P> getTestPoints(int cnt, double eps);
+
+    /** Get a list of points that lie {@code dist} distance from {@code pt}.
+     * @param pt input point
+     * @param dist distance from {@code pt}
+     * @return list of points that lie {@code dist} distance from {@code pt}
+     */
+    protected abstract List<P> getTestPointsAtDistance(P pt, double dist);
+
+    /** Get {@code cnt} number of unique test points that differ from each other in
+     * each dimension by <em>at least</em> {@code eps}. The returned list is shuffled
+     * using {@code rnd}.
+     * @param cnt number of points to return
+     * @param eps minimum value that each point must differ from other points along
+     *      each dimension
+     * @param rnd random instance used to shuffle the order of the points
+     * @return randomly ordered list of test points
+     */
+    protected List<P> getTestPoints(final int cnt, final double eps, final Random rnd) {
+        final List<P> pts = new ArrayList<>(getTestPoints(cnt, eps));
+        Collections.shuffle(pts, rnd);
+
+        return pts;
+    }
+
+    /** Return true if the given points are equivalent to each other using the given precision.
+     * @param a first point
+     * @param b second point
+     * @param precision precision context
+     * @return true if the two points are equivalent when compared using the given precision
+     */
+    protected abstract boolean eq(P a, P b, Precision.DoubleEquivalence precision);
+
+    /** Assert that {@code a} and {@code b} are equivalent using the given precision context.
+     * @param a first point
+     * @param b second point
+     * @param precision precision context
+     */
+    protected void assertEq(final P a, final P b, final Precision.DoubleEquivalence precision) {
+        assertTrue(eq(a, b, precision), () -> "Expected " + a + " and " + b + " to be equivalent");
+    }
+
+    /** Assert that {@code a} and {@code b} are not equivalent using the given precision context.
+     * @param a first point
+     * @param b second point
+     * @param precision precision context
+     */
+    protected void assertNotEq(final P a, final P b, final Precision.DoubleEquivalence precision) {
+        assertFalse(eq(a, b, precision), () -> "Expected " + a + " and " + b + " to not be equivalent");
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/collection/PointMapTestBase.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/collection/PointMapTestBase.java
new file mode 100644
index 0000000..4cb7477
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/collection/PointMapTestBase.java
@@ -0,0 +1,1809 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.core.collection;
+
+import java.util.AbstractMap.SimpleEntry;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.ConcurrentModificationException;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Random;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.numbers.core.Precision;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Base test class for {@link PointMap} implementations.
+ * @param <P> Point type
+ */
+public abstract class PointMapTestBase<P extends Point<P>>
+    extends PointCollectionTestBase<P> {
+
+    /** Get a new point map instance for testing.
+     * @param <V> Value type
+     * @param precision precision context to determine floating point equality
+     * @return a new map instance for testing.
+     */
+    protected abstract <V> PointMap<P, V> getMap(Precision.DoubleEquivalence precision);
+
+    @Test
+    void testEmpty() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final P pt = getTestPoints(1, EPS).get(0);
+
+        // act/assert
+        Assertions.assertEquals(0, map.size());
+        Assertions.assertTrue(map.isEmpty());
+
+        Assertions.assertNull(map.get(pt));
+        Assertions.assertFalse(map.containsKey(pt));
+    }
+
+    @Test
+    void testSingleEntry() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+        final P a = pts.get(0);
+
+        // act
+        map.put(a, 1);
+
+        // assert
+        checkerFor(map)
+            .expectEntry(a, 1)
+            .doesNotContainKeys(pts.subList(1, pts.size()))
+            .check();
+    }
+
+    @Test
+    void testMultipleEntries() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final PointMapChecker<P, Integer> checker = checkerFor(map);
+
+        final int putCnt = 1000;
+        final List<P> pts = getTestPoints(putCnt * 2, EPS, new Random(1L));
+
+        // act
+        for (int i = 0; i < putCnt; ++i) {
+            final P key = pts.get(i);
+
+            map.put(key, i);
+
+            checker.expectEntry(key, i);
+        }
+
+        // assert
+        checker
+            .doesNotContainKeys(pts.subList(putCnt, pts.size()))
+            .check();
+    }
+
+    @Test
+    void testGet() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+
+        insertPoints(pts.subList(1, 3), map);
+        // act/assert
+        Assertions.assertNull(map.get(pts.get(0)));
+
+        Assertions.assertEquals(Integer.valueOf(0), map.get(pts.get(1)));
+        Assertions.assertEquals(Integer.valueOf(1), map.get(pts.get(2)));
+    }
+
+    @Test
+    void testGet_equivalentPoints_singleEntry() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(1, EPS);
+        insertPoints(pts, map);
+
+        final P pt = pts.get(0);
+
+        // act/assert
+        Assertions.assertEquals(0, map.get(pt));
+        Assertions.assertTrue(map.containsKey(pt));
+        Assertions.assertEquals(new SimpleEntry<>(pt, 0), map.getEntry(pt));
+
+        for (final P closePt : getTestPointsAtDistance(pt, EPS * 0.75)) {
+            Assertions.assertEquals(0, map.get(closePt));
+            Assertions.assertTrue(map.containsKey(closePt));
+            Assertions.assertEquals(new SimpleEntry<>(pt, 0), map.getEntry(closePt));
+
+            Assertions.assertTrue(map.entrySet().contains(new SimpleEntry<>(closePt, 0)));
+            Assertions.assertTrue(map.keySet().contains(closePt));
+
+            assertEq(closePt, pt, PRECISION);
+        }
+
+        for (final P farPt : getTestPointsAtDistance(pt, EPS * 1.25)) {
+            Assertions.assertNull(map.get(farPt));
+            Assertions.assertFalse(map.containsKey(farPt));
+            Assertions.assertNull(map.getEntry(farPt));
+
+            Assertions.assertFalse(map.entrySet().contains(new SimpleEntry<>(farPt, 0)));
+            Assertions.assertFalse(map.keySet().contains(farPt));
+
+            assertNotEq(farPt, pt, PRECISION);
+        }
+    }
+
+    @Test
+    void testGet_equivalentPoints_multipleEntries() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(1_000, 3 * EPS);
+        insertPoints(pts, map);
+
+        // act/assert
+        int i = -1;
+        for (final P pt : pts) {
+            final int value = ++i;
+
+            Assertions.assertEquals(value, map.get(pt));
+            Assertions.assertTrue(map.containsKey(pt));
+            Assertions.assertEquals(new SimpleEntry<>(pt, value), map.getEntry(pt));
+
+            for (final P closePt : getTestPointsAtDistance(pt, EPS * 0.75)) {
+                Assertions.assertEquals(value, map.get(closePt));
+                Assertions.assertTrue(map.containsKey(closePt));
+                Assertions.assertEquals(new SimpleEntry<>(pt, value), map.getEntry(closePt));
+
+                Assertions.assertTrue(map.entrySet().contains(new SimpleEntry<>(closePt, value)));
+                Assertions.assertTrue(map.keySet().contains(closePt));
+
+                assertEq(closePt, pt, PRECISION);
+            }
+
+            for (final P farPt : getTestPointsAtDistance(pt, EPS * 1.25)) {
+                Assertions.assertNull(map.get(farPt));
+                Assertions.assertFalse(map.containsKey(farPt));
+                Assertions.assertNull(map.getEntry(farPt));
+
+                Assertions.assertFalse(map.entrySet().contains(new SimpleEntry<>(farPt, 0)));
+                Assertions.assertFalse(map.keySet().contains(farPt));
+
+                assertNotEq(farPt, pt, PRECISION);
+            }
+        }
+    }
+
+    @Test
+    void testGet_invalidArgs() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        insertPoints(getTestPoints(3, EPS), map);
+
+        // act/assert
+        Assertions.assertThrows(NullPointerException.class, () -> map.get(null));
+        Assertions.assertThrows(ClassCastException.class, () -> map.get(new Object()));
+    }
+
+    @Test
+    void testGet_nanAndInf() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        insertPoints(getTestPoints(100, EPS), map);
+
+        // act/assert
+        for (final P pt : getNaNPoints()) {
+            Assertions.assertNull(map.get(pt));
+        }
+
+        for (final P pt : getInfPoints()) {
+            Assertions.assertNull(map.get(pt));
+        }
+    }
+
+    @Test
+    void testGetEntry_canSetValue() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(100, EPS);
+        insertPoints(pts, map);
+
+        final Map.Entry<P, Integer> entry = map.getEntry(pts.get(1));
+
+        // act
+        Assertions.assertEquals(1, entry.setValue(Integer.valueOf(-1)));
+        Assertions.assertEquals(-1, entry.setValue(Integer.valueOf(-2)));
+
+        // assert
+        Assertions.assertEquals(-2, map.get(pts.get(1)));
+    }
+
+    @Test
+    void testGetEntry_setCalledAfterEntryRemoved_updatesLocalEntryOnly() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(100, EPS);
+        insertPoints(pts, map);
+
+        final Map.Entry<P, Integer> entry = map.getEntry(pts.get(49));
+        final Integer oldValue = entry.getValue();
+
+        // act/assert
+        map.remove(entry.getKey());
+
+        Assertions.assertEquals(oldValue, entry.setValue(-1));
+        Assertions.assertEquals(-1, entry.setValue(-2));
+
+        Assertions.assertNull(map.get(entry.getKey()));
+    }
+
+    @Test
+    void testPut_replaceValue() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+        final P a = pts.get(0);
+
+        // act
+        Assertions.assertNull(map.put(a, 1));
+        Assertions.assertEquals(1, map.put(a, 2));
+        Assertions.assertEquals(2, map.put(a, 3));
+
+        // assert
+        checkerFor(map)
+            .expectEntry(a, 3)
+            .doesNotContainKeys(pts.subList(1, pts.size()))
+            .check();
+    }
+
+    @Test
+    void testPut_equivalentValues_multipleEntries() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(1_000, EPS, new Random(10L));
+        insertPoints(pts, map);
+
+        final double delta = 0.75 * EPS;
+
+        // act/assert
+        int i = 0;
+        for (final P pt : pts) {
+            final List<P> closePts = getTestPointsAtDistance(pt, delta);
+            checkPut(map, closePts, i++);
+        }
+    }
+
+    private void checkPut(final PointMap<P, Integer> map, final List<? extends P> pts, final int startValue) {
+        int currentValue = startValue;
+        for (final P pt : pts) {
+            int nextValue = currentValue + 1;
+
+            Assertions.assertEquals(currentValue, map.put(pt, nextValue));
+            Assertions.assertEquals(nextValue, map.get(pt));
+
+            currentValue = nextValue;
+        }
+    }
+
+    @Test
+    void testPut_nullKey() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        // act/assert
+        Assertions.assertThrows(NullPointerException.class, () -> map.put(null, 0));
+    }
+
+    @Test
+    void testPut_nanAndInf() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        // act/assert
+        for (final P nanPt : getNaNPoints()) {
+            Assertions.assertThrows(IllegalArgumentException.class, () -> map.put(nanPt, 0));
+        }
+
+        for (final P infPt : getInfPoints()) {
+            Assertions.assertThrows(IllegalArgumentException.class, () -> map.put(infPt, 0));
+        }
+    }
+
+    @Test
+    void testPutAll_nonPointMap() {
+        // arrange
+        final Map<P, Integer> a = new HashMap<>();
+        final Map<P, Integer> b = new HashMap<>();
+
+        final PointMap<P, Integer> c = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(5, EPS);
+
+        a.put(pts.get(0), 0);
+        a.put(pts.get(1), 1);
+        a.put(pts.get(2), 2);
+
+        b.put(pts.get(2), 0);
+        b.put(pts.get(3), 1);
+        b.put(pts.get(4), 2);
+
+        // act
+        c.putAll(a);
+        c.putAll(b);
+
+        // assert
+        checkerFor(c)
+            .expectEntry(pts.get(0), 0)
+            .expectEntry(pts.get(1), 1)
+            .expectEntry(pts.get(2), 0)
+            .expectEntry(pts.get(3), 1)
+            .expectEntry(pts.get(4), 2)
+            .check();
+    }
+
+    @Test
+    void testPutAll_otherPointMap() {
+        // arrange
+        final PointMap<P, Integer> a = getMap(PRECISION);
+        final PointMap<P, Integer> b = getMap(PRECISION);
+        final PointMap<P, Integer> c = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(5, EPS);
+
+        insertPoints(pts.subList(0, 3), a);
+        insertPoints(pts.subList(2, 5), b);
+
+        // act
+        c.putAll(a);
+        c.putAll(b);
+
+        // assert
+        checkerFor(c)
+            .expectEntry(pts.get(0), 0)
+            .expectEntry(pts.get(1), 1)
+            .expectEntry(pts.get(2), 0)
+            .expectEntry(pts.get(3), 1)
+            .expectEntry(pts.get(4), 2)
+            .check();
+    }
+
+    @Test
+    void testPutAll_nanAndInf() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        // act/assert
+        for (final P nanPt : getNaNPoints()) {
+            final Map<P, Integer> nanMap = new HashMap<>();
+            nanMap.put(nanPt, 0);
+
+            Assertions.assertThrows(IllegalArgumentException.class, () -> map.putAll(nanMap));
+        }
+
+        for (final P infPt : getInfPoints()) {
+            final Map<P, Integer> infMap = new HashMap<>();
+            infMap.put(infPt, 0);
+
+            Assertions.assertThrows(IllegalArgumentException.class, () -> map.putAll(infMap));
+        }
+    }
+
+    @Test
+    void testRemove() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+        final P c = pts.get(2);
+        final P d = pts.get(3);
+
+        map.put(a, 1);
+        map.put(b, 2);
+        map.put(c, 3);
+
+        // act/assert
+        Assertions.assertNull(map.remove(d));
+        Assertions.assertEquals(1, map.remove(a));
+        Assertions.assertEquals(2, map.remove(b));
+        Assertions.assertEquals(3, map.remove(c));
+
+        Assertions.assertNull(map.remove(a));
+        Assertions.assertNull(map.remove(b));
+        Assertions.assertNull(map.remove(c));
+        Assertions.assertNull(map.remove(d));
+
+        checkerFor(map)
+            .doesNotContainKeys(pts)
+            .check();
+    }
+
+    @Test
+    void testRemove_equivalentPoints() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(100, EPS, new Random(9L));
+        insertPoints(pts, map);
+
+        // act
+        for (final P pt : pts) {
+            final Integer val = map.get(pt);
+
+            for (final P closePt : getTestPointsAtDistance(pt, 0.9 * EPS)) {
+                map.put(pt, val);
+
+                Assertions.assertEquals(val, map.remove(closePt));
+            }
+        }
+
+        // assert
+        assertEmpty(map);
+    }
+
+    @Test
+    void testRemove_largeEntryCount() {
+        // -- arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final Random rnd = new Random(2L);
+
+        final int cnt = 10_000;
+        final List<P> pts = getTestPoints(cnt * 2, EPS, rnd);
+
+        final List<P> testPts = new ArrayList<>(pts.subList(0, cnt));
+        final List<P> otherPts = new ArrayList<>(pts.subList(cnt, pts.size()));
+
+        // -- act/assert
+        // insert the test points
+        final PointMapChecker<P, Integer> allChecker = checkerFor(map);
+        final PointMapChecker<P, Integer> oddChecker = checkerFor(map);
+
+        final List<P> evenKeys = new ArrayList<>();
+        final List<P> oddKeys = new ArrayList<>();
+
+        for (int i = 0; i < cnt; ++i) {
+            final P key = testPts.get(i);
+
+            Assertions.assertNull(map.put(key, i));
+
+            allChecker.expectEntry(key, i);
+
+            if (i % 2 == 0) {
+                evenKeys.add(key);
+            } else {
+                oddKeys.add(key);
+                oddChecker.expectEntry(key, i);
+            }
+        }
+
+        // check map state after insertion of all test points
+        allChecker
+            .doesNotContainKeys(otherPts)
+            .check();
+
+        // remove points inserted on even indices; remove the keys in
+        // a different order than insertion
+        Collections.shuffle(evenKeys);
+        for (final P key : evenKeys) {
+            Assertions.assertNotNull(map.remove(key));
+        }
+
+        // check map state after partial removal
+        oddChecker
+            .doesNotContainKeys(otherPts)
+            .doesNotContainKeys(evenKeys)
+            .check();
+
+        // remove remaining points
+        Collections.shuffle(oddKeys);
+        for (final P key : oddKeys) {
+            Assertions.assertNotNull(map.remove(key));
+        }
+
+        // ensure that nothing is left
+        checkerFor(map)
+            .doesNotContainKeys(pts)
+            .check();
+    }
+
+    @Test
+    void testClear_empty() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        // act
+        map.clear();
+
+        // assert
+        assertEmpty(map);
+    }
+
+    @Test
+    void testClear_populated() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+        insertPoints(getTestPoints(1_000, EPS, new Random(6L)), map);
+
+        // act
+        map.clear();
+
+        // assert
+        assertEmpty(map);
+    }
+
+    @Test
+    void testRepeatedUse() {
+        // -- arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final Random rnd = new Random(3L);
+
+        final int cnt = 10_000;
+        final List<P> pts = getTestPoints(cnt, EPS, rnd);
+
+        // -- act
+        final int iterations = 10;
+        final int subListSize = cnt / iterations;
+        for (int i = 0; i < iterations; ++i) {
+            final int subListStart = i * subListSize;
+            final List<P> subList = pts.subList(subListStart, subListStart + subListSize);
+
+            // add sublist
+            insertPoints(subList, map);
+
+            // remove sublist in different order
+            final List<P> shuffledSubList = new ArrayList<>(subList);
+            Collections.shuffle(shuffledSubList, rnd);
+
+            removePoints(shuffledSubList, map);
+
+            // add sublist again
+            insertPoints(subList, map);
+        }
+
+        // -- assert
+        PointMapChecker<P, Integer> checker = checkerFor(map);
+
+        for (int i = 0; i < iterations * subListSize; ++i) {
+            checker.expectEntry(pts.get(i), i % subListSize);
+        }
+
+        checker.check();
+    }
+
+    @Test
+    void testHashCode() {
+        // arrange
+        final PointMap<P, Integer> a = getMap(PRECISION);
+        final PointMap<P, Integer> b = getMap(PRECISION);
+        final PointMap<P, Integer> c = getMap(PRECISION);
+        final PointMap<P, Integer> d = getMap(PRECISION);
+        final PointMap<P, Integer> e = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+
+        insertPoints(pts, a);
+        insertPoints(pts.subList(0, 2), b);
+
+        insertPoints(pts, d);
+        d.put(pts.get(0), -1);
+
+        insertPoints(pts, e);
+
+        // act
+        final int hash = a.hashCode();
+
+        // act/assert
+        Assertions.assertEquals(hash, a.hashCode());
+
+        Assertions.assertNotEquals(hash, b.hashCode());
+        Assertions.assertNotEquals(hash, c.hashCode());
+        Assertions.assertNotEquals(hash, d.hashCode());
+
+        Assertions.assertEquals(hash, e.hashCode());
+    }
+
+    @Test
+    void testEquals() {
+        // arrange
+        final PointMap<P, Integer> a = getMap(PRECISION);
+        final PointMap<P, Integer> b = getMap(PRECISION);
+        final PointMap<P, Integer> c = getMap(PRECISION);
+        final PointMap<P, Integer> d = getMap(PRECISION);
+        final PointMap<P, Integer> e = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+
+        insertPoints(pts, a);
+        insertPoints(pts.subList(0, 2), b);
+
+        insertPoints(pts, d);
+        d.put(pts.get(0), -1);
+
+        insertPoints(pts, e);
+
+        // act/assert
+        Assertions.assertFalse(a.equals(null));
+        Assertions.assertFalse(a.equals(new Object()));
+
+        Assertions.assertTrue(a.equals(a));
+
+        Assertions.assertFalse(a.equals(b));
+        Assertions.assertFalse(a.equals(c));
+        Assertions.assertFalse(a.equals(d));
+
+        Assertions.assertTrue(a.equals(e));
+    }
+
+    @Test
+    void testToString() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+        final List<P> pts = getTestPoints(1, EPS);
+        insertPoints(pts, map);
+
+        // act
+        final String str = map.toString();
+
+        // assert
+        GeometryTestUtils.assertContains(pts.get(0).toString(), str);
+    }
+
+    // EntrySet -----------------------------------
+
+    @Test
+    void testEntrySet_add_unsupported() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final P pt = getTestPoints(1, EPS).get(0);
+        final Map.Entry<P, Integer> entry = new SimpleEntry<>(pt, 100);
+
+        // act/assert
+        assertCollectionAddUnsupported(map.entrySet(), entry);
+    }
+
+    @Test
+    void testEntrySet_setValue() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(100, EPS);
+        insertPoints(pts, map);
+
+        final int offset = 100;
+
+        // act
+        for (final Map.Entry<P, Integer> entry : map.entrySet()) {
+            final Integer val = entry.getValue();
+            final Integer newVal = val + offset;
+
+            Assertions.assertEquals(val, entry.setValue(newVal));
+            Assertions.assertEquals(newVal, entry.getValue());
+        }
+
+        // assert
+        final PointMapChecker<P, Integer> checker = checkerFor(map);
+        int i = offset;
+        for (final P pt : pts) {
+            checker.expectEntry(pt, i++);
+        }
+
+        checker.check();
+    }
+
+    @Test
+    void testEntrySet_clear() {
+        // act/assert
+        assertCollectionClear(PointMap::entrySet);
+    }
+
+    @Test
+    void testEntrySet_contains() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+        insertPoints(pts.subList(0, 1),  map);
+
+        final Set<Map.Entry<P, Integer>> entrySet = map.entrySet();
+
+        // act/assert
+        Assertions.assertFalse(entrySet.contains(null));
+        Assertions.assertFalse(entrySet.contains(new Object()));
+
+        Assertions.assertTrue(entrySet.contains(new SimpleEntry<>(pts.get(0), 0)));
+
+        Assertions.assertFalse(entrySet.contains(new SimpleEntry<>(pts.get(0), 1)));
+        Assertions.assertFalse(entrySet.contains(new SimpleEntry<>(pts.get(1), 0)));
+    }
+
+    @Test
+    void testEntrySet_containsAll() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        insertPoints(pts.subList(0, 2),  map);
+
+        final Set<Map.Entry<P, Integer>> entrySet = map.entrySet();
+
+        final Map.Entry<P, Integer> a = new SimpleEntry<>(pts.get(0), 0);
+        final Map.Entry<P, Integer> b = new SimpleEntry<>(pts.get(1), 1);
+
+        final Map.Entry<P, Integer> c = new SimpleEntry<>(pts.get(2), 2);
+        final Map.Entry<P, Integer> d = new SimpleEntry<>(pts.get(3), 3);
+
+        // act/assert
+        Assertions.assertFalse(entrySet.containsAll(Arrays.asList(new Object(), new Object())));
+
+        Assertions.assertTrue(entrySet.containsAll(new ArrayList<>()));
+
+        Assertions.assertTrue(entrySet.containsAll(Arrays.asList(a)));
+        Assertions.assertTrue(entrySet.containsAll(Arrays.asList(b, a)));
+
+        Assertions.assertFalse(entrySet.containsAll(Arrays.asList(a, b, c)));
+        Assertions.assertFalse(entrySet.containsAll(Arrays.asList(c, d)));
+    }
+
+    @Test
+    void testEntrySet_remove() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+        insertPoints(pts, map);
+
+        final Set<Map.Entry<P, Integer>> entrySet = map.entrySet();
+
+        final Map.Entry<P, Integer> a = new SimpleEntry<>(pts.get(0), 0);
+        final Map.Entry<P, Integer> b = new SimpleEntry<>(pts.get(1), 1);
+        final Map.Entry<P, Integer> c = new SimpleEntry<>(pts.get(2), 2);
+
+        // act/assert
+        Assertions.assertFalse(entrySet.remove(null));
+        Assertions.assertFalse(entrySet.remove(new Object()));
+
+        Assertions.assertFalse(entrySet.remove(new SimpleEntry<>(pts.get(0), 1)));
+        Assertions.assertFalse(entrySet.remove(new SimpleEntry<>(pts.get(1), 0)));
+
+        checkerFor(map)
+            .expectEntry(a)
+            .expectEntry(b)
+            .expectEntry(c)
+            .check();
+
+        Assertions.assertTrue(entrySet.remove(a));
+
+        checkerFor(map)
+            .expectEntry(b)
+            .expectEntry(c)
+            .check();
+
+        Assertions.assertTrue(entrySet.remove(b));
+
+        checkerFor(map)
+            .expectEntry(c)
+            .check();
+
+        Assertions.assertTrue(entrySet.remove(c));
+
+        Assertions.assertEquals(0, entrySet.size());
+        assertEmpty(map);
+
+        Assertions.assertFalse(entrySet.remove(a));
+        Assertions.assertFalse(entrySet.remove(b));
+        Assertions.assertFalse(entrySet.remove(c));
+    }
+
+    @Test
+    void testEntrySet_removeAll() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        insertPoints(pts.subList(0, 3), map);
+
+        final Set<Map.Entry<P, Integer>> entrySet = map.entrySet();
+
+        final Map.Entry<P, Integer> a = new SimpleEntry<>(pts.get(0), 0);
+        final Map.Entry<P, Integer> b = new SimpleEntry<>(pts.get(1), 1);
+        final Map.Entry<P, Integer> c = new SimpleEntry<>(pts.get(2), 2);
+        final Map.Entry<P, Integer> d = new SimpleEntry<>(pts.get(3), 3);
+
+        // act/assert
+        Assertions.assertFalse(entrySet.removeAll(Arrays.asList(new Object(), new Object())));
+
+        Assertions.assertFalse(entrySet.removeAll(new ArrayList<>()));
+        Assertions.assertFalse(entrySet.removeAll(Arrays.asList(d)));
+
+        checkerFor(map)
+            .expectEntry(a)
+            .expectEntry(b)
+            .expectEntry(c)
+            .check();
+
+        Assertions.assertTrue(entrySet.removeAll(Arrays.asList(a, b)));
+
+        checkerFor(map)
+            .expectEntry(c)
+            .check();
+
+        Assertions.assertTrue(entrySet.removeAll(Arrays.asList(c, d)));
+
+        Assertions.assertEquals(0, entrySet.size());
+        assertEmpty(map);
+
+        Assertions.assertFalse(entrySet.removeAll(Arrays.asList(a, b)));
+        Assertions.assertFalse(entrySet.removeAll(Arrays.asList(c, d)));
+    }
+
+    @Test
+    void testEntrySet_retainAll() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        insertPoints(pts.subList(0, 3), map);
+
+        final Set<Map.Entry<P, Integer>> entrySet = map.entrySet();
+
+        final Map.Entry<P, Integer> a = new SimpleEntry<>(pts.get(0), 0);
+        final Map.Entry<P, Integer> b = new SimpleEntry<>(pts.get(1), 1);
+        final Map.Entry<P, Integer> c = new SimpleEntry<>(pts.get(2), 2);
+        final Map.Entry<P, Integer> d = new SimpleEntry<>(pts.get(3), 3);
+
+        // act/assert
+        Assertions.assertFalse(entrySet.retainAll(Arrays.asList(a, b, c)));
+
+        checkerFor(map)
+            .expectEntry(a)
+            .expectEntry(b)
+            .expectEntry(c)
+            .check();
+
+        Assertions.assertTrue(entrySet.retainAll(Arrays.asList(a, b, d)));
+
+        checkerFor(map)
+            .expectEntry(a)
+            .expectEntry(b)
+            .check();
+
+        Assertions.assertTrue(entrySet.retainAll(Arrays.asList(new Object(), new Object())));
+
+        Assertions.assertEquals(0, entrySet.size());
+        assertEmpty(map);
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    void testEntrySet_toArray() {
+        // act/assert
+        assertCollectionToArray(PointMap::entrySet, new Map.Entry[0]);
+    }
+
+    @Test
+    void testEntrySet_equalsAndHashCode() {
+        // act/assert
+        assertCollectionEquals(PointMap::entrySet);
+        assertCollectionHashCode(PointMap::entrySet);
+    }
+
+    @Test
+    void testEntrySet_toString() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+        final List<P> pts = getTestPoints(20, EPS);
+        insertPoints(pts, map);
+
+        // act
+        final String str = map.entrySet().toString();
+
+        // assert
+        GeometryTestUtils.assertContains(pts.get(17).toString(), str);
+        GeometryTestUtils.assertContains(Integer.toString(17), str);
+    }
+
+    @Test
+    void testEntrySetIterator() {
+        // act/assert
+        assertCollectionIterator(PointMap::entrySet);
+        assertCollectionIteratorRemove(PointMap::entrySet);
+        assertCollectionIteratorRemoveWithMultiplePasses(PointMap::entrySet);
+        assertCollectionIteratorRemoveWithoutNext(PointMap::entrySet);
+        assertCollectionIteratorRemoveMultipleCalls(PointMap::entrySet);
+        assertCollectionIteratorConcurrentModification(PointMap::entrySet);
+    }
+
+    // KeySet -----------------------------------
+
+    @Test
+    void testKeySet_add_unsupported() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final P pt = getTestPoints(1, EPS).get(0);
+
+        // act/assert
+        assertCollectionAddUnsupported(map.keySet(), pt);
+    }
+
+    @Test
+    void testKeySet_clear() {
+        // act/assert
+        assertCollectionClear(PointMap::keySet);
+    }
+
+    @Test
+    void testKeySet_contains() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+        insertPoints(pts.subList(0, 1),  map);
+
+        final Set<P> keySet = map.keySet();
+
+        // act/assert
+        Assertions.assertTrue(keySet.contains(pts.get(0)));
+        Assertions.assertFalse(keySet.contains(pts.get(1)));
+    }
+
+    @Test
+    void testKeySet_containsAll() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        insertPoints(pts.subList(0, 2),  map);
+
+        final Set<P> keySet = map.keySet();
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+
+        final P c = pts.get(2);
+        final P d = pts.get(3);
+
+        // act/assert
+        Assertions.assertTrue(keySet.containsAll(new ArrayList<>()));
+
+        Assertions.assertTrue(keySet.containsAll(Arrays.asList(a)));
+        Assertions.assertTrue(keySet.containsAll(Arrays.asList(b, a)));
+
+        Assertions.assertFalse(keySet.containsAll(Arrays.asList(a, b, c)));
+        Assertions.assertFalse(keySet.containsAll(Arrays.asList(c, d)));
+    }
+
+    @Test
+    void testKeySet_remove() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        insertPoints(pts.subList(0, 3), map);
+
+        final Set<P> keySet = map.keySet();
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+        final P c = pts.get(2);
+        final P d = pts.get(3);
+
+        // act/assert
+        Assertions.assertTrue(keySet.remove(a));
+        Assertions.assertTrue(keySet.remove(b));
+        Assertions.assertFalse(keySet.remove(d));
+
+        checkerFor(map)
+            .expectEntry(c, 2)
+            .check();
+
+        Assertions.assertTrue(keySet.remove(c));
+
+        Assertions.assertEquals(0, keySet.size());
+        assertEmpty(map);
+
+        Assertions.assertFalse(keySet.remove(a));
+        Assertions.assertFalse(keySet.remove(b));
+        Assertions.assertFalse(keySet.remove(c));
+        Assertions.assertFalse(keySet.remove(d));
+    }
+
+    @Test
+    void testKeySet_removeAll() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        insertPoints(pts.subList(0, 3), map);
+
+        final Set<P> keySet = map.keySet();
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+
+        final P c = pts.get(2);
+        final P d = pts.get(3);
+
+        // act/assert
+        Assertions.assertFalse(keySet.removeAll(new ArrayList<>()));
+        Assertions.assertFalse(keySet.removeAll(Arrays.asList(d)));
+
+        checkerFor(map)
+            .expectEntry(a, 0)
+            .expectEntry(b, 1)
+            .expectEntry(c, 2)
+            .check();
+
+        Assertions.assertTrue(keySet.removeAll(Arrays.asList(a, b)));
+
+        checkerFor(map)
+            .expectEntry(c, 2)
+            .check();
+
+        Assertions.assertTrue(keySet.removeAll(Arrays.asList(c, d)));
+
+        Assertions.assertEquals(0, keySet.size());
+        assertEmpty(map);
+
+        Assertions.assertFalse(keySet.removeAll(Arrays.asList(a, b)));
+        Assertions.assertFalse(keySet.removeAll(Arrays.asList(c, d)));
+    }
+
+    @Test
+    void testKeySet_retainAll() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        insertPoints(pts.subList(0, 3), map);
+
+        final Set<P> keySet = map.keySet();
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+
+        final P c = pts.get(2);
+        final P d = pts.get(3);
+
+        // act/assert
+        Assertions.assertFalse(keySet.retainAll(Arrays.asList(a, b, c)));
+
+        checkerFor(map)
+            .expectEntry(a, 0)
+            .expectEntry(b, 1)
+            .expectEntry(c, 2)
+            .check();
+
+        Assertions.assertTrue(keySet.retainAll(Arrays.asList(a, b, d)));
+
+        checkerFor(map)
+            .expectEntry(a, 0)
+            .expectEntry(b, 1)
+            .check();
+
+        Assertions.assertTrue(keySet.retainAll(Arrays.asList(new Object(), new Object())));
+
+        Assertions.assertEquals(0, keySet.size());
+        assertEmpty(map);
+    }
+
+    @Test
+    void testKeySet_toArray() {
+        // act/assert
+        assertCollectionToArray(PointMap::keySet, createPointArray());
+    }
+
+    @Test
+    void testKeySet_equalsAndHashCode() {
+        // act/assert
+        assertCollectionEquals(PointMap::keySet);
+        assertCollectionHashCode(PointMap::keySet);
+    }
+
+    @Test
+    void testKeySet_toString() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+        final List<P> pts = getTestPoints(1, EPS);
+        insertPoints(pts, map);
+
+        // act
+        final String str = map.keySet().toString();
+
+        // assert
+        GeometryTestUtils.assertContains(pts.get(0).toString(), str);
+    }
+
+    @Test
+    void testKeySetIterator() {
+        // act/assert
+        assertCollectionIterator(PointMap::keySet);
+        assertCollectionIteratorRemove(PointMap::keySet);
+        assertCollectionIteratorRemoveWithMultiplePasses(PointMap::keySet);
+        assertCollectionIteratorRemoveWithoutNext(PointMap::keySet);
+        assertCollectionIteratorRemoveMultipleCalls(PointMap::keySet);
+        assertCollectionIteratorConcurrentModification(PointMap::keySet);
+    }
+
+    // Values -----------------------------------
+
+    @Test
+    void testValues_add_unsupported() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        // act/assert
+        assertCollectionAddUnsupported(map.values(), 2);
+    }
+
+    @Test
+    void testValues_clear() {
+        // act/assert
+        assertCollectionClear(PointMap::values);
+    }
+
+    @Test
+    void testValues_contains() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+        insertPoints(pts.subList(0, 1),  map);
+
+        final Collection<Integer> values = map.values();
+
+        // act/assert
+        Assertions.assertTrue(values.contains(0));
+        Assertions.assertFalse(values.contains(1));
+    }
+
+    @Test
+    void testValues_containsAll() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        insertPoints(pts.subList(0, 2),  map);
+
+        final Collection<Integer> values = map.values();
+
+        // act/assert
+        Assertions.assertFalse(values.containsAll(Arrays.asList(new Object())));
+
+        Assertions.assertTrue(values.containsAll(new ArrayList<>()));
+
+        Assertions.assertTrue(values.containsAll(Arrays.asList(0)));
+        Assertions.assertTrue(values.containsAll(Arrays.asList(1, 0)));
+
+        Assertions.assertFalse(values.containsAll(Arrays.asList(0, 1, 2)));
+        Assertions.assertFalse(values.containsAll(Arrays.asList(2, 3)));
+    }
+
+    @Test
+    void testValues_remove() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(5, EPS);
+        insertPoints(pts.subList(0, 3), map);
+
+        map.put(pts.get(4), 0);
+
+        final Collection<Integer> values = map.values();
+
+        // act/assert
+        Assertions.assertTrue(values.remove(0));
+        Assertions.assertTrue(values.remove(0));
+        Assertions.assertTrue(values.remove(1));
+        Assertions.assertFalse(values.remove(3));
+
+        checkerFor(map)
+            .expectEntry(pts.get(2), 2)
+            .check();
+
+        Assertions.assertTrue(values.remove(2));
+
+        Assertions.assertEquals(0, values.size());
+        assertEmpty(map);
+
+        Assertions.assertFalse(values.remove(0));
+        Assertions.assertFalse(values.remove(1));
+        Assertions.assertFalse(values.remove(2));
+        Assertions.assertFalse(values.remove(3));
+    }
+
+    @Test
+    void testValues_removeAll() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        insertPoints(pts.subList(0, 3), map);
+
+        final Collection<Integer> values = map.values();
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+
+        final P c = pts.get(2);
+
+        // act/assert
+        Assertions.assertFalse(values.removeAll(new ArrayList<>()));
+        Assertions.assertFalse(values.removeAll(Arrays.asList(4)));
+
+        checkerFor(map)
+            .expectEntry(a, 0)
+            .expectEntry(b, 1)
+            .expectEntry(c, 2)
+            .check();
+
+        Assertions.assertTrue(values.removeAll(Arrays.asList(0, 1)));
+
+        checkerFor(map)
+            .expectEntry(c, 2)
+            .check();
+
+        Assertions.assertTrue(values.removeAll(Arrays.asList(2, 3)));
+
+        Assertions.assertEquals(0, values.size());
+        assertEmpty(map);
+
+        Assertions.assertFalse(values.removeAll(Arrays.asList(0, 1)));
+        Assertions.assertFalse(values.removeAll(Arrays.asList(2, 3)));
+    }
+
+    @Test
+    void testValues_retainAll() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        insertPoints(pts.subList(0, 3), map);
+
+        final Collection<Integer> values = map.values();
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+
+        final P c = pts.get(2);
+
+        // act/assert
+        Assertions.assertFalse(values.retainAll(Arrays.asList(0, 1, 2)));
+
+        checkerFor(map)
+            .expectEntry(a, 0)
+            .expectEntry(b, 1)
+            .expectEntry(c, 2)
+            .check();
+
+        Assertions.assertTrue(values.retainAll(Arrays.asList(0, 1, 3)));
+
+        checkerFor(map)
+            .expectEntry(a, 0)
+            .expectEntry(b, 1)
+            .check();
+
+        Assertions.assertTrue(values.retainAll(Arrays.asList(new Object(), new Object())));
+
+        Assertions.assertEquals(0, values.size());
+        assertEmpty(map);
+    }
+
+    @Test
+    void testValues_toArray() {
+        // act/assert
+        assertCollectionToArray(PointMap::values, new Integer[0]);
+    }
+
+    @Test
+    void testValues_toString() {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+        final List<P> pts = getTestPoints(20, EPS);
+        insertPoints(pts, map);
+
+        // act
+        final String str = map.values().toString();
+
+        // assert
+        GeometryTestUtils.assertContains(Integer.toString(17), str);
+    }
+
+    @Test
+    void testValuesIterator() {
+        // act/assert
+        assertCollectionIterator(PointMap::values);
+        assertCollectionIteratorRemove(PointMap::values);
+        assertCollectionIteratorRemoveWithMultiplePasses(PointMap::values);
+        assertCollectionIteratorRemoveWithoutNext(PointMap::values);
+        assertCollectionIteratorRemoveMultipleCalls(PointMap::values);
+        assertCollectionIteratorConcurrentModification(PointMap::values);
+    }
+
+    // Helpers -----------------------------------
+
+    private <V> void assertCollectionAddUnsupported(final Collection<V> coll, final V value) {
+        Assertions.assertThrows(UnsupportedOperationException.class, () -> coll.add(value));
+
+        final List<V> valueList = Arrays.asList(value);
+        Assertions.assertThrows(UnsupportedOperationException.class, () -> coll.addAll(valueList));
+    }
+
+    private void assertCollectionClear(final Function<PointMap<P, ?>, Collection<?>> collectionFactory) {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+        insertPoints(pts, map);
+
+        final Collection<?> coll = collectionFactory.apply(map);
+
+        // act
+        coll.clear();
+
+        // assert
+        Assertions.assertEquals(0, coll.size());
+        Assertions.assertTrue(coll.isEmpty());
+
+        assertEmpty(map);
+    }
+
+    private <T> void assertCollectionToArray(final Function<PointMap<P, Integer>, Collection<T>> collectionFactory,
+            final T[] typedArray) {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+        insertPoints(pts, map);
+
+        final Collection<T> coll = collectionFactory.apply(map);
+
+        // act
+        final Object[] objArr = coll.toArray();
+        final T[] tArr = coll.toArray(typedArray);
+
+        // assert
+        Assertions.assertEquals(map.size(), objArr.length);
+        Assertions.assertEquals(map.size(), tArr.length);
+
+        int i = 0;
+        for (final T element : coll) {
+            Assertions.assertEquals(element, objArr[i]);
+            Assertions.assertEquals(element, tArr[i]);
+
+            ++i;
+        }
+    }
+
+    private void assertCollectionEquals(final Function<PointMap<P, ?>, Collection<?>> collectionFactory) {
+        // arrange
+        final PointMap<P, Integer> mapA = getMap(PRECISION);
+        final PointMap<P, Integer> mapB = getMap(PRECISION);
+        final PointMap<P, Integer> mapC = getMap(PRECISION);
+        final PointMap<P, Integer> mapD = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+
+        insertPoints(pts, mapA);
+        insertPoints(pts.subList(0, 2), mapB);
+
+        insertPoints(pts, mapD);
+
+        final Collection<?> a = collectionFactory.apply(mapA);
+        final Collection<?> b = collectionFactory.apply(mapB);
+        final Collection<?> c = collectionFactory.apply(mapC);
+        final Collection<?> d = collectionFactory.apply(mapD);
+
+        // act/assert
+        Assertions.assertFalse(a.equals(null));
+        Assertions.assertFalse(a.equals(new Object()));
+
+        Assertions.assertTrue(a.equals(a));
+
+        Assertions.assertFalse(a.equals(b));
+        Assertions.assertFalse(a.equals(c));
+
+        Assertions.assertTrue(a.equals(d));
+    }
+
+    private void assertCollectionHashCode(final Function<PointMap<P, ?>, Collection<?>> collectionFactory) {
+        // arrange
+        final PointMap<P, Integer> mapA = getMap(PRECISION);
+        final PointMap<P, Integer> mapB = getMap(PRECISION);
+        final PointMap<P, Integer> mapC = getMap(PRECISION);
+        final PointMap<P, Integer> mapD = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+
+        insertPoints(pts, mapA);
+        insertPoints(pts.subList(0, 2), mapB);
+
+        insertPoints(pts, mapD);
+
+        final Collection<?> a = collectionFactory.apply(mapA);
+        final Collection<?> b = collectionFactory.apply(mapB);
+        final Collection<?> c = collectionFactory.apply(mapC);
+        final Collection<?> d = collectionFactory.apply(mapD);
+
+        // act
+        final int hash = a.hashCode();
+
+        // assert
+        Assertions.assertEquals(hash, a.hashCode());
+
+        Assertions.assertNotEquals(hash, b.hashCode());
+        Assertions.assertNotEquals(hash, c.hashCode());
+
+        Assertions.assertEquals(hash, d.hashCode());
+    }
+
+    private void assertCollectionIterator(final Function<PointMap<P, ?>, Collection<?>> collectionFactory) {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+        insertPoints(getTestPoints(1, EPS), map);
+
+        // act/assert
+        final Collection<?> coll = collectionFactory.apply(map);
+
+        final Iterator<?> it = coll.iterator();
+
+        Assertions.assertTrue(it.hasNext());
+        Assertions.assertNotNull(it.next());
+
+        Assertions.assertFalse(it.hasNext());
+        Assertions.assertThrows(NoSuchElementException.class, () -> it.next());
+    }
+
+    private void assertCollectionIteratorRemove(final Function<PointMap<P, ?>, Collection<?>> collectionFactory) {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(1_000, EPS, new Random(10L));
+
+        insertPoints(pts, map);
+
+        // act
+        final Collection<?> coll = collectionFactory.apply(map);
+
+        int removed = 0;
+
+        final Iterator<?> it = coll.iterator();
+        while (it.hasNext()) {
+            Assertions.assertNotNull(it.next());
+            it.remove();
+
+            ++removed;
+        }
+
+        // assert
+        Assertions.assertEquals(pts.size(), removed);
+
+        Assertions.assertEquals(0, coll.size());
+        Assertions.assertTrue(coll.isEmpty());
+
+        assertEmpty(map);
+    }
+
+    private void assertCollectionIteratorRemoveWithMultiplePasses(
+            final Function<PointMap<P, ?>, Collection<?>> collectionFactory) {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final List<P> pts = getTestPoints(1_000, EPS, new Random(10L));
+
+        insertPoints(pts, map);
+
+        // act
+        // remove the entries in two passes: one to remove the entries with even
+        // values and the second to remove the remaining
+        final Collection<?> coll = collectionFactory.apply(map);
+
+        final Iterator<?> firstPass = coll.iterator();
+        int removed = 0;
+        int i = 0;
+        while (firstPass.hasNext()) {
+            Assertions.assertNotNull(firstPass.next());
+            if ((++i) % 2 == 0) {
+                ++removed;
+                firstPass.remove();
+            }
+        }
+
+        final Iterator<?> secondPass = coll.iterator();
+        while (secondPass.hasNext()) {
+            Assertions.assertNotNull(secondPass.next());
+            secondPass.remove();
+            ++removed;
+        }
+
+        // assert
+        Assertions.assertEquals(pts.size(), removed);
+
+        Assertions.assertEquals(0, coll.size());
+        Assertions.assertTrue(coll.isEmpty());
+
+        assertEmpty(map);
+    }
+
+    private void assertCollectionIteratorRemoveWithoutNext(
+            final Function<PointMap<P, ?>, Collection<?>> collectionFactory) {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+
+        final Collection<?> coll = collectionFactory.apply(map);
+
+        // act/assert
+        final Iterator<?> it = coll.iterator();
+
+        Assertions.assertThrows(IllegalStateException.class, () -> it.remove());
+    }
+
+    private void assertCollectionIteratorRemoveMultipleCalls(
+            final Function<PointMap<P, ?>, Collection<?>> collectionFactory) {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+        insertPoints(getTestPoints(1, EPS), map);
+
+        final Collection<?> coll = collectionFactory.apply(map);
+        final Iterator<?> it = coll.iterator();
+
+        // act/assert
+        Assertions.assertNotNull(it.next());
+        it.remove();
+
+        Assertions.assertThrows(IllegalStateException.class, () -> it.remove());
+
+        Assertions.assertEquals(0, map.size());
+    }
+
+    private void assertCollectionIteratorConcurrentModification(
+            final Function<PointMap<P, ?>, Collection<?>> collectionFactory) {
+        // arrange
+        final PointMap<P, Integer> map = getMap(PRECISION);
+        final List<P> pts = getTestPoints(3, EPS);
+
+        insertPoints(pts.subList(0, 2), map);
+
+        final Collection<?> coll = collectionFactory.apply(map);
+        final Iterator<?> it = coll.iterator();
+
+        // act
+        it.next();
+        map.put(pts.get(2), 3);
+
+        // assert
+        Assertions.assertTrue(it.hasNext());
+        Assertions.assertThrows(ConcurrentModificationException.class, () -> it.next());
+    }
+
+    /** Insert the given list of points into {@code map}. The value of each key is the index of the key
+     * in {@code pts}.
+     * @param <P> Point type
+     * @param pts list of points
+     * @param map map to insert into
+     */
+    public static <P extends Point<P>> void insertPoints(final List<P> pts, final PointMap<P, Integer> map) {
+        int i = -1;
+        for (final P pt : pts) {
+            map.put(pt, ++i);
+        }
+    }
+
+    /** Remove each point in {@code pts} from {@code map}.
+     * @param <P> Point type
+     * @param pts points to remove
+     * @param map map to remove from
+     */
+    public static <P extends Point<P>> void removePoints(final List<P> pts, final PointMap<P, Integer> map) {
+        for (final P pt : pts) {
+            map.remove(pt);
+        }
+    }
+
+    /** Return a new {@link PointMapChecker} for asserting the contents
+     * of the given map.
+     * @return a new checker instance
+     */
+    public static <P extends Point<P>, V> PointMapChecker<P, V> checkerFor(final PointMap<P, V> map) {
+        return new PointMapChecker<>(map);
+    }
+
+    /** Assert that the given map is empty.
+     * @param map map to assert empty
+     */
+    public static void assertEmpty(final PointMap<?, ?> map) {
+        checkerFor(map)
+            .check();
+    }
+
+    /** Class designed to assist with performing assertions on the state
+     * of a point map.
+     */
+    public static class PointMapChecker<P extends Point<P>, V> {
+
+        private final PointMap<P, V> map;
+
+        private final Map<P, V> expectedMap = new HashMap<>();
+
+        private final List<P> unexpectedKeys = new ArrayList<>();
+
+        public PointMapChecker(final PointMap<P, V> map) {
+            this.map = map;
+        }
+
+        public PointMapChecker<P, V> expectEntry(final P key, final V value) {
+            expectedMap.put(key, value);
+
+            return this;
+        }
+
+        public PointMapChecker<P, V> expectEntry(final Map.Entry<P, V> entry) {
+            return expectEntry(entry.getKey(), entry.getValue());
+        }
+
+        public PointMapChecker<P, V> doesNotContainKey(final P key) {
+            unexpectedKeys.add(key);
+
+            return this;
+        }
+
+        public PointMapChecker<P, V> doesNotContainKeys(final Iterable<? extends P> keys) {
+            for (final P key : keys) {
+                doesNotContainKey(key);
+            }
+
+            return this;
+        }
+
+        public void check() {
+            checkSize();
+
+            checkEntries();
+
+            checkEntrySet();
+            checkKeySet();
+            checkValues();
+
+            checkUnexpectedKeys();
+        }
+
+        private void checkSize() {
+            Assertions.assertEquals(expectedMap.size(), map.size(), "Unexpected map size");
+            Assertions.assertEquals(expectedMap.isEmpty(), map.isEmpty(), "Unexpected isEmpty() result");
+        }
+
+        private void checkEntries() {
+            for (final Map.Entry<P, V> expectedEntry : expectedMap.entrySet()) {
+                final P expectedKey = expectedEntry.getKey();
+                final V expectedValue = expectedEntry.getValue();
+
+                Assertions.assertEquals(new SimpleEntry<>(expectedKey, expectedValue), map.getEntry(expectedKey),
+                        () -> "Failed to get entry for key " + expectedKey);
+                Assertions.assertEquals(expectedValue, map.get(expectedKey),
+                        () -> "Unexpected value for key " + expectedKey);
+
+                Assertions.assertTrue(map.containsKey(expectedKey),
+                        () -> "Expected map to contain key " + expectedKey);
+                Assertions.assertTrue(map.containsValue(expectedValue),
+                        () -> "Expected map to contain value " + expectedValue);
+            }
+        }
+
+        private void checkKeySet() {
+            Set<P> expectedKeySet = expectedMap.keySet();
+
+            Set<P> keySet = map.keySet();
+            Assertions.assertEquals(expectedKeySet.size(), keySet.size(), "Unexpected key set size");
+            Assertions.assertEquals(expectedKeySet.isEmpty(), keySet.isEmpty(),
+                    "Unexpected key set \"isEmpty\" value");
+
+            for (final P key : keySet) {
+                Assertions.assertTrue(expectedKeySet.contains(key),
+                        () -> "Unexpected key in key set: " + key);
+            }
+
+            for (final P expectedKey : expectedKeySet) {
+                Assertions.assertTrue(keySet.contains(expectedKey),
+                        () -> "Key set is missing expected key: " + expectedKey);
+            }
+        }
+
+        private void checkEntrySet() {
+            final Set<Map.Entry<P, V>> entrySet = map.entrySet();
+            Assertions.assertEquals(expectedMap.size(), entrySet.size(), "Unexpected entry set size");
+            Assertions.assertEquals(expectedMap.isEmpty(), entrySet.isEmpty(),
+                    "Unexpected entry set \"isEmpty\" value");
+
+            final Map<P, V> remainingEntryMap = new HashMap<>(expectedMap);
+            for (final Map.Entry<P, V> actualEntry : entrySet) {
+                Assertions.assertTrue(remainingEntryMap.containsKey(actualEntry.getKey()),
+                        "Unexpected key in entry set: " + actualEntry.getKey());
+
+                final V expectedValue = remainingEntryMap.remove(actualEntry.getKey());
+                Assertions.assertEquals(expectedValue, actualEntry.getValue(),
+                        () -> "Unexpected value in entry set for key " + actualEntry.getKey());
+            }
+
+            Assertions.assertTrue(remainingEntryMap.isEmpty(),
+                    () -> "Entry set is missing expected entries: " + remainingEntryMap);
+        }
+
+        private void checkValues() {
+            Collection<V> actualValues = map.values();
+
+            Assertions.assertEquals(expectedMap.size(), actualValues.size(),
+                    "Unexpected values collection size");
+
+            // check that each value in the list occurs the value number of times
+            // as expect)ed
+            final Map<V, Integer> expectedCounts = new HashMap<>();
+            for (final Map.Entry<P, V> entry : expectedMap.entrySet()) {
+                expectedCounts.merge(entry.getValue(), 1, (a, b) -> a + b);
+            }
+
+            final Map<V, Integer> actualCounts = new HashMap<>();
+            for (final V value : actualValues) {
+                actualCounts.merge(value, 1, (a, b) -> a + b);
+            }
+
+            for (final Map.Entry<V, Integer> expected : expectedCounts.entrySet()) {
+                Assertions.assertEquals(expected.getValue(), actualCounts.get(expected.getKey()),
+                        () -> "Unexpected count for value " + expected.getKey());
+            }
+        }
+
+        private void checkUnexpectedKeys() {
+            for (final P key : unexpectedKeys) {
+                Assertions.assertFalse(map.containsKey(key), () -> "Expected map to not contain key " + key);
+                Assertions.assertNull(map.get(key), () -> "Expected map to not contain value for key " + key);
+
+                Assertions.assertNull(map.getEntry(key), () -> "Expected map to not contain key " + key);
+
+                Assertions.assertFalse(map.keySet().contains(key),
+                        () -> "Expected map key set to not contain " + key);
+
+                final boolean inEntrySet = map.entrySet().stream()
+                        .anyMatch(e -> e.getKey().equals(key));
+                Assertions.assertFalse(inEntrySet, () -> "Expected map entry set to not contain key " + key);
+            }
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/collection/PointSetTestBase.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/collection/PointSetTestBase.java
new file mode 100644
index 0000000..54102fb
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/collection/PointSetTestBase.java
@@ -0,0 +1,801 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.core.collection;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Random;
+import java.util.Set;
+
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.numbers.core.Precision;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Abstract base class for {@link PointSet} implementations.
+ * @param <P> Point type
+ */
+public abstract class PointSetTestBase<P extends Point<P>>
+    extends PointCollectionTestBase<P> {
+
+    /** Get a new point set instance for testing.
+     * @param <V> Value type
+     * @param precision precision context to determine floating point equality
+     * @return a new set instance for testing.
+     */
+    protected abstract PointSet<P> getSet(Precision.DoubleEquivalence precision);
+
+    @Test
+    void testEmpty() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final P pt = getTestPoints(1, EPS).get(0);
+
+        // act/assert
+        Assertions.assertEquals(0, set.size());
+        Assertions.assertTrue(set.isEmpty());
+
+        Assertions.assertFalse(set.contains(pt));
+        Assertions.assertNull(set.get(pt));
+    }
+
+    @Test
+    void testSingleEntry() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+        final P a = pts.get(0);
+
+        // act
+        set.add(a);
+
+        // assert
+        checkerFor(set)
+            .expect(a)
+            .doesNotContain(pts.subList(1, pts.size()))
+            .check();
+    }
+
+    @Test
+    void testMultipleEntries() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final PointSetChecker<P> checker = checkerFor(set);
+
+        final int putCnt = 1000;
+        final List<P> pts = getTestPoints(putCnt * 2, EPS, new Random(1L));
+
+        // act
+        for (int i = 0; i < putCnt; ++i) {
+            final P pt = pts.get(i);
+
+            set.add(pt);
+
+            checker.expect(pt);
+        }
+
+        // assert
+        checker
+            .doesNotContain(pts.subList(putCnt, pts.size()))
+            .check();
+    }
+
+    @Test
+    void testAdd() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(500, EPS);
+
+        // act/assert
+        for (final P pt : pts) {
+            Assertions.assertTrue(set.add(pt));
+            Assertions.assertFalse(set.add(pt));
+
+            for (final P closePt : getTestPointsAtDistance(pt, 0.5 * EPS)) {
+                Assertions.assertFalse(set.add(closePt));
+            }
+        }
+
+        checkerFor(set)
+            .expect(pts)
+            .check();
+    }
+
+    @Test
+    void testContainsGet_equivalentPoints_singleEntry() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(1, EPS);
+        set.addAll(pts);
+
+        final P pt = pts.get(0);
+
+        // act/assert
+        Assertions.assertTrue(set.contains(pt));
+        Assertions.assertEquals(pt, set.get(pt));
+
+        for (final P closePt : getTestPointsAtDistance(pt, EPS * 0.75)) {
+            Assertions.assertTrue(set.contains(closePt));
+            Assertions.assertEquals(pt, set.get(closePt));
+
+            assertEq(closePt, pt, PRECISION);
+        }
+
+        for (final P farPt : getTestPointsAtDistance(pt, EPS * 1.25)) {
+            Assertions.assertFalse(set.contains(farPt));
+            Assertions.assertNull(set.get(farPt));
+
+            assertNotEq(farPt, pt, PRECISION);
+        }
+    }
+
+    @Test
+    void testContainsGet_equivalentPoints_multipleEntries() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(1_000, 3 * EPS);
+        set.addAll(pts);
+
+        // act/assert
+        for (final P pt : pts) {
+            Assertions.assertTrue(set.contains(pt));
+            Assertions.assertEquals(pt, set.get(pt));
+
+            for (final P closePt : getTestPointsAtDistance(pt, EPS * 0.75)) {
+                Assertions.assertTrue(set.contains(closePt));
+                Assertions.assertEquals(pt, set.get(closePt));
+
+                assertEq(closePt, pt, PRECISION);
+            }
+
+            for (final P farPt : getTestPointsAtDistance(pt, EPS * 1.25)) {
+                Assertions.assertFalse(set.contains(farPt));
+                Assertions.assertNull(set.get(farPt));
+
+                assertNotEq(farPt, pt, PRECISION);
+            }
+        }
+    }
+
+    @Test
+    void testContainsGet_invalidArgs() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        set.addAll(getTestPoints(3, EPS));
+
+        // act/assert
+        Assertions.assertThrows(NullPointerException.class, () -> set.get(null));
+        Assertions.assertThrows(ClassCastException.class, () -> set.contains(new Object()));
+    }
+
+    @Test
+    void testContainsGet_nanAndInf() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+        set.addAll(getTestPoints(100, EPS));
+
+        // act/assert
+        for (final P pt : getNaNPoints()) {
+            Assertions.assertFalse(set.contains(pt));
+            Assertions.assertNull(set.get(pt));
+        }
+
+        for (final P pt : getInfPoints()) {
+            Assertions.assertFalse(set.contains(pt));
+            Assertions.assertNull(set.get(pt));
+        }
+    }
+
+    @Test
+    void testContainsAll() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(1_000, 3 * EPS);
+        final List<P> addedPts = pts.subList(0, pts.size() / 2);
+
+        set.addAll(addedPts);
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+        final P c = pts.get(pts.size() - 1);
+        final P d = pts.get(pts.size() - 2);
+
+        // act/assert
+        Assertions.assertTrue(set.containsAll(Arrays.asList()));
+        Assertions.assertTrue(set.containsAll(Arrays.asList(a)));
+        Assertions.assertTrue(set.containsAll(Arrays.asList(b)));
+        Assertions.assertTrue(set.containsAll(addedPts));
+
+        Assertions.assertFalse(set.containsAll(Arrays.asList(c)));
+        Assertions.assertFalse(set.containsAll(Arrays.asList(c, d)));
+        Assertions.assertFalse(set.containsAll(Arrays.asList(a, b, c, d)));
+    }
+
+    @Test
+    void testContainsAll_empty() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(10, 3 * EPS);
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+
+        // act/assert
+        Assertions.assertTrue(set.containsAll(Arrays.asList()));
+
+        Assertions.assertFalse(set.containsAll(Arrays.asList(a, b)));
+        Assertions.assertFalse(set.containsAll(pts));
+    }
+
+    @Test
+    void testRemove() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(4, EPS);
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+        final P c = pts.get(2);
+        final P d = pts.get(3);
+
+        set.add(a);
+        set.add(b);
+        set.add(c);
+
+        // act/assert
+        Assertions.assertFalse(set.remove(d));
+        Assertions.assertTrue(set.remove(a));
+        Assertions.assertTrue(set.remove(b));
+        Assertions.assertTrue(set.remove(c));
+
+        Assertions.assertFalse(set.remove(a));
+        Assertions.assertFalse(set.remove(b));
+        Assertions.assertFalse(set.remove(c));
+        Assertions.assertFalse(set.remove(d));
+
+        checkerFor(set)
+            .doesNotContain(pts)
+            .check();
+    }
+
+    @Test
+    void testRemove_largeEntryCount() {
+        // -- arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final Random rnd = new Random(2L);
+
+        final int cnt = 10_000;
+        final List<P> pts = getTestPoints(cnt * 2, EPS, rnd);
+
+        final List<P> testPts = new ArrayList<>(pts.subList(0, cnt));
+        final List<P> otherPts = new ArrayList<>(pts.subList(cnt, pts.size()));
+
+        // -- act/assert
+        // insert the test points
+        final PointSetChecker<P> allChecker = checkerFor(set);
+        final PointSetChecker<P> oddChecker = checkerFor(set);
+
+        final List<P> evenPts = new ArrayList<>();
+        final List<P> oddPts = new ArrayList<>();
+
+        for (int i = 0; i < cnt; ++i) {
+            final P pt = testPts.get(i);
+
+            Assertions.assertTrue(set.add(pt));
+
+            allChecker.expect(pt);
+
+            if (i % 2 == 0) {
+                evenPts.add(pt);
+            } else {
+                oddPts.add(pt);
+                oddChecker.expect(pt);
+            }
+        }
+
+        // check state after insertion of all test points
+        allChecker
+            .doesNotContain(otherPts)
+            .check();
+
+        // remove points inserted on even indices; remove the values in
+        // a different order than insertion
+        Collections.shuffle(evenPts);
+        for (final P pt : evenPts) {
+            Assertions.assertTrue(set.remove(pt));
+        }
+
+        // check state after partial removal
+        oddChecker
+            .doesNotContain(otherPts)
+            .doesNotContain(evenPts)
+            .check();
+
+        // remove remaining points
+        Collections.shuffle(oddPts);
+        for (final P pt : oddPts) {
+            Assertions.assertTrue(set.remove(pt));
+        }
+
+        // ensure that nothing is left
+        assertEmpty(set);
+    }
+
+    @Test
+    void testRemoveAll() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final int size = 500;
+
+        final List<P> pts = getTestPoints(size * 2, 3 * EPS);
+        final List<P> addedPts = pts.subList(0, size);
+
+        set.addAll(addedPts);
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+        final P c = pts.get(3);
+        final P d = pts.get(pts.size() - 1);
+        final P e = pts.get(pts.size() - 2);
+
+        // act/assert
+        Assertions.assertFalse(set.removeAll(Arrays.asList()));
+        Assertions.assertEquals(size, set.size());
+
+        Assertions.assertTrue(set.removeAll(Arrays.asList(a)));
+        Assertions.assertEquals(size - 1, set.size());
+
+        Assertions.assertTrue(set.removeAll(Arrays.asList(a, b, c)));
+        Assertions.assertEquals(size - 3, set.size());
+
+        Assertions.assertFalse(set.removeAll(Arrays.asList(a, b, c)));
+        Assertions.assertEquals(size - 3, set.size());
+
+        Assertions.assertFalse(set.removeAll(Arrays.asList(d, e)));
+        Assertions.assertEquals(size - 3, set.size());
+
+        Assertions.assertTrue(set.removeAll(addedPts));
+        assertEmpty(set);
+    }
+
+    @Test
+    void testRemoveAll_empty() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(10, 3 * EPS);
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+
+        // act/assert
+        Assertions.assertFalse(set.removeAll(Arrays.asList()));
+
+        Assertions.assertFalse(set.removeAll(Arrays.asList(a, b)));
+        Assertions.assertFalse(set.removeAll(pts));
+    }
+
+    @Test
+    void testRetainAll() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final int size = 500;
+
+        final List<P> pts = getTestPoints(size * 2, 3 * EPS);
+        final List<P> addedPts = pts.subList(0, size);
+
+        set.addAll(addedPts);
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+        final P c = pts.get(3);
+        final P d = pts.get(pts.size() - 1);
+        final P e = pts.get(pts.size() - 2);
+
+        // act/assert
+        Assertions.assertFalse(set.retainAll(addedPts));
+        Assertions.assertEquals(size, set.size());
+
+        Assertions.assertTrue(set.retainAll(Arrays.asList(a, b, c)));
+        Assertions.assertEquals(3, set.size());
+
+        Assertions.assertTrue(set.retainAll(Arrays.asList(d, e)));
+        assertEmpty(set);
+
+        Assertions.assertFalse(set.retainAll(Arrays.asList()));
+        assertEmpty(set);
+    }
+
+    @Test
+    void testRetainAll_empty() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(10, 3 * EPS);
+
+        final P a = pts.get(0);
+        final P b = pts.get(1);
+
+        // act/assert
+        Assertions.assertFalse(set.retainAll(Arrays.asList()));
+
+        Assertions.assertFalse(set.retainAll(Arrays.asList(a, b)));
+        Assertions.assertFalse(set.retainAll(pts));
+    }
+
+    @Test
+    void testClear_empty() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        // act
+        set.clear();
+
+        // assert
+        assertEmpty(set);
+    }
+
+    @Test
+    void testClear_populated() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+        set.addAll(getTestPoints(1_000, EPS, new Random(6L)));
+
+        // act
+        set.clear();
+
+        // assert
+        assertEmpty(set);
+    }
+
+    @Test
+    void testRepeatedUse() {
+        // -- arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final Random rnd = new Random(3L);
+
+        final int cnt = 10_000;
+        final List<P> pts = getTestPoints(cnt, EPS, rnd);
+
+        // -- act
+        final int iterations = 10;
+        final int subListSize = cnt / iterations;
+        for (int i = 0; i < iterations; ++i) {
+            final int subListStart = i * subListSize;
+            final List<P> subList = pts.subList(subListStart, subListStart + subListSize);
+
+            // add sublist
+            set.addAll(subList);
+
+            // remove sublist in different order
+            final List<P> shuffledSubList = new ArrayList<>(subList);
+            Collections.shuffle(shuffledSubList, rnd);
+
+            for (final P pt : shuffledSubList) {
+                Assertions.assertTrue(set.remove(pt));
+            }
+
+            // add sublist again
+            set.addAll(subList);
+        }
+
+        // -- assert
+        checkerFor(set)
+                .expect(pts)
+                .check();
+    }
+
+    @Test
+    void testIterator() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final Set<P> testPts = new HashSet<>(getTestPoints(1_000, 2 * EPS));
+        set.addAll(testPts);
+
+        // act/assert
+        final Iterator<P> it = set.iterator();
+        while (it.hasNext()) {
+            final P pt = it.next();
+
+            Assertions.assertTrue(testPts.remove(pt), () -> "Unexpected iterator point " + pt);
+        }
+
+        Assertions.assertEquals(0, testPts.size(), "Expected iterator to visit all points");
+
+        Assertions.assertFalse(it.hasNext());
+        Assertions.assertThrows(NoSuchElementException.class, () -> it.next());
+    }
+
+    @Test
+    void testIterator_empty() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        // act
+        final Iterator<P> it = set.iterator();
+
+        // assert
+        Assertions.assertFalse(it.hasNext());
+        Assertions.assertThrows(NoSuchElementException.class, () -> it.next());
+    }
+
+    @Test
+    void testIterator_remove() {
+        // --- arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final int size = 1_000;
+        final Set<P> testPts = new HashSet<>(getTestPoints(size, 2 * EPS));
+
+        set.addAll(testPts);
+
+        // --- act/assert
+        final Iterator<P> it = set.iterator();
+        while (it.hasNext()) {
+            final P pt = it.next();
+
+            it.remove();
+            Assertions.assertTrue(testPts.remove(pt), () -> "Unexpected iterator point " + pt);
+        }
+
+        Assertions.assertEquals(0, testPts.size(), "Expected iterator to visit all points");
+
+        assertEmpty(set);
+    }
+
+    @Test
+    void testIterator_remove_multiplePasses() {
+        // --- arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final int size = 1_000;
+        final Set<P> testPts = new HashSet<>(getTestPoints(size, 2 * EPS));
+
+        set.addAll(testPts);
+
+        // --- act/assert
+        // remove the points in two passes
+        final Iterator<P> firstIt = set.iterator();
+
+        int i = -1;
+        while (firstIt.hasNext()) {
+            final P pt = firstIt.next();
+
+            if ((++i) % 2 == 0) {
+                firstIt.remove();
+                Assertions.assertTrue(testPts.remove(pt), () -> "Unexpected iterator point " + pt);
+            }
+        }
+
+        Assertions.assertEquals(size / 2, set.size());
+
+        final Iterator<P> secondIt = set.iterator();
+        while (secondIt.hasNext()) {
+            final P pt = secondIt.next();
+
+            secondIt.remove();
+
+            Assertions.assertTrue(testPts.remove(pt), () -> "Unexpected iterator point " + pt);
+        }
+
+        Assertions.assertEquals(0, testPts.size(), "Expected iterator to visit all points");
+
+        assertEmpty(set);
+    }
+
+    @Test
+    void testToArray() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+        set.addAll(pts);
+
+        // act
+        final Object[] objArr = set.toArray();
+        final P[] typedArr = set.toArray(createPointArray());
+
+        // assert
+        Assertions.assertEquals(set.size(), objArr.length);
+        Assertions.assertEquals(set.size(), typedArr.length);
+
+        int i = 0;
+        for (final P element : set) {
+            Assertions.assertEquals(element, objArr[i]);
+            Assertions.assertEquals(element, typedArr[i]);
+
+            ++i;
+        }
+    }
+
+    @Test
+    void testHashCode() {
+        // arrange
+        final PointSet<P> a = getSet(PRECISION);
+        final PointSet<P> b = getSet(PRECISION);
+        final PointSet<P> c = getSet(PRECISION);
+        final PointSet<P> d = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+
+        a.addAll(pts);
+        b.addAll(pts.subList(0, 2));
+
+        d.addAll(pts);
+
+        // act
+        final int hash = a.hashCode();
+
+        // act/assert
+        Assertions.assertEquals(hash, a.hashCode());
+
+        Assertions.assertNotEquals(hash, b.hashCode());
+        Assertions.assertNotEquals(hash, c.hashCode());
+
+        Assertions.assertEquals(hash, d.hashCode());
+    }
+
+    @Test
+    void testEquals() {
+        // arrange
+        final PointSet<P> a = getSet(PRECISION);
+        final PointSet<P> b = getSet(PRECISION);
+        final PointSet<P> c = getSet(PRECISION);
+        final PointSet<P> d = getSet(PRECISION);
+
+        final List<P> pts = getTestPoints(3, EPS);
+
+        a.addAll(pts);
+        b.addAll(pts.subList(0, 2));
+
+        d.addAll(pts);
+
+        // act/assert
+        Assertions.assertFalse(a.equals(null));
+        Assertions.assertFalse(a.equals(new Object()));
+
+        Assertions.assertTrue(a.equals(a));
+
+        Assertions.assertFalse(a.equals(b));
+        Assertions.assertFalse(a.equals(c));
+
+        Assertions.assertTrue(a.equals(d));
+    }
+
+    @Test
+    void testToString() {
+        // arrange
+        final PointSet<P> set = getSet(PRECISION);
+        final P pt = getTestPoints(1, EPS).get(0);
+
+        set.add(pt);
+
+        // act
+        final String str = set.toString();
+
+        // assert
+        GeometryTestUtils.assertContains(pt.toString(), str);
+    }
+
+    /** Return a new {@link PointSetChecker} for asserting the contents
+     * of the given set.
+     * @param set
+     * @return a new checker instance
+     */
+    public static <P extends Point<P>> PointSetChecker<P> checkerFor(final PointSet<P> set) {
+        return new PointSetChecker<>(set);
+    }
+
+    /** Assert that the given set is empty.
+     * @param set setto assert empty
+     */
+    public static void assertEmpty(final PointSet<?> set) {
+        checkerFor(set)
+            .check();
+    }
+
+    /** Class designed to assist with performing assertions on the state
+     * of a point set.
+     */
+    public static class PointSetChecker<P extends Point<P>> {
+
+        private final PointSet<P> set;
+
+        private final List<P> expected = new ArrayList<>();
+
+        private final List<P> unexpected = new ArrayList<>();
+
+        public PointSetChecker(final PointSet<P> set) {
+            this.set = set;
+        }
+
+        public PointSetChecker<P> expect(final P value) {
+            expected.add(value);
+
+            return this;
+        }
+
+        public PointSetChecker<P> expect(final Iterable<? extends P> values) {
+            for (final P value : values) {
+                expect(value);
+            }
+
+            return this;
+        }
+
+        public PointSetChecker<P> doesNotContain(final P value) {
+            unexpected.add(value);
+
+            return this;
+        }
+
+        public PointSetChecker<P> doesNotContain(final Iterable<? extends P> values) {
+            for (final P value : values) {
+                doesNotContain(value);
+            }
+
+            return this;
+        }
+
+        public void check() {
+            checkSize();
+            checkValues();
+            checkUnexpectedValues();
+        }
+
+        private void checkSize() {
+            Assertions.assertEquals(expected.size(), set.size(), "Unexpected set size");
+            Assertions.assertEquals(expected.isEmpty(), set.isEmpty(), "Unexpected isEmpty() result");
+        }
+
+        private void checkValues() {
+            Assertions.assertEquals(expected.size(), set.size(), "Unexpected size");
+
+            for (final P value : expected) {
+                Assertions.assertTrue(set.contains(value), () -> "Expected set to contain value " + value);
+                Assertions.assertEquals(value, set.get(value), () -> "Expected set to contain value " + value);
+            }
+        }
+
+        private void checkUnexpectedValues() {
+            for (final P value : unexpected) {
+                Assertions.assertFalse(set.contains(value), () -> "Expected set to not contain value " + value);
+                Assertions.assertNull(set.get(value), () -> "Expected set to not contain value " + value);
+            }
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/AbstractBucketPointMapTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/AbstractBucketPointMapTest.java
new file mode 100644
index 0000000..c61f0d6
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/AbstractBucketPointMapTest.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.core.internal;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.collection.PointMapTestBase;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint1D;
+import org.apache.commons.numbers.core.Precision;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class AbstractBucketPointMapTest extends PointMapTestBase<TestPoint1D> {
+
+    @Test
+    void testPut_pointsCloseToSplit() {
+        // arrange
+        TestBucketPointMap1D<Integer> map = getMap(PRECISION);
+
+        final List<TestPoint1D> pts = createPointList(0, 1, TestBucketPointMap1D.MAX_ENTRY_COUNT);
+        insertPoints(pts, map);
+
+        final TestPoint1D split = centroid(pts);
+
+        final TestPoint1D pt = new TestPoint1D(split.getX() + (1.25 * EPS));
+
+        map.put(pt, 100);
+
+        // act/assert
+        final TestPoint1D close = new TestPoint1D(split.getX() + (0.75 * EPS));
+
+        Assertions.assertEquals(100, map.put(close, 101));
+        Assertions.assertEquals(101, map.get(close));
+        Assertions.assertEquals(101, map.get(pt));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected <V> TestBucketPointMap1D<V> getMap(final Precision.DoubleEquivalence precision) {
+        return new TestBucketPointMap1D<>(precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected TestPoint1D[] createPointArray() {
+        return new TestPoint1D[0];
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getNaNPoints() {
+        return Arrays.asList(new TestPoint1D(Double.NaN));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getInfPoints() {
+        return Arrays.asList(
+                new TestPoint1D(Double.NEGATIVE_INFINITY),
+                new TestPoint1D(Double.POSITIVE_INFINITY));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getTestPoints(final int cnt, final double eps) {
+        final double delta = 10 * eps;
+        return createPointList(-1.0, delta, cnt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getTestPointsAtDistance(final TestPoint1D pt, final double dist) {
+        return Arrays.asList(
+                new TestPoint1D(pt.getX() - dist),
+                new TestPoint1D(pt.getX() + dist));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean eq(final TestPoint1D a, final TestPoint1D b, final Precision.DoubleEquivalence precision) {
+        return precision.eq(a.getX(), b.getX());
+    }
+
+    private static List<TestPoint1D> createPointList(final double start, final double delta, final int cnt) {
+        final List<TestPoint1D> pts = new ArrayList<>(cnt);
+
+        double x = start;
+        for (int i = 0; i < cnt; ++i) {
+            pts.add(new TestPoint1D(x));
+
+            x += delta;
+        }
+
+        return pts;
+    }
+
+    private static TestPoint1D centroid(final List<TestPoint1D> pts) {
+        double sum = 0;
+        for (final TestPoint1D pt : pts) {
+            sum += pt.getX();
+        }
+
+        return new TestPoint1D(sum / pts.size());
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/AbstractPointMap1DTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/AbstractPointMap1DTest.java
new file mode 100644
index 0000000..60166cd
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/AbstractPointMap1DTest.java
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.core.internal;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Set;
+
+import org.apache.commons.geometry.core.collection.PointMapTestBase;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint1D;
+import org.apache.commons.numbers.core.Precision;
+
+class AbstractPointMap1DTest extends PointMapTestBase<TestPoint1D> {
+
+    /** {@inheritDoc} */
+    @Override
+    protected <V> TestPointMap1D<V> getMap(final Precision.DoubleEquivalence precision) {
+        return new TestPointMap1D<>(precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected TestPoint1D[] createPointArray() {
+        return new TestPoint1D[0];
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getNaNPoints() {
+        return Arrays.asList(new TestPoint1D(Double.NaN));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getInfPoints() {
+        return Arrays.asList(
+                new TestPoint1D(Double.NEGATIVE_INFINITY),
+                new TestPoint1D(Double.POSITIVE_INFINITY));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getTestPoints(final int cnt, final double eps) {
+        final double delta = 10 * eps;
+        return createPointList(-1.0, delta, cnt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getTestPointsAtDistance(final TestPoint1D pt, final double dist) {
+        return Arrays.asList(
+                new TestPoint1D(pt.getX() - dist),
+                new TestPoint1D(pt.getX() + dist));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean eq(final TestPoint1D a, final TestPoint1D b, final Precision.DoubleEquivalence precision) {
+        return precision.eq(a.getX(), b.getX());
+    }
+
+    private static List<TestPoint1D> createPointList(final double start, final double delta, final int cnt) {
+        final List<TestPoint1D> pts = new ArrayList<>(cnt);
+
+        double x = start;
+        for (int i = 0; i < cnt; ++i) {
+            pts.add(new TestPoint1D(x));
+
+            x += delta;
+        }
+
+        return pts;
+    }
+
+    private static final class TestPointMap1D<V> extends AbstractPointMap1D<TestPoint1D, V> {
+
+        TestPointMap1D(final Precision.DoubleEquivalence precision) {
+            super((a, b) -> precision.compare(a.getX(), b.getX()));
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean containsKey(final Object key) {
+            return getMap().containsKey(key);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public V get(final Object key) {
+            return getMap().get(key);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public V remove(final Object key) {
+            return getMap().remove(key);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void clear() {
+            getMap().clear();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Set<TestPoint1D> keySet() {
+            return getMap().keySet();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Set<Entry<TestPoint1D, V>> entrySet() {
+            return getMap().entrySet();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected Entry<TestPoint1D, V> getEntryInternal(final TestPoint1D key) {
+            final NavigableMap<TestPoint1D, V> map = getMap();
+            final Map.Entry<TestPoint1D, V> floor = map.floorEntry(key);
+            if (floor != null &&
+                    map.comparator().compare(floor.getKey(), key) == 0) {
+                return floor;
+            }
+            return null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected V putInternal(final TestPoint1D key, final V value) {
+            return getMap().put(key, value);
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/PointMapAsSetAdapterTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/PointMapAsSetAdapterTest.java
new file mode 100644
index 0000000..2fdbae1
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/PointMapAsSetAdapterTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.core.internal;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.collection.PointSet;
+import org.apache.commons.geometry.core.collection.PointSetTestBase;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint1D;
+import org.apache.commons.numbers.core.Precision;
+import org.apache.commons.numbers.core.Precision.DoubleEquivalence;
+
+class PointMapAsSetAdapterTest extends PointSetTestBase<TestPoint1D> {
+
+    /** {@inheritDoc} */
+    @Override
+    protected PointSet<TestPoint1D> getSet(final Precision.DoubleEquivalence precision) {
+        return new PointMapAsSetAdapter<>(new TestBucketPointMap1D<>(precision));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected TestPoint1D[] createPointArray() {
+        return new TestPoint1D[0];
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getNaNPoints() {
+        return Arrays.asList(new TestPoint1D(Double.NaN));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getInfPoints() {
+        return Arrays.asList(
+                new TestPoint1D(Double.NEGATIVE_INFINITY),
+                new TestPoint1D(Double.POSITIVE_INFINITY));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getTestPoints(final int cnt, final double eps) {
+        final double delta = 10 * eps;
+        return createPointList(-1.0, delta, cnt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<TestPoint1D> getTestPointsAtDistance(final TestPoint1D pt, final double dist) {
+        return Arrays.asList(
+                new TestPoint1D(pt.getX() - dist),
+                new TestPoint1D(pt.getX() + dist));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean eq(final TestPoint1D a, final TestPoint1D b, final DoubleEquivalence precision) {
+        return precision.eq(a.getX(), b.getX());
+    }
+
+    private static List<TestPoint1D> createPointList(final double start, final double delta, final int cnt) {
+        final List<TestPoint1D> pts = new ArrayList<>(cnt);
+
+        double x = start;
+        for (int i = 0; i < cnt; ++i) {
+            pts.add(new TestPoint1D(x));
+
+            x += delta;
+        }
+
+        return pts;
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/TestBucketPointMap1D.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/TestBucketPointMap1D.java
new file mode 100644
index 0000000..49d9004
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/TestBucketPointMap1D.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.core.internal;
+
+import java.util.Map;
+
+import org.apache.commons.geometry.core.partitioning.test.TestPoint1D;
+import org.apache.commons.numbers.core.Precision;
+
+/** {@link AbstractBucketPointMap} implementation for use in tests.
+ * @param <V> Value type
+ */
+public class TestBucketPointMap1D<V> extends AbstractBucketPointMap<TestPoint1D, V> {
+
+    static final int MAX_ENTRY_COUNT = 16;
+
+    static final int NODE_CHILD_COUNT = 2;
+
+    TestBucketPointMap1D(final Precision.DoubleEquivalence precision) {
+        super(TestNode1D::new,
+                MAX_ENTRY_COUNT,
+                NODE_CHILD_COUNT,
+                precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean pointsEq(final TestPoint1D a, final TestPoint1D b) {
+        return getPrecision().eq(a.getX(), b.getX());
+    }
+
+    private static final class TestNode1D<V> extends AbstractBucketPointMap.BucketNode<TestPoint1D, V> {
+
+        /** Negative half-space flag. */
+        private static final int NEG = 1 << 1;
+
+        /** Positve half-space flag. */
+        private static final int POS = 1;
+
+        /** Location flags for child nodes. */
+        private static final int[] CHILD_LOCATIONS = {
+            NEG,
+            POS
+        };
+
+        private double split;
+
+        TestNode1D(
+                final AbstractBucketPointMap<TestPoint1D, V> map,
+                final BucketNode<TestPoint1D, V> parent) {
+            super(map, parent);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected void computeSplit() {
+            double sum = 0;
+            for (Map.Entry<TestPoint1D, V> entry : this) {
+                sum += entry.getKey().getX();
+            }
+
+            split = sum / TestBucketPointMap1D.MAX_ENTRY_COUNT;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected int getSearchLocation(final TestPoint1D pt) {
+            return getSearchLocationValue(getPrecision().compare(pt.getX(), split), NEG, POS);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected int getInsertLocation(final TestPoint1D pt) {
+            return getInsertLocationValue(Double.compare(pt.getX(), split), NEG, POS);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected boolean testChildLocation(final int childIdx, final int loc) {
+            final int childLoc = CHILD_LOCATIONS[childIdx];
+            return (childLoc & loc) == childLoc;
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/EuclideanCollections.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/EuclideanCollections.java
new file mode 100644
index 0000000..77e66b7
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/EuclideanCollections.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.euclidean;
+
+import org.apache.commons.geometry.core.collection.PointMap;
+import org.apache.commons.geometry.core.collection.PointSet;
+import org.apache.commons.geometry.core.internal.PointMapAsSetAdapter;
+import org.apache.commons.geometry.euclidean.oned.Vector1D;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+import org.apache.commons.numbers.core.Precision;
+
+/** Class containing utility methods for Euclidean collection types.
+ */
+public final class EuclideanCollections {
+
+    /** No instantiation. */
+    private EuclideanCollections() {}
+
+    /** Construct a new 1D {@link PointSet} instance using the given precision context to determine
+     * equality between points.
+     *
+     * <p>NOTE: The returned instance is <em>not</em> thread-safe.</p>
+     * @param precision precision context used to determine point equality
+     * @return new 1D point set instance
+     */
+    public static PointSet<Vector1D> pointSet1D(final Precision.DoubleEquivalence precision) {
+        return new PointMapAsSetAdapter<>(pointMap1D(precision));
+    }
+
+    /** Construct a new 1D {@link PointMap} instance using the given precision context to determine
+     * equality between points.
+     *
+     * <p>NOTE: The returned instance is <em>not</em> thread-safe.</p>
+     * @param <V> Map value type
+     * @param precision precision context used to determine point equality
+     * @return new 1D point map instance
+     */
+    public static <V> PointMap<Vector1D, V> pointMap1D(final Precision.DoubleEquivalence precision) {
+        return new PointMap1DImpl<>(precision);
+    }
+
+    /** Construct a new 2D {@link PointSet} instance using the given precision context to determine
+     * equality between points.
+     *
+     * <p>NOTE: The returned instance is <em>not</em> thread-safe.</p>
+     * @param precision precision context used to determine point equality
+     * @return new 2D point set instance
+     */
+    public static PointSet<Vector2D> pointSet2D(final Precision.DoubleEquivalence precision) {
+        return new PointMapAsSetAdapter<>(pointMap2D(precision));
+    }
+
+    /** Construct a new 2D {@link PointMap} instance using the given precision context to determine
+     * equality between points.
+     *
+     * <p>NOTE: The returned instance is <em>not</em> thread-safe.</p>
+     * @param <V> Map value type
+     * @param precision precision context used to determine point equality
+     * @return new 2D point map instance
+     */
+    public static <V> PointMap<Vector2D, V> pointMap2D(final Precision.DoubleEquivalence precision) {
+        return new PointMap2DImpl<>(precision);
+    }
+
+    /** Construct a new 3D {@link PointSet} instance using the given precision context to determine
+     * equality between points.
+     *
+     * <p>NOTE: The returned instance is <em>not</em> thread-safe.</p>
+     * @param precision precision context used to determine point equality
+     * @return new 3D point set instance
+     */
+    public static PointSet<Vector3D> pointSet3D(final Precision.DoubleEquivalence precision) {
+        return new PointMapAsSetAdapter<>(pointMap3D(precision));
+    }
+
+    /** Construct a new 3D {@link PointMap} instance using the given precision context to determine
+     * equality between points.
+     *
+     * <p>NOTE: The returned instance is <em>not</em> thread-safe.</p>
+     * @param <V> Map value type
+     * @param precision precision context used to determine point equality
+     * @return new 3D point map instance
+     */
+    public static <V> PointMap<Vector3D, V> pointMap3D(final Precision.DoubleEquivalence precision) {
+        return new PointMap3DImpl<>(precision);
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/PointMap1DImpl.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/PointMap1DImpl.java
new file mode 100644
index 0000000..1525d1a
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/PointMap1DImpl.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.euclidean;
+
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Set;
+
+import org.apache.commons.geometry.core.internal.AbstractPointMap1D;
+import org.apache.commons.geometry.euclidean.oned.Vector1D;
+import org.apache.commons.numbers.core.Precision;
+
+/** Internal {@link org.apache.commons.geometry.core.collection.PointMap PointMap}
+ * implementation for Euclidean 1D space.
+ * @param <V> Map value type
+ */
+final class PointMap1DImpl<V>
+    extends AbstractPointMap1D<Vector1D, V> {
+
+    /** Construct a new instance using the given precision context to determine
+     * floating point equality.
+     * @param precision precision context
+     */
+    PointMap1DImpl(final Precision.DoubleEquivalence precision) {
+        super((a, b) -> precision.compare(a.getX(), b.getX()));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean containsKey(final Object key) {
+        return getMap().containsKey(key);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V get(final Object key) {
+        return getMap().get(key);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V remove(final Object key) {
+        return getMap().remove(key);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void clear() {
+        getMap().clear();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<Vector1D> keySet() {
+        return getMap().keySet();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<Entry<Vector1D, V>> entrySet() {
+        return getMap().entrySet();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected Map.Entry<Vector1D, V> getEntryInternal(final Vector1D key) {
+        final NavigableMap<Vector1D, V> map = getMap();
+        final Map.Entry<Vector1D, V> floor = map.floorEntry(key);
+        if (floor != null &&
+                map.comparator().compare(floor.getKey(), key) == 0) {
+            return floor;
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected V putInternal(final Vector1D key, final V value) {
+        return getMap().put(key, value);
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/PointMap2DImpl.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/PointMap2DImpl.java
new file mode 100644
index 0000000..472d6ca
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/PointMap2DImpl.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.euclidean;
+
+import java.util.List;
+
+import org.apache.commons.geometry.core.collection.PointMap;
+import org.apache.commons.geometry.core.internal.AbstractBucketPointMap;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+import org.apache.commons.numbers.core.Precision;
+
+/** Internal {@link PointMap} implementation for Euclidean 2D space.
+ * @param <V> Map value type
+ */
+final class PointMap2DImpl<V>
+    extends AbstractBucketPointMap<Vector2D, V>
+    implements PointMap<Vector2D, V> {
+
+    /** Number of children per node. */
+    private static final int NODE_CHILD_COUNT = 4;
+
+    /** Max entries per node. */
+    private static final int MAX_ENTRIES_PER_NODE = 16;
+
+    /** X negative quadrant flag. */
+    private static final int XNEG = 1 << 3;
+
+    /** X postive quadrant flag. */
+    private static final int XPOS = 1 << 2;
+
+    /** Y negative quadrant flag. */
+    private static final int YNEG = 1 << 1;
+
+    /** Y positive quadrant flag. */
+    private static final int YPOS = 1;
+
+    /** Quadtree location flags for child nodes. */
+    private static final int[] CHILD_LOCATIONS = {
+        XNEG | YNEG,
+        XNEG | YPOS,
+        XPOS | YNEG,
+        XPOS | YPOS
+    };
+
+    /** Construct a new instance using the given precision context to determine
+     * floating point equality.
+     * @param precision precision context
+     */
+    PointMap2DImpl(final Precision.DoubleEquivalence precision) {
+        super(MapNode2D::new,
+                MAX_ENTRIES_PER_NODE,
+                NODE_CHILD_COUNT,
+                precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean pointsEq(final Vector2D a, final Vector2D b) {
+        return a.eq(b, getPrecision());
+    }
+
+    /** Tree node class for {@link PointMap2DImpl}.
+     * @param <V> Map value type
+     */
+    private static final class MapNode2D<V> extends BucketNode<Vector2D, V> {
+
+        /** Point to split child spaces; will be null for leaf nodes. */
+        private Vector2D split;
+
+        /** Construct a new instance.
+         * @param map owning map
+         * @param parent parent node; set to null for the root node
+         */
+        MapNode2D(final AbstractBucketPointMap<Vector2D, V> map,
+                final BucketNode<Vector2D, V> parent) {
+            super(map, parent);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected int getSearchLocation(final Vector2D pt) {
+            final Precision.DoubleEquivalence precision = getPrecision();
+
+            int loc = getSearchLocationValue(
+                    precision.compare(pt.getX(), split.getX()),
+                    XNEG,
+                    XPOS);
+            loc |= getSearchLocationValue(
+                    precision.compare(pt.getY(), split.getY()),
+                    YNEG,
+                    YPOS);
+
+            return loc;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected int getInsertLocation(final Vector2D pt) {
+            int loc = getInsertLocationValue(
+                    Double.compare(pt.getX(), split.getX()),
+                    XNEG,
+                    XPOS);
+            loc |= getInsertLocationValue(
+                    Double.compare(pt.getY(), split.getY()),
+                    YNEG,
+                    YPOS);
+
+            return loc;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected void computeSplit() {
+            final Vector2D.Sum sum = Vector2D.Sum.create();
+            for (final Entry<Vector2D, V> entry : this) {
+                sum.add(entry.getKey());
+            }
+
+            split = sum.get().multiply(1.0 / MAX_ENTRIES_PER_NODE);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected boolean testChildLocation(final int childIdx, final int loc) {
+            final int childLoc = CHILD_LOCATIONS[childIdx];
+            return (childLoc & loc) == childLoc;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected void makeLeaf(final List<Entry<Vector2D, V>> leafEntries) {
+            super.makeLeaf(leafEntries);
+
+            split = null;
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/PointMap3DImpl.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/PointMap3DImpl.java
new file mode 100644
index 0000000..eae192a
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/PointMap3DImpl.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.euclidean;
+
+import java.util.List;
+
+import org.apache.commons.geometry.core.collection.PointMap;
+import org.apache.commons.geometry.core.internal.AbstractBucketPointMap;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.numbers.core.Precision;
+
+/** Internal {@link PointMap} implementation for Euclidean 3D space.
+ * @param <V> Map value type
+ */
+final class PointMap3DImpl<V>
+    extends AbstractBucketPointMap<Vector3D, V>
+    implements PointMap<Vector3D, V> {
+
+    /** Number of children per node. */
+    private static final int NODE_CHILD_COUNT = 8;
+
+    /** Max entries per node. This value was determined empirically and was chosen to
+     * provide a balance between having a small number of entries in each node when
+     * searching and having a large number of samples to provide a good split point
+     * during insertion. See the {@code org.apache.commons.geometry.examples.jmh.euclidean.PointMap3DPerformance}
+     * class in the {@code examples-jmh} module for details on the performance tests used.
+     */
+    private static final int MAX_ENTRIES_PER_NODE = 32;
+
+    /** X negative octant flag. */
+    private static final int XNEG = 1 << 5;
+
+    /** X postive octant flag. */
+    private static final int XPOS = 1 << 4;
+
+    /** Y negative octant flag. */
+    private static final int YNEG = 1 << 3;
+
+    /** Y positive octant flag. */
+    private static final int YPOS = 1 << 2;
+
+    /** Z negative octant flag. */
+    private static final int ZNEG = 1 << 1;
+
+    /** Z positive octant flag. */
+    private static final int ZPOS = 1;
+
+    /** Octant location flags for child nodes. */
+    private static final int[] CHILD_LOCATIONS = {
+        XNEG | YNEG | ZNEG,
+        XNEG | YNEG | ZPOS,
+        XNEG | YPOS | ZNEG,
+        XNEG | YPOS | ZPOS,
+
+        XPOS | YNEG | ZNEG,
+        XPOS | YNEG | ZPOS,
+        XPOS | YPOS | ZNEG,
+        XPOS | YPOS | ZPOS
+    };
+
+    /** Construct a new instance using the given precision context to determine
+     * floating point equality.
+     * @param precision precision context
+     */
+    PointMap3DImpl(final Precision.DoubleEquivalence precision) {
+        super(MapNode3D::new,
+                MAX_ENTRIES_PER_NODE,
+                NODE_CHILD_COUNT,
+                precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean pointsEq(final Vector3D a, final Vector3D b) {
+        return a.eq(b, getPrecision());
+    }
+
+    /** Tree node class for {@link PointMap3DImpl}.
+     * @param <V> Map value type
+     */
+    private static final class MapNode3D<V> extends BucketNode<Vector3D, V> {
+
+        /** Point to split child spaces; will be null for leaf nodes. */
+        private Vector3D split;
+
+        /** Construct a new instance.
+         * @param map owning map
+         * @param parent parent node; set to null for the root node
+         */
+        MapNode3D(final AbstractBucketPointMap<Vector3D, V> map,
+                final BucketNode<Vector3D, V> parent) {
+            super(map, parent);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected void computeSplit() {
+            final Vector3D.Sum sum = Vector3D.Sum.create();
+            for (final Entry<Vector3D, V> entry : this) {
+                sum.add(entry.getKey());
+            }
+
+            split = sum.get().multiply(1.0 / MAX_ENTRIES_PER_NODE);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected int getSearchLocation(final Vector3D pt) {
+            final Precision.DoubleEquivalence precision = getPrecision();
+
+            int loc = getSearchLocationValue(
+                    precision.compare(pt.getX(), split.getX()),
+                    XNEG,
+                    XPOS);
+            loc |= getSearchLocationValue(
+                    precision.compare(pt.getY(), split.getY()),
+                    YNEG,
+                    YPOS);
+            loc |= getSearchLocationValue(
+                    precision.compare(pt.getZ(), split.getZ()),
+                    ZNEG,
+                    ZPOS);
+
+            return loc;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected int getInsertLocation(final Vector3D pt) {
+            int loc = getInsertLocationValue(
+                    Double.compare(pt.getX(), split.getX()),
+                    XNEG,
+                    XPOS);
+            loc |= getInsertLocationValue(
+                    Double.compare(pt.getY(), split.getY()),
+                    YNEG,
+                    YPOS);
+            loc |= getInsertLocationValue(
+                    Double.compare(pt.getZ(), split.getZ()),
+                    ZNEG,
+                    ZPOS);
+
+            return loc;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected boolean testChildLocation(final int childIdx, final int loc) {
+            final int childLoc = CHILD_LOCATIONS[childIdx];
+            return (childLoc & loc) == childLoc;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected void makeLeaf(final List<Entry<Vector3D, V>> leafEntries) {
+            super.makeLeaf(leafEntries);
+
+            split = null;
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/SimpleTriangleMesh.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/SimpleTriangleMesh.java
index 409e8ad..d85476d 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/SimpleTriangleMesh.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/SimpleTriangleMesh.java
@@ -20,18 +20,17 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Objects;
-import java.util.TreeMap;
 import java.util.function.Function;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 
 import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.collection.PointMap;
+import org.apache.commons.geometry.euclidean.EuclideanCollections;
 import org.apache.commons.geometry.euclidean.internal.EuclideanUtils;
 import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
 import org.apache.commons.geometry.euclidean.threed.Bounds3D;
@@ -417,7 +416,7 @@ public final class SimpleTriangleMesh implements TriangleMesh {
         private final ArrayList<Vector3D> vertices = new ArrayList<>();
 
         /** Map of vertices to their first occurrence in the vertex list. */
-        private Map<Vector3D, Integer> vertexIndexMap;
+        private PointMap<Vector3D, Integer> vertexIndexMap;
 
         /** List of face vertex indices. */
         private final ArrayList<int[]> faces = new ArrayList<>();
@@ -674,9 +673,9 @@ public final class SimpleTriangleMesh implements TriangleMesh {
         /** Get the vertex index map, creating and initializing it if needed.
          * @return the vertex index map
          */
-        private Map<Vector3D, Integer> getVertexIndexMap() {
+        private PointMap<Vector3D, Integer> getVertexIndexMap() {
             if (vertexIndexMap == null) {
-                vertexIndexMap = new TreeMap<>(new FuzzyVectorComparator(precision));
+                vertexIndexMap = EuclideanCollections.pointMap3D(precision);
 
                 // populate the index map
                 final int size = vertices.size();
@@ -697,7 +696,7 @@ public final class SimpleTriangleMesh implements TriangleMesh {
          * @return the index now associated with the given vertex or its equivalent
          */
         private int addToVertexIndexMap(final Vector3D vertex, final int targetIdx,
-                final Map<? super Vector3D, Integer> map) {
+                final PointMap<Vector3D, Integer> map) {
             validateCanModify();
 
             final Integer actualIdx = map.putIfAbsent(vertex, targetIdx);
@@ -745,35 +744,4 @@ public final class SimpleTriangleMesh implements TriangleMesh {
             }
         }
     }
-
-    /** Comparator used to sort vectors using non-strict ("fuzzy") comparisons.
-     * Vectors are considered equal if their values in all coordinate dimensions
-     * are equivalent as evaluated by the precision context.
-     */
-    private static final class FuzzyVectorComparator implements Comparator<Vector3D> {
-        /** Precision context to determine floating-point equality. */
-        private final Precision.DoubleEquivalence precision;
-
-        /** Construct a new instance that uses the given precision context for
-         * floating point comparisons.
-         * @param precision precision context used for floating point comparisons
-         */
-        FuzzyVectorComparator(final Precision.DoubleEquivalence precision) {
-            this.precision = precision;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public int compare(final Vector3D a, final Vector3D b) {
-            int result = precision.compare(a.getX(), b.getX());
-            if (result == 0) {
-                result = precision.compare(a.getY(), b.getY());
-                if (result == 0) {
-                    result = precision.compare(a.getZ(), b.getZ());
-                }
-            }
-
-            return result;
-        }
-    }
 }
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/DocumentationExamplesTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/DocumentationExamplesTest.java
index e87848d..5fe389c 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/DocumentationExamplesTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/DocumentationExamplesTest.java
@@ -21,6 +21,7 @@ import java.util.List;
 import java.util.stream.Collectors;
 
 import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.collection.PointMap;
 import org.apache.commons.geometry.core.partitioning.Split;
 import org.apache.commons.geometry.core.partitioning.bsp.RegionCutRule;
 import org.apache.commons.geometry.euclidean.oned.Interval;
@@ -441,4 +442,23 @@ class DocumentationExamplesTest {
         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0), intersection, TEST_EPS);
         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, -1), normal, TEST_EPS);
     }
+
+    @Test
+    void testPointMap3DExample() {
+        final Precision.DoubleEquivalence precision = Precision.doubleEquivalenceOfEpsilon(1e-6);
+
+        final PointMap<Vector3D, String> map = EuclideanCollections.pointMap3D(precision);
+        map.put(Vector3D.ZERO, "a");
+        map.put(Vector3D.Unit.PLUS_X, "b");
+
+        final String originValue = map.get(Vector3D.of(1e-8, 1e-8, -1e-8)); // originValue = "a"
+        final String plusXValue = map.get(Vector3D.of(1, 0, 1e-8)); // plusXValue = "b"
+
+        final String missingValue = map.get(Vector3D.of(1e-5, 0, 0)); // missingValue = null
+
+        // ---------------------
+        Assertions.assertEquals("a", originValue);
+        Assertions.assertEquals("b", plusXValue);
+        Assertions.assertNull(missingValue);
+    }
 }
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/PointMap1DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/PointMap1DTest.java
new file mode 100644
index 0000000..683b7ce
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/PointMap1DTest.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.euclidean.oned;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.collection.PointMap;
+import org.apache.commons.geometry.core.collection.PointMapTestBase;
+import org.apache.commons.geometry.euclidean.EuclideanCollections;
+import org.apache.commons.numbers.core.Precision;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class PointMap1DTest extends PointMapTestBase<Vector1D> {
+
+    @Test
+    void testDenseLine() {
+        // arrange
+        final PointMap<Vector1D, Integer> map = getMap(PRECISION);
+
+        final double step = 1.1 * EPS;
+        final double start = -1.0;
+        final int cnt = 1_000_000;
+
+        // act
+        double x = start;
+        for (int i = 0; i < cnt; ++i) {
+            map.put(Vector1D.of(x), 0);
+
+            x += step;
+        }
+
+        // act
+        assertEquals(cnt, map.size());
+
+        final double offset = 0.9 * EPS;
+        x = start;
+        for (int i = 0; i < cnt; ++i) {
+            Assertions.assertEquals(0, map.get(Vector1D.of(x + offset)));
+
+            x += step;
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected <V> PointMap<Vector1D, V> getMap(final Precision.DoubleEquivalence precision) {
+        return EuclideanCollections.pointMap1D(precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected Vector1D[] createPointArray() {
+        return new Vector1D[0];
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector1D> getNaNPoints() {
+        return Collections.singletonList(Vector1D.NaN);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector1D> getInfPoints() {
+        return Arrays.asList(
+                Vector1D.NEGATIVE_INFINITY,
+                Vector1D.POSITIVE_INFINITY);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector1D> getTestPoints(final int cnt, final double eps) {
+        final List<Vector1D> pts = new ArrayList<>(cnt);
+
+        final double delta = 10 * eps;
+
+        double x = 0.0;
+        for (int i = 0; i < cnt; ++i) {
+            pts.add(Vector1D.of(x));
+
+            x += delta;
+        }
+
+        return pts;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector1D> getTestPointsAtDistance(final Vector1D pt, final double dist) {
+        return Arrays.asList(
+                Vector1D.of(pt.getX() - dist),
+                Vector1D.of(pt.getX() + dist));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean eq(final Vector1D a, final Vector1D b, final Precision.DoubleEquivalence precision) {
+        return a.eq(b, precision);
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/PointSet1DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/PointSet1DTest.java
new file mode 100644
index 0000000..b57e0e8
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/PointSet1DTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.euclidean.oned;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.collection.PointSet;
+import org.apache.commons.geometry.core.collection.PointSetTestBase;
+import org.apache.commons.geometry.euclidean.EuclideanCollections;
+import org.apache.commons.numbers.core.Precision;
+
+class PointSet1DTest extends PointSetTestBase<Vector1D> {
+
+    /** {@inheritDoc} */
+    @Override
+    protected PointSet<Vector1D> getSet(final Precision.DoubleEquivalence precision) {
+        return EuclideanCollections.pointSet1D(precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected Vector1D[] createPointArray() {
+        return new Vector1D[0];
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector1D> getNaNPoints() {
+        return Collections.singletonList(Vector1D.NaN);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector1D> getInfPoints() {
+        return Arrays.asList(
+                Vector1D.NEGATIVE_INFINITY,
+                Vector1D.POSITIVE_INFINITY);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector1D> getTestPoints(final int cnt, final double eps) {
+        final List<Vector1D> pts = new ArrayList<>(cnt);
+
+        final double delta = 10 * eps;
+
+        double x = 0.0;
+        for (int i = 0; i < cnt; ++i) {
+            pts.add(Vector1D.of(x));
+
+            x += delta;
+        }
+
+        return pts;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector1D> getTestPointsAtDistance(final Vector1D pt, final double dist) {
+        return Arrays.asList(
+                Vector1D.of(pt.getX() - dist),
+                Vector1D.of(pt.getX() + dist));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean eq(final Vector1D a, final Vector1D b, final Precision.DoubleEquivalence precision) {
+        return a.eq(b, precision);
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PointMap3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PointMap3DTest.java
new file mode 100644
index 0000000..37e3b1d
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PointMap3DTest.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.euclidean.threed;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.collection.PointMap;
+import org.apache.commons.geometry.core.collection.PointMapTestBase;
+import org.apache.commons.geometry.euclidean.EuclideanCollections;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.numbers.core.Precision;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class PointMap3DTest extends PointMapTestBase<Vector3D> {
+
+    @Test
+    void testDenseGrid() {
+        // arrange
+        final PointMap<Vector3D, Integer> map = getMap(PRECISION);
+
+        final double step = 3 * EPS;
+        final int stepsPerHalfSide = 50;
+        final double max = step * stepsPerHalfSide;
+        final int sideLength = (2 * stepsPerHalfSide) + 1;
+
+        // act
+        EuclideanTestUtils.permute(-max, max, step,
+                (x, y, z) -> map.put(Vector3D.of(x, y, z), 0));
+
+        // act
+        assertEquals(sideLength * sideLength * sideLength, map.size());
+
+        final double offset = 0.9 * EPS;
+        EuclideanTestUtils.permute(-max, max, step, (x, y, z) -> {
+            Assertions.assertEquals(0, map.get(Vector3D.of(x + offset, y + offset, z + offset)));
+        });
+    }
+
+    @Test
+    void testDenseLine() {
+        // arrange
+        final PointMap<Vector3D, Integer> map = getMap(PRECISION);
+
+        final double step = 1.1 * EPS;
+        final double start = -1.0;
+        final int cnt = 10_000;
+
+        // act
+        double x = start;
+        for (int i = 0; i < cnt; ++i) {
+            map.put(Vector3D.of(x, 0, 0), 0);
+
+            x += step;
+        }
+
+        // act
+        assertEquals(cnt, map.size());
+
+        final double offset = 0.9 * EPS;
+        x = start;
+        for (int i = 0; i < cnt; ++i) {
+            Assertions.assertEquals(0, map.get(Vector3D.of(x + offset, 0, 0)));
+
+            x += step;
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected <V> PointMap<Vector3D, V> getMap(final Precision.DoubleEquivalence precision) {
+        return EuclideanCollections.pointMap3D(precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected Vector3D[] createPointArray() {
+        return new Vector3D[0];
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector3D> getNaNPoints() {
+        return Arrays.asList(
+                Vector3D.NaN,
+                Vector3D.of(Double.NaN, 0, 0),
+                Vector3D.of(0, Double.NaN, 0),
+                Vector3D.of(0, 0, Double.NaN));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector3D> getInfPoints() {
+        return Arrays.asList(
+                Vector3D.NEGATIVE_INFINITY,
+                Vector3D.POSITIVE_INFINITY,
+
+                Vector3D.of(Double.NEGATIVE_INFINITY, 0, 0),
+                Vector3D.of(0, Double.NEGATIVE_INFINITY, 0),
+                Vector3D.of(0, 0, Double.NEGATIVE_INFINITY),
+
+                Vector3D.of(Double.POSITIVE_INFINITY, 0, 0),
+                Vector3D.of(0, Double.POSITIVE_INFINITY, 0),
+                Vector3D.of(0, 0, Double.POSITIVE_INFINITY));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector3D> getTestPoints(final int cnt, final double eps) {
+        final List<Vector3D> pts = new ArrayList<>(cnt);
+
+        final double delta = 10 * eps;
+
+        double x = 0.0;
+        double y = 0.0;
+        double z = 0.0;
+        for (int i = 0; i < cnt; ++i) {
+
+            pts.add(Vector3D.of(x, y, z));
+
+            final int m = i % 3;
+            if (m == 0) {
+                x += delta;
+            } else if (m == 1) {
+                y += delta;
+            } else {
+                z += delta;
+            }
+        }
+
+        return pts;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector3D> getTestPointsAtDistance(final Vector3D pt, final double dist) {
+        final double x = pt.getX();
+        final double y = pt.getY();
+        final double z = pt.getZ();
+
+        return Arrays.asList(
+                Vector3D.of(x - dist, y, z),
+                Vector3D.of(x + dist, y, z),
+
+                Vector3D.of(x, y - dist, z),
+                Vector3D.of(x, y + dist, z),
+
+                Vector3D.of(x, y, z - dist),
+                Vector3D.of(x, y, z + dist));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean eq(final Vector3D a, final Vector3D b, final Precision.DoubleEquivalence precision) {
+        return a.eq(b, precision);
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PointSet3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PointSet3DTest.java
new file mode 100644
index 0000000..e6bf890
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PointSet3DTest.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.euclidean.threed;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.collection.PointSet;
+import org.apache.commons.geometry.core.collection.PointSetTestBase;
+import org.apache.commons.geometry.euclidean.EuclideanCollections;
+import org.apache.commons.numbers.core.Precision;
+
+class PointSet3DTest extends PointSetTestBase<Vector3D> {
+
+    /** {@inheritDoc} */
+    @Override
+    protected PointSet<Vector3D> getSet(final Precision.DoubleEquivalence precision) {
+        return EuclideanCollections.pointSet3D(precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected Vector3D[] createPointArray() {
+        return new Vector3D[0];
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector3D> getNaNPoints() {
+        return Arrays.asList(
+                Vector3D.NaN,
+                Vector3D.of(Double.NaN, 0, 0),
+                Vector3D.of(0, Double.NaN, 0),
+                Vector3D.of(0, 0, Double.NaN));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector3D> getInfPoints() {
+        return Arrays.asList(
+                Vector3D.NEGATIVE_INFINITY,
+                Vector3D.POSITIVE_INFINITY,
+
+                Vector3D.of(Double.NEGATIVE_INFINITY, 0, 0),
+                Vector3D.of(0, Double.NEGATIVE_INFINITY, 0),
+                Vector3D.of(0, 0, Double.NEGATIVE_INFINITY),
+
+                Vector3D.of(Double.POSITIVE_INFINITY, 0, 0),
+                Vector3D.of(0, Double.POSITIVE_INFINITY, 0),
+                Vector3D.of(0, 0, Double.POSITIVE_INFINITY));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector3D> getTestPoints(final int cnt, final double eps) {
+        final List<Vector3D> pts = new ArrayList<>(cnt);
+
+        final double delta = 10 * eps;
+
+        double x = 0.0;
+        double y = 0.0;
+        double z = 0.0;
+        for (int i = 0; i < cnt; ++i) {
+
+            pts.add(Vector3D.of(x, y, z));
+
+            final int m = i % 3;
+            if (m == 0) {
+                x += delta;
+            } else if (m == 1) {
+                y += delta;
+            } else {
+                z += delta;
+            }
+        }
+
+        return pts;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector3D> getTestPointsAtDistance(final Vector3D pt, final double dist) {
+        final double x = pt.getX();
+        final double y = pt.getY();
+        final double z = pt.getZ();
+
+        return Arrays.asList(
+                Vector3D.of(x - dist, y, z),
+                Vector3D.of(x + dist, y, z),
+
+                Vector3D.of(x, y - dist, z),
+                Vector3D.of(x, y + dist, z),
+
+                Vector3D.of(x, y, z - dist),
+                Vector3D.of(x, y, z + dist));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean eq(final Vector3D a, final Vector3D b, final Precision.DoubleEquivalence precision) {
+        return a.eq(b, precision);
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PointMap2DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PointMap2DTest.java
new file mode 100644
index 0000000..0a06826
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PointMap2DTest.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.euclidean.twod;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.collection.PointMap;
+import org.apache.commons.geometry.core.collection.PointMapTestBase;
+import org.apache.commons.geometry.euclidean.EuclideanCollections;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.numbers.core.Precision;
+import org.apache.commons.numbers.core.Precision.DoubleEquivalence;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class PointMap2DTest extends PointMapTestBase<Vector2D> {
+
+    @Test
+    void testDenseGrid() {
+        // arrange
+        final PointMap<Vector2D, Integer> map = getMap(PRECISION);
+
+        final double step = 3 * EPS;
+        final int stepsPerHalfSide = 100;
+        final double max = step * stepsPerHalfSide;
+        final int sideLength = (2 * stepsPerHalfSide) + 1;
+
+        // act
+        EuclideanTestUtils.permute(-max, max, step,
+                (x, y, z) -> map.put(Vector2D.of(x, y), 0));
+
+        // act
+        assertEquals(sideLength * sideLength, map.size());
+
+        final double offset = 0.9 * EPS;
+        EuclideanTestUtils.permute(-max, max, step, (x, y) -> {
+            Assertions.assertEquals(0, map.get(Vector2D.of(x + offset, y + offset)));
+        });
+    }
+
+    @Test
+    void testDenseLine() {
+        // arrange
+        final PointMap<Vector2D, Integer> map = getMap(PRECISION);
+
+        final double step = 1.1 * EPS;
+        final double start = -1.0;
+        final int cnt = 10_000;
+
+        // act
+        double x = start;
+        for (int i = 0; i < cnt; ++i) {
+            map.put(Vector2D.of(x, 0), 0);
+
+            x += step;
+        }
+
+        // act
+        assertEquals(cnt, map.size());
+
+        final double offset = 0.9 * EPS;
+        x = start;
+        for (int i = 0; i < cnt; ++i) {
+            Assertions.assertEquals(0, map.get(Vector2D.of(x + offset, 0)));
+
+            x += step;
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected <V> PointMap<Vector2D, V> getMap(final DoubleEquivalence precision) {
+        return EuclideanCollections.pointMap2D(precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected Vector2D[] createPointArray() {
+        return new Vector2D[0];
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector2D> getNaNPoints() {
+        return Arrays.asList(
+                Vector2D.NaN,
+                Vector2D.of(Double.NaN, 0),
+                Vector2D.of(0, Double.NaN));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector2D> getInfPoints() {
+        return Arrays.asList(
+                Vector2D.NEGATIVE_INFINITY,
+                Vector2D.POSITIVE_INFINITY,
+
+                Vector2D.of(Double.NEGATIVE_INFINITY, 0),
+                Vector2D.of(0, Double.NEGATIVE_INFINITY),
+
+                Vector2D.of(Double.POSITIVE_INFINITY, 0),
+                Vector2D.of(0, Double.POSITIVE_INFINITY));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector2D> getTestPoints(final int cnt, final double eps) {
+        final List<Vector2D> pts = new ArrayList<>(cnt);
+
+        final double delta = 10 * eps;
+
+        double x = 0.0;
+        double y = 0.0;
+        for (int i = 0; i < cnt; ++i) {
+
+            pts.add(Vector2D.of(x, y));
+
+            final int m = i % 2;
+            if (m == 0) {
+                x += delta;
+            } else {
+                y += delta;
+            }
+        }
+
+        return pts;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector2D> getTestPointsAtDistance(final Vector2D pt, final double dist) {
+        final double x = pt.getX();
+        final double y = pt.getY();
+
+        return Arrays.asList(
+                Vector2D.of(x - dist, y),
+                Vector2D.of(x + dist, y),
+
+                Vector2D.of(x, y - dist),
+                Vector2D.of(x, y + dist));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean eq(final Vector2D a, final Vector2D b, final Precision.DoubleEquivalence precision) {
+        return a.eq(b, precision);
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PointSet2DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PointSet2DTest.java
new file mode 100644
index 0000000..de79314
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PointSet2DTest.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.euclidean.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.collection.PointSet;
+import org.apache.commons.geometry.core.collection.PointSetTestBase;
+import org.apache.commons.geometry.euclidean.EuclideanCollections;
+import org.apache.commons.numbers.core.Precision;
+import org.apache.commons.numbers.core.Precision.DoubleEquivalence;
+
+class PointSet2DTest extends PointSetTestBase<Vector2D> {
+
+    /** {@inheritDoc} */
+    @Override
+    protected PointSet<Vector2D> getSet(final DoubleEquivalence precision) {
+        return EuclideanCollections.pointSet2D(precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected Vector2D[] createPointArray() {
+        return new Vector2D[0];
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector2D> getNaNPoints() {
+        return Arrays.asList(
+                Vector2D.NaN,
+                Vector2D.of(Double.NaN, 0),
+                Vector2D.of(0, Double.NaN));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector2D> getInfPoints() {
+        return Arrays.asList(
+                Vector2D.NEGATIVE_INFINITY,
+                Vector2D.POSITIVE_INFINITY,
+
+                Vector2D.of(Double.NEGATIVE_INFINITY, 0),
+                Vector2D.of(0, Double.NEGATIVE_INFINITY),
+
+                Vector2D.of(Double.POSITIVE_INFINITY, 0),
+                Vector2D.of(0, Double.POSITIVE_INFINITY));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector2D> getTestPoints(final int cnt, final double eps) {
+        final List<Vector2D> pts = new ArrayList<>(cnt);
+
+        final double delta = 10 * eps;
+
+        double x = 0.0;
+        double y = 0.0;
+        for (int i = 0; i < cnt; ++i) {
+
+            pts.add(Vector2D.of(x, y));
+
+            final int m = i % 2;
+            if (m == 0) {
+                x += delta;
+            } else {
+                y += delta;
+            }
+        }
+
+        return pts;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected List<Vector2D> getTestPointsAtDistance(final Vector2D pt, final double dist) {
+        final double x = pt.getX();
+        final double y = pt.getY();
+
+        return Arrays.asList(
+                Vector2D.of(x - dist, y),
+                Vector2D.of(x + dist, y),
+
+                Vector2D.of(x, y - dist),
+                Vector2D.of(x, y + dist));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean eq(final Vector2D a, final Vector2D b, final Precision.DoubleEquivalence precision) {
+        return a.eq(b, precision);
+    }
+}
diff --git a/commons-geometry-examples/examples-jmh/pom.xml b/commons-geometry-examples/examples-jmh/pom.xml
index 2e02769..c50ea4e 100644
--- a/commons-geometry-examples/examples-jmh/pom.xml
+++ b/commons-geometry-examples/examples-jmh/pom.xml
@@ -92,6 +92,15 @@
       <artifactId>junit-jupiter</artifactId>
       <scope>test</scope>
     </dependency>
+
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-geometry-euclidean</artifactId>
+      <version>${project.version}</version>
+      <classifier>tests</classifier>
+      <type>test-jar</type>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <profiles>
diff --git a/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/PointMap3DPerformance.java b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/PointMap3DPerformance.java
new file mode 100644
index 0000000..01d3802
--- /dev/null
+++ b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/PointMap3DPerformance.java
@@ -0,0 +1,339 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.examples.jmh.euclidean;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.geometry.euclidean.EuclideanCollections;
+import org.apache.commons.geometry.euclidean.threed.Bounds3D;
+import org.apache.commons.geometry.euclidean.threed.SphericalCoordinates;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.numbers.angle.Angle;
+import org.apache.commons.numbers.core.Precision;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/** Benchmarks for the 3D Euclidean
+ * {@link org.apache.commons.geometry.core.collection.PointMap PointMap} implementation.
+ */
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 1, jvmArgs = {"-server", "-Xms512M", "-Xmx512M"})
+public class PointMap3DPerformance {
+
+    /** Precision context. */
+    private static final Precision.DoubleEquivalence PRECISION =
+            Precision.doubleEquivalenceOfEpsilon(1e-6);
+
+    /** Value inserted into maps during runs. */
+    private static final Integer VAL = Integer.valueOf(1);
+
+    /** Base input class for point map benchmarks. */
+    @State(Scope.Thread)
+    public static class PointMapInput {
+
+        /** Data structure implementation. */
+        @Param({"treemap", "pointmap3d"})
+        private String impl;
+
+        /** Point list shape. */
+        @Param({"block", "line", "sphere"})
+        private String shape;
+
+        /** Point distribution. */
+        @Param({"none", "random", "ordered"})
+        private String dist;
+
+        /** Seed value for randomization. */
+        @Param({"1"})
+        private int randomSeed;
+
+        /** Map instance for the run. */
+        private Map<Vector3D, Integer> map;
+
+        /** List of points for the run. */
+        private List<Vector3D> points;
+
+        /** Random instance. */
+        private Random random;
+
+        /** Set up the instance for the benchmark. */
+        @Setup(Level.Iteration)
+        public void setup() {
+            random = new Random(randomSeed);
+
+            map = createMap();
+            points = createPoints();
+
+            switch (dist) {
+            case "none":
+                break;
+            case "random":
+                Collections.shuffle(points, random);
+                break;
+            case "ordered":
+                Collections.sort(points, Vector3D.COORDINATE_ASCENDING_ORDER);
+                break;
+            default:
+                throw new IllegalArgumentException("Unknown distribution: " + dist);
+            }
+        }
+
+        /** Get the map instance under test.
+         * @return map instance
+         */
+        public Map<Vector3D, Integer> getMap() {
+            return map;
+        }
+
+        /** Get the points for the run.
+         * @return list of points
+         */
+        public List<Vector3D> getPoints() {
+            return points;
+        }
+
+        /** Get the random number generator for the instance.
+         * @return random number generate
+         */
+        public Random getRandom() {
+            return random;
+        }
+
+        /** Create the map implementation for the run.
+         * @return map instance
+         */
+        private Map<Vector3D, Integer> createMap() {
+            switch (impl.toLowerCase()) {
+            case "treemap":
+                return new TreeMap<>((a, b) -> {
+                    int cmp = PRECISION.compare(a.getX(), b.getX());
+                    if (cmp == 0) {
+                        cmp = PRECISION.compare(a.getY(), b.getY());
+                        if (cmp == 0) {
+                            cmp = PRECISION.compare(a.getZ(), b.getZ());
+                        }
+                    }
+                    return cmp;
+                });
+            case "pointmap3d":
+                return EuclideanCollections.pointMap3D(PRECISION);
+            default:
+                throw new IllegalArgumentException("Unknown map implementation: " + impl);
+            }
+        }
+
+        /** Create the list of points for the run.
+         * @return list of points
+         */
+        private List<Vector3D> createPoints() {
+            switch (shape.toLowerCase()) {
+            case "block":
+                return createPointBlock(20, 1);
+            case "line":
+                return createLine(8_000, 1);
+            case "sphere":
+                return createPointSphere(5, 5, 10);
+            default:
+                throw new IllegalArgumentException("Unknown point distribution " + impl);
+            }
+        }
+    }
+
+    /** Input class containing pre-inserted points. */
+    @State(Scope.Thread)
+    public static class PreInsertedPointMapInput extends PointMapInput {
+
+        /** List of test points. */
+        private List<Vector3D> testPoints;
+
+        /** {@inheritDoc} */
+        @Override
+        @Setup(Level.Iteration)
+        public void setup() {
+            super.setup();
+
+            final List<Vector3D> pts = getPoints();
+
+            // add the points to the map
+            final Map<Vector3D, Integer> map = getMap();
+            for (final Vector3D pt : pts) {
+                map.put(pt, VAL);
+            }
+
+            // compute test points
+            testPoints = new ArrayList<>(pts.size() * 2);
+            testPoints.addAll(pts);
+
+            final Random rnd = getRandom();
+            final Bounds3D bounds = Bounds3D.from(pts);
+            final Vector3D diag = bounds.getDiagonal();
+            for (int i = 0; i < pts.size(); ++i) {
+                testPoints.add(Vector3D.of(
+                        bounds.getMin().getX() + (rnd.nextDouble() * diag.getX()),
+                        bounds.getMin().getY() + (rnd.nextDouble() * diag.getY()),
+                        bounds.getMin().getZ() + (rnd.nextDouble() * diag.getZ())));
+            }
+
+            Collections.shuffle(testPoints, rnd);
+        }
+
+        /** Get a list of test points to look for in the map. The
+         * returned list contains 2x the number of points in the map,
+         * with half equal to map entries and half random.
+         * @return list of test points
+         */
+        public List<Vector3D> getTestPoints() {
+            return testPoints;
+        }
+    }
+
+    /** Create a solid block of points.
+     * @param pointsPerSide number of points along each side
+     * @param spacing spacing between each point
+     * @return list of points in a block
+     */
+    private static List<Vector3D> createPointBlock(final int pointsPerSide, final double spacing) {
+        final List<Vector3D> points = new ArrayList<>(pointsPerSide * pointsPerSide * pointsPerSide);
+
+        for (int x = 0; x < pointsPerSide; ++x) {
+            for (int y = 0; y < pointsPerSide; ++y) {
+                for (int z = 0; z < pointsPerSide; ++z) {
+                    points.add(Vector3D.of(x, y, z).multiply(spacing));
+                }
+            }
+        }
+
+        return points;
+    }
+
+    /** Create a line of points.
+     * @param count number of points
+     * @param spacing spacing between each point
+     * @return list of points in a lin
+     */
+    private static List<Vector3D> createLine(final int count, final double spacing) {
+        final List<Vector3D> points = new ArrayList<>(count);
+
+        final Vector3D base = Vector3D.of(2.0, 1.0, 0.5);
+        for (int i = 0; i < count; ++i) {
+            points.add(base.multiply(i));
+        }
+
+        return points;
+    }
+
+    /** Create a hollow sphere of points.
+     * @param slices number of sections in the x-y plane, not counting the poles
+     * @param segments number of section perpendicular to the x-y plane for each slice
+     * @param radius sphere radius
+     * @return list of points in a hollow sphere
+     */
+    private static List<Vector3D> createPointSphere(final int slices, final int segments, final double radius) {
+        final List<Vector3D> points = new ArrayList<>();
+
+        final double polarDelta = Math.PI / (slices + 1);
+        final double azDelta = Angle.TWO_PI / segments;
+
+        // add the top pole
+        points.add(Vector3D.of(0, 0, radius));
+
+        // add the lines of latitude
+        for (int i = 1; i <= slices; ++i) {
+            for (int j = 0; j < segments; ++j) {
+                final SphericalCoordinates coords = SphericalCoordinates.of(
+                        radius,
+                        j * azDelta,
+                        i * polarDelta);
+
+                points.add(coords.toVector());
+            }
+        }
+
+        // add the bottom pole
+        points.add(Vector3D.of(0, 0, -radius));
+
+        return points;
+    }
+
+    /** Benchmark that inserts each point in the input into the target map.
+     * @param input input for the run
+     * @param bh blackhole instance
+     * @return input instance
+     */
+    @Benchmark
+    public PointMapInput put(final PointMapInput input, final Blackhole bh) {
+        final Map<Vector3D, Integer> map = input.getMap();
+
+        for (final Vector3D p : input.getPoints()) {
+            bh.consume(map.put(p, VAL));
+        }
+
+        return input;
+    }
+
+    /** Benchmark that retrieves each point in the input from the target map.
+     * @param input input for the run
+     * @param bh blackhole instance
+     * @return input instance
+     */
+    @Benchmark
+    public PointMapInput get(final PreInsertedPointMapInput input, final Blackhole bh) {
+        final Map<Vector3D, Integer> map = input.getMap();
+
+        for (final Vector3D p : input.getTestPoints()) {
+            bh.consume(map.get(p));
+        }
+
+        return input;
+    }
+
+    /** Benchmark that remove each point in the input from the target map.
+     * @param input input for the run
+     * @param bh blackhole instance
+     * @return input instance
+     */
+    @Benchmark
+    public PointMapInput remove(final PreInsertedPointMapInput input, final Blackhole bh) {
+        final Map<Vector3D, Integer> map = input.getMap();
+
+        for (final Vector3D p : input.getPoints()) {
+            bh.consume(map.remove(p));
+        }
+
+        return input;
+    }
+}
diff --git a/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/BucketKDTree.java b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/BucketKDTree.java
new file mode 100644
index 0000000..7d29e9e
--- /dev/null
+++ b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/BucketKDTree.java
@@ -0,0 +1,984 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.examples.jmh.euclidean.pointmap;
+
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.ToDoubleFunction;
+import java.util.stream.Collectors;
+
+import org.apache.commons.geometry.core.internal.GeometryInternalUtils;
+import org.apache.commons.geometry.euclidean.threed.Bounds3D;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.numbers.core.Precision;
+
+/**
+ * KD-tree implementation that buffers vector entries at leaf nodes
+ * and then splits on the median of the buffer contents when enough
+ * nodes are collected. This guarantees that the tree has at least some
+ * branching.
+ * @param <V> Value type
+ */
+public class BucketKDTree<V> extends AbstractMap<Vector3D, V> {
+
+    /** Token used in debug tree strings. */
+    private static final String TREE_STRING_EQ_TOKEN = " => ";
+
+    /** Number of map entries stored in leaf nodes before splitting. */
+    private static final int ENTRY_BUFFER_SIZE = 10;
+
+    /** Precision context. */
+    private final Precision.DoubleEquivalence precision;
+
+    /** Root node; not null. */
+    private Node<V> root;
+
+    /** Tree node count. */
+    private int nodeCount;
+
+    /** Construct a new instance with the given precision.
+     * @param precision object used to determine floating point equality between dimension
+     *      coordinates
+     */
+    public BucketKDTree(final Precision.DoubleEquivalence precision) {
+        this.precision = precision;
+        this.root = new BucketNode<>(this, null);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int size() {
+        return nodeCount;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V put(final Vector3D key, final V value) {
+        validateKey(key);
+
+        final FindOrInsertResult<V> result = root.findOrInsert(key);
+        if (result.isNewEntry()) {
+            ++nodeCount;
+        }
+
+        final Vector3DEntry<V> entry = result.getEntry();
+        final V prevValue = entry.getValue();
+        entry.setValue(value);
+
+        return prevValue;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V get(final Object key) {
+        final Vector3DEntry<V> entry = root.find((Vector3D) key);
+        return entry != null ?
+                entry.getValue() :
+                null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V remove(final Object key) {
+        final Vector3DEntry<V> entry = root.remove((Vector3D) key);
+        if (entry != null) {
+            --nodeCount;
+
+            root.condense();
+
+            return entry.getValue();
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<Entry<Vector3D, V>> entrySet() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** Return a string representation of this tree for debugging purposes.
+     * @return a string representation of this tree
+     */
+    public String treeString() {
+        final StringBuilder sb = new StringBuilder();
+
+        root.treeString(0, sb);
+
+        return sb.toString();
+    }
+
+    /** Throw an exception if {@code key} is invalid.
+     * @param key map key
+     */
+    private void validateKey(final Vector3D key) {
+        Objects.requireNonNull(key);
+        if (!key.isFinite()) {
+            throw new IllegalArgumentException("Map key must be finite; was " + key);
+        }
+    }
+
+    /** Enum containing possible node cut dimensions. */
+    private enum CutDimension {
+
+        /** X dimension. */
+        X(Vector3D::getX),
+
+        /** Y dimension. */
+        Y(Vector3D::getY),
+
+        /** Z dimension. */
+        Z(Vector3D::getZ);
+
+        /** Coordinate extraction function. */
+        private final ToDoubleFunction<Vector3D> coordinateFn;
+
+        CutDimension(final ToDoubleFunction<Vector3D> coordinateFn) {
+            this.coordinateFn = coordinateFn;
+        }
+
+        /** Get the coordinate for this dimension.
+         * @param pt point
+         * @return dimension coordinate
+         */
+        public double getCoordinate(final Vector3D pt) {
+            return coordinateFn.applyAsDouble(pt);
+        }
+    }
+
+    /** Interface representing a reference to single entry in the tree.
+     * @param <V> Value type
+     */
+    private interface EntryReference<V> {
+
+        /** Get the entry.
+         * @return the entry
+         */
+        Vector3DEntry<V> getEntry();
+
+        /** Remove the referenced entry from the tree.
+         * @return the removed entry
+         */
+        Vector3DEntry<V> remove();
+    }
+
+    /** Class encapsulating the result of a find-or-insert operation.
+     * @param <V> Value type
+     */
+    private static final class FindOrInsertResult<V> {
+        /** Map entry. */
+        private final Vector3DEntry<V> entry;
+
+        /** Flag indicating if the entry is new. */
+        private final boolean newEntry;
+
+        FindOrInsertResult(final Vector3DEntry<V> entry, final boolean newEntry) {
+            this.entry = entry;
+            this.newEntry = newEntry;
+        }
+
+        /** Return the map entry.
+         * @return map entry
+         */
+        public Vector3DEntry<V> getEntry() {
+            return entry;
+        }
+
+        /** Return true if the map entry is new.
+         * @return true if the map entry is new
+         */
+        public boolean isNewEntry() {
+            return newEntry;
+        }
+
+        /** Construct a new result instance representing an existing map entry.
+         * @param <V> Value type
+         * @param entry existing entry
+         * @return instance representing an existing map entry
+         */
+        public static <V> FindOrInsertResult<V> existingEntry(final Vector3DEntry<V> entry) {
+            return new FindOrInsertResult<>(entry, false);
+        }
+
+        /** Construct a new result instance representing a new map entry.
+         * @param <V> Value type
+         * @param entry new map entry
+         * @return instance representing a new map entry
+         */
+        public static <V> FindOrInsertResult<V> newEntry(final Vector3DEntry<V> entry) {
+            return new FindOrInsertResult<>(entry, true);
+        }
+    }
+
+    /** Abstract base class for bucket kd-tree nodes.
+     * @param <V> Value type
+     */
+    private abstract static class Node<V> {
+
+        /** Owning tree. */
+        protected final BucketKDTree<V> tree;
+
+        /** Parent node; null for root node. */
+        protected CutNode<V> parent;
+
+        /** True if the node needs to be condensed. */
+        protected boolean requiresCondense;
+
+        Node(final BucketKDTree<V> tree, final CutNode<V> parent) {
+            this.tree = tree;
+            this.parent = parent;
+        }
+
+        /** Return the map entry equivalent to the given key or null if not
+         * found.
+         * @param key key to search for
+         * @return entry equivalent to {@code key} or null if not found
+         */
+        public abstract Vector3DEntry<V> find(Vector3D key);
+
+        /** Return the map entry equivalent to the given key. An entry is created
+         * if one does not exist.
+         * @param key map key
+         * @return result of the operation
+         */
+        public abstract FindOrInsertResult<V> findOrInsert(Vector3D key);
+
+        /** Find the entry in the subtree rooted at this node containing the minimum value measured
+         * along the cut dimension.
+         * @param dim dimension to measure along
+         * @return the entry containing the minimum value measured along the cut dimension
+         */
+        public abstract EntryReference<V> findMin(CutDimension dim);
+
+        /** Insert an entry from another portion of the tree into the subtree
+         * rooted at this node.
+         * @param entry entry to insert
+         */
+        public abstract void insertExisting(Vector3DEntry<V> entry);
+
+        /** Remove the map entry equivalent to the given key or null if one
+         * was not found.
+         * @param key map key
+         * @return the removed entry or null if not found
+         */
+        public abstract Vector3DEntry<V> remove(Vector3D key);
+
+        /** Mark this node and its ancestors as requiring condensing.
+         */
+        public void markRequiresCondense() {
+            Node<V> node = this;
+            while (node != null && !node.requiresCondense) {
+                node.requiresCondense = true;
+                node = node.parent;
+            }
+        }
+
+        /** Condense the subtree rooted at this node if possible.
+         */
+        public abstract void condense();
+
+        /** Return a string representation of the subtree rooted at this node.
+         * @param depth depth of this node
+         * @param sb string builder to append content to
+         */
+        public void treeString(final int depth, final StringBuilder sb) {
+            for (int i = 0; i < depth; ++i) {
+                sb.append("    ");
+            }
+
+            String label = parent == null ?
+                    "*" :
+                    isLeftChild() ? "L" : "R";
+
+            sb.append("[")
+                .append(label);
+        }
+
+        /** Validate the state of the tree.
+         */
+        public abstract void validate();
+
+        /** Return true if the entry and key are equivalent according to the map
+         * precision context.
+         * @param entry map entry
+         * @param key key value
+         * @return true if the entry and key are equivalent
+         */
+        protected boolean eq(final Vector3DEntry<V> entry, final Vector3D key) {
+            return entry.getKey().eq(key, tree.precision);
+        }
+
+        /** Compare the given values using the precision context of the map.
+         * @param a first value
+         * @param b second value
+         * @return comparison result
+         */
+        protected int eqCompare(final double a, final double b) {
+            return tree.precision.compare(a, b);
+        }
+
+        /** Replace this node with the given node.
+         * @param node replacement node; may be null
+         */
+        protected void replaceSelf(final Node<V> node) {
+            if (parent == null) {
+                // this is the root
+                if (node != null) {
+                    node.parent = null;
+                }
+
+                tree.root = node;
+            } else {
+                if (node != null) {
+                    node.parent = parent;
+                }
+
+                if (isLeftChild()) {
+                    parent.left = node;
+                } else {
+                    parent.right = node;
+                }
+            }
+        }
+
+        /** Return true if this node is the left child of its parent.
+         * @return true if this node is the left child of its parent
+         */
+        protected boolean isLeftChild() {
+            return parent != null && GeometryInternalUtils.sameInstance(parent.left, this);
+        }
+    }
+
+    /** Internal tree node containing a single entry and a cut dimension.
+     * @param <V> Value type
+     */
+    private static final class CutNode<V> extends Node<V> {
+
+        /** Map entry. */
+        private Vector3DEntry<V> entry;
+
+        /** Left child node; may be null. */
+        private Node<V> left;
+
+        /** Right child node; may be null. */
+        private Node<V> right;
+
+        /** Cut dimension; not null. */
+        private CutDimension cutDimension;
+
+        CutNode(final BucketKDTree<V> tree, final CutNode<V> parent, final Vector3DEntry<V> entry,
+                final CutDimension cutDimension) {
+            super(tree, parent);
+
+            this.entry = entry;
+            this.cutDimension = cutDimension;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Vector3DEntry<V> find(final Vector3D key) {
+            // pull the coordinates for the node cut dimension
+            final double nodeCoord = cutDimension.getCoordinate(entry.getKey());
+            final double keyCoord = cutDimension.getCoordinate(key);
+
+            // perform an equivalence comparison
+            final int eqcmp = eqCompare(keyCoord, nodeCoord);
+
+            if (eqcmp < 0) {
+                return left != null ?
+                        left.find(key) :
+                        null;
+            } else if (eqcmp > 0) {
+                return right != null ?
+                        right.find(key) :
+                        null;
+            } else if (eq(entry, key)) {
+                // our entry key is equivalent to the search key
+                return entry;
+            } else {
+                // Not equivalent; the matching node (if any) could be on either
+                // side of the cut so we'll need to search both subtrees. Since points with
+                // cut dimension coordinates are always inserted into the right subtree,
+                // search that subtree first.
+                final Vector3DEntry<V> rightSearchResult = right != null ?
+                        right.find(key) :
+                        null;
+                if (rightSearchResult != null) {
+                    return rightSearchResult;
+                }
+
+                return left != null ?
+                        left.find(key) :
+                        null;
+            }
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public FindOrInsertResult<V> findOrInsert(final Vector3D key) {
+            // pull the coordinates for the node cut dimension
+            final double nodeCoord = cutDimension.getCoordinate(entry.getKey());
+            final double keyCoord = cutDimension.getCoordinate(key);
+
+            // perform an equivalence comparison
+            final int eqCmp = eqCompare(keyCoord, nodeCoord);
+
+            if (eqCmp < 0) {
+                // we definitely belong in the left subtree
+                return getOrCreateLeft().findOrInsert(key);
+            } else if (eqCmp > 0) {
+                // we definitely belong in the right subtree
+                return getOrCreateRight().findOrInsert(key);
+            } else if (eq(entry, key)) {
+                // our entry key is equivalent to the search key
+                return FindOrInsertResult.existingEntry(entry);
+            } else {
+                // We are not equivalent and we straddle the cut line for this node,
+                // meaning that an existing node equivalent to the key could be on either
+                // side of the cut. Perform a strict comparison to determine where the
+                // node would be inserted if we were inserting. Then check the opposite
+                // subtree for an existing node and if not found, go ahead and try inserting
+                // into the target subtree.
+                final int strictCmp = Double.compare(keyCoord, nodeCoord);
+                if (strictCmp < 0) {
+                    // insertion, if needed, would be performed in the left subtree, so
+                    // check the right subtree first
+                    final Vector3DEntry<V> rightExisting = right != null ?
+                            right.find(key) :
+                            null;
+                    if (rightExisting != null) {
+                        return FindOrInsertResult.existingEntry(rightExisting);
+                    }
+
+                    return getOrCreateLeft().findOrInsert(key);
+                } else {
+                    // insertion, if needed, would be performed in the right subtree, so
+                    // check the left subtree first
+                    final Vector3DEntry<V> leftExisting = left != null ?
+                            left.find(key) :
+                            null;
+                    if (leftExisting != null) {
+                        return FindOrInsertResult.existingEntry(leftExisting);
+                    }
+
+                    return getOrCreateRight().findOrInsert(key);
+                }
+            }
+        }
+        /** {@inheritDoc} */
+        @Override
+        public Vector3DEntry<V> remove(final Vector3D key) {
+            // pull the coordinates for the node cut dimension
+            final double nodeCoord = cutDimension.getCoordinate(entry.getKey());
+            final double keyCoord = cutDimension.getCoordinate(key);
+
+            // perform an equivalence comparison
+            final int eqcmp = eqCompare(keyCoord, nodeCoord);
+
+            if (eqcmp < 0) {
+                return left != null ?
+                        left.remove(key) :
+                        null;
+            } else if (eqcmp > 0) {
+                return right != null ?
+                        right.remove(key) :
+                        null;
+            } else if (eq(entry, key)) {
+                return removeOwnEntry();
+            } else {
+                // Not equivalent; the matching node (if any) could be on either
+                // side of the cut so we'll need to search both subtrees. Use the side
+                // closest to the key as the first to search.
+                final Node<V> first;
+                final Node<V> second;
+                if (Double.compare(keyCoord, nodeCoord) < 0) {
+                    first = left;
+                    second = right;
+                } else {
+                    first = right;
+                    second = left;
+                }
+
+                final Vector3DEntry<V> firstRemoveResult = first != null ?
+                        first.remove(key) :
+                        null;
+                if (firstRemoveResult != null) {
+                    return firstRemoveResult;
+                }
+
+                return second != null ?
+                        second.remove(key) :
+                        null;
+            }
+        }
+
+        /** Remove the entry for this cut node and replace it with an appropriate
+         * value from a child node.
+         * @return the entry removed from this node.
+         */
+        private Vector3DEntry<V> removeOwnEntry() {
+            final Vector3DEntry<V> result = entry;
+            entry = null;
+
+            if (right != null) {
+                entry = right.findMin(cutDimension).remove();
+            } else if (left != null) {
+                // swap left and right subtrees; the replacement entry will
+                // contain the minimum of the entire subtree
+                entry = left.findMin(cutDimension).remove();
+
+                right = left;
+                left = null;
+            }
+
+            return result;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public EntryReference<V> findMin(final CutDimension dim) {
+            if (cutDimension.equals(dim)) {
+                // this node splits on the dimensions we're searching for, so we
+                // only need to search the left subtree
+                if (left == null) {
+                    return new CutNodeEntryReference<>(this);
+                }
+                return left.findMin(cutDimension);
+            } else {
+                // search both subtrees for the minimum value
+                final EntryReference<V> leftMin = left != null ?
+                        left.findMin(dim) :
+                        null;
+                final EntryReference<V> rightMin = right != null ?
+                        right.findMin(dim) :
+                        null;
+
+                return minResult(
+                        leftMin,
+                        new CutNodeEntryReference<>(this),
+                        rightMin,
+                        dim);
+            }
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void condense() {
+            if (requiresCondense) {
+                // condense children
+                if (left != null) {
+                    left.condense();
+                }
+                if (right != null) {
+                    right.condense();
+                }
+
+                if (entry == null) {
+                    // no entries in this subtree; remove completely
+                    replaceSelf(null);
+                } else if (right == null && left == null) {
+                    // no more children; convert to a bucket node
+                    final BucketNode<V> bucket = new BucketNode<>(tree, null);
+                    bucket.entries.add(entry);
+
+                    replaceSelf(bucket);
+                }
+            }
+
+            requiresCondense = false;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void insertExisting(final Vector3DEntry<V> newEntry) {
+            // pull the coordinates for the node cut dimension
+            final double nodeCoord = cutDimension.getCoordinate(entry.getKey());
+            final double newCoord = cutDimension.getCoordinate(newEntry.getKey());
+
+            // perform an equivalence comparison
+            final int eqCmp = eqCompare(newCoord, nodeCoord);
+
+            if (eqCmp < 0) {
+                // we definitely belong in the left subtree
+                getOrCreateLeft().insertExisting(newEntry);
+            } else if (eqCmp > 0) {
+                // we definitely belong in the right subtree
+                getOrCreateRight().insertExisting(newEntry);
+            } else {
+                // We are not equivalent and we straddle the cut line for this node,
+                // meaning that an existing node equivalent to the key could be on either
+                // side of the cut. Perform a strict comparison to determine where the
+                // node would be inserted if we were inserting. Then check, search the
+                // opposite subtree for an existing node and if not found, go ahead and
+                // try inserting into the target subtree.
+                final int strictCmp = Double.compare(newCoord, nodeCoord);
+                if (strictCmp < 0) {
+                    getOrCreateLeft().insertExisting(newEntry);
+                } else {
+                    getOrCreateRight().insertExisting(newEntry);
+                }
+            }
+        }
+
+        /** Get or create the left child node.
+         * @return left child node
+         */
+        private Node<V> getOrCreateLeft() {
+            if (left == null) {
+                left = new BucketNode<>(tree, this);
+            }
+            return left;
+        }
+
+        /** Get or create the right child node.
+         * @return right child node
+         */
+        private Node<V> getOrCreateRight() {
+            if (right == null) {
+                right = new BucketNode<>(tree, this);
+            }
+            return right;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void treeString(final int depth, final StringBuilder sb) {
+            super.treeString(depth, sb);
+
+            sb.append(" | ")
+                .append(cutDimension)
+                .append("] ")
+                .append(entry.getKey())
+                .append(TREE_STRING_EQ_TOKEN)
+                .append(entry.getValue())
+                .append("\n");
+
+            if (left != null) {
+                left.treeString(depth + 1, sb);
+            }
+            if (right != null) {
+                right.treeString(depth + 1, sb);
+            }
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void validate() {
+            if (entry == null) {
+                throw new IllegalArgumentException("Cut node entry cannot be null");
+            }
+            if (left == null && right == null) {
+                throw new IllegalArgumentException("Cut node cannot be leaf: " + entry.getKey());
+            }
+
+            if (left != null) {
+                left.validate();
+            }
+
+            if (right != null) {
+                right.validate();
+            }
+        }
+    }
+
+    /** {@link EntryReference} implementation representing the entry from a {@link CutNode}.
+     * @param <V> Value type
+     */
+    private static final class CutNodeEntryReference<V> implements EntryReference<V> {
+
+        /** Cut node instance. */
+        private final CutNode<V> node;
+
+        CutNodeEntryReference(final CutNode<V> node) {
+            this.node = node;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Vector3DEntry<V> getEntry() {
+            return node.entry;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Vector3DEntry<V> remove() {
+            final Vector3DEntry<V> result = node.entry;
+            node.removeOwnEntry();
+
+            return result;
+        }
+    }
+
+    /** Leaf node class containing multiple entries.
+     * @param <V> Value type
+     */
+    private static final class BucketNode<V> extends Node<V> {
+
+        /** List of entries. */
+        private List<Vector3DEntry<V>> entries = new ArrayList<>(ENTRY_BUFFER_SIZE);
+
+        BucketNode(final BucketKDTree<V> tree, final CutNode<V> parent) {
+            super(tree, parent);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Vector3DEntry<V> find(final Vector3D key) {
+            for (Vector3DEntry<V> entry : entries) {
+                if (eq(entry, key)) {
+                    return entry;
+                }
+            }
+            return null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public FindOrInsertResult<V> findOrInsert(final Vector3D key) {
+            Vector3DEntry<V> entry = find(key);
+            if (entry != null) {
+                return FindOrInsertResult.existingEntry(entry);
+            } else {
+                // we need to create the entry
+                entry = new Vector3DEntry<>(key, null);
+
+                if (entries.size() < ENTRY_BUFFER_SIZE) {
+                    entries.add(entry);
+                } else {
+                    // we need to split
+                    final CutNode<V> splitNode = split();
+
+                    replaceSelf(splitNode);
+
+                    splitNode.insertExisting(entry);
+                }
+
+                return FindOrInsertResult.newEntry(entry);
+            }
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Vector3DEntry<V> remove(final Vector3D key) {
+            final Iterator<Vector3DEntry<V>> it = entries.iterator();
+            while (it.hasNext()) {
+                final Vector3DEntry<V> entry = it.next();
+                if (eq(entry, key)) {
+                    it.remove();
+
+                    checkCondense();
+
+                    return entry;
+                }
+            }
+
+            return null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public EntryReference<V> findMin(final CutDimension dim) {
+            int idx = 0;
+            int minIdx = -1;
+            double minCoord = Double.POSITIVE_INFINITY;
+
+            for (final Vector3DEntry<V> entry : entries) {
+                final double coord = dim.getCoordinate(entry.getKey());
+                if (minIdx < 0 || coord < minCoord) {
+                    minIdx = idx;
+                    minCoord = coord;
+                }
+
+                ++idx;
+            }
+
+            return new BucketNodeEntryReference<>(this, minIdx);
+        }
+
+        /** Check if this node requires condensing and mark it if so.
+         */
+        private void checkCondense() {
+            if (entries.isEmpty() && parent != null) {
+                markRequiresCondense();
+            }
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void condense() {
+            if (requiresCondense) {
+                replaceSelf(null);
+            }
+
+            requiresCondense = false;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void insertExisting(final Vector3DEntry<V> entry) {
+            entries.add(entry);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void treeString(final int depth, final StringBuilder sb) {
+            super.treeString(depth, sb);
+
+            String entryStr = entries.stream()
+                    .map(e -> e.getKey() + TREE_STRING_EQ_TOKEN + e.getValue())
+                    .collect(Collectors.joining(", "));
+
+            sb.append("] [")
+                .append(entryStr)
+                .append("]\n");
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void validate() {
+            if (entries.isEmpty() && parent != null) {
+                throw new IllegalArgumentException("Non-root bucket node entry list is empty");
+            }
+        }
+
+        /** Split this instance into a {@link CutNode} and child nodes.
+         * @return the new cut node that should replace this instance
+         */
+        private CutNode<V> split() {
+            final CutDimension dim = determineCutDimension();
+
+            // find the median
+            entries.sort((a, b) ->
+                Double.compare(dim.getCoordinate(a.getKey()), dim.getCoordinate(b.getKey())));
+            final int medianIdx = entries.size() / 2;
+            final Vector3DEntry<V> median = entries.get(medianIdx);
+
+            // create the subtree root node on the median
+            final CutNode<V> subtree = new CutNode<>(tree, null, median, dim);
+
+            // insert the other entries
+            for (int i = 0; i < entries.size(); ++i) {
+                if (i != medianIdx) {
+                    subtree.insertExisting(entries.get(i));
+                }
+            }
+
+            // clean up
+            entries.clear();
+
+            return subtree;
+        }
+
+        /** Determine the best cutting dimension to use for the current set of entries.
+         * @return split cutting dimension
+         */
+        private CutDimension determineCutDimension() {
+            final Bounds3D.Builder boundsBuilder = Bounds3D.builder();
+            for (Vector3DEntry<V> entry : entries) {
+                boundsBuilder.add(entry.getKey());
+            }
+
+            final Bounds3D bounds = boundsBuilder.build();
+            final Vector3D diff = bounds.getDiagonal();
+
+            if (diff.getX() > diff.getY()) {
+                if (diff.getX() > diff.getZ()) {
+                    return CutDimension.X;
+                } else {
+                    return CutDimension.Z;
+                }
+            } else if (diff.getY() > diff.getZ()) {
+                return CutDimension.Y;
+            } else {
+                return CutDimension.Z;
+            }
+        }
+    }
+
+    /** {@link EntryReference} implementation representing the entry from a {@link BucketNode}.
+     * @param <V> Value type
+     */
+    private static final class BucketNodeEntryReference<V> implements EntryReference<V> {
+
+        /** Bucket node reference. */
+        private final BucketNode<V> node;
+
+        /** Entry index. */
+        private final int idx;
+
+        BucketNodeEntryReference(final BucketNode<V> node, final int idx) {
+            this.node = node;
+            this.idx = idx;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Vector3DEntry<V> getEntry() {
+            return node.entries.get(idx);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Vector3DEntry<V> remove() {
+            final Vector3DEntry<V> result = node.entries.remove(idx);
+            node.checkCondense();
+
+            return result;
+        }
+    }
+
+    /** Return the node containing the minimum value along the given cut dimension.
+     * @param <V> Value type
+     * @param a first node
+     * @param b second node
+     * @param c third node
+     * @param cutDimension search dimension
+     * @return minimum node
+     */
+    private static <V> EntryReference<V> minResult(final EntryReference<V> a, final EntryReference<V> b,
+            final EntryReference<V> c,
+            final CutDimension cutDimension) {
+        final EntryReference<V> tempMin = minResult(a, b, cutDimension);
+        return minResult(tempMin, c, cutDimension);
+    }
+
+    /** Return the node containing the minimum value along the given cut dimension. If one
+     * argument is null, the other argument is returned.
+     * @param <V> Value type
+     * @param a first node
+     * @param b second node
+     * @param cutDimension search dimension
+     * @return minimum node
+     */
+    private static <V> EntryReference<V> minResult(final EntryReference<V> a, final EntryReference<V> b,
+            final CutDimension cutDimension) {
+        if (a == null) {
+            return b;
+        } else if (b == null) {
+            return a;
+        }
+
+        final double aCoord = cutDimension.getCoordinate(a.getEntry().getKey());
+        final double bCoord = cutDimension.getCoordinate(b.getEntry().getKey());
+
+        return aCoord < bCoord ? a : b;
+    }
+}
diff --git a/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/KDTree.java b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/KDTree.java
new file mode 100644
index 0000000..c096c63
--- /dev/null
+++ b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/KDTree.java
@@ -0,0 +1,576 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.examples.jmh.euclidean.pointmap;
+
+import java.util.AbstractMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.ToDoubleFunction;
+
+import org.apache.commons.geometry.core.internal.GeometryInternalUtils;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.numbers.core.Precision;
+
+/**
+ * Standard kd-tree implementation with no self-balancing or rebuilding features.
+ * @param <V> Value type
+ */
+public class KDTree<V> extends AbstractMap<Vector3D, V> {
+
+    /** Enum containing possible node cut dimensions. */
+    enum CutDimension {
+
+        /** X dimension. */
+        X(Vector3D::getX),
+
+        /** Y dimension. */
+        Y(Vector3D::getY),
+
+        /** Z dimension. */
+        Z(Vector3D::getZ);
+
+        /** Coordinate extraction function. */
+        private final ToDoubleFunction<Vector3D> coordinateFn;
+
+        CutDimension(final ToDoubleFunction<Vector3D> coordinateFn) {
+            this.coordinateFn = coordinateFn;
+        }
+
+        /** Get the coordinate for this dimension.
+         * @param pt point
+         * @return dimension coordinate
+         */
+        public double getCoordinate(final Vector3D pt) {
+            return coordinateFn.applyAsDouble(pt);
+        }
+    }
+
+    /** Array of cut dimensions; pull these eagerly to avoid having to call values() constantly. */
+    private static final CutDimension[] CUT_DIMENSIONS = CutDimension.values();
+
+    /** Precision context. */
+    private final Precision.DoubleEquivalence precision;
+
+    /** Root node; may be null. */
+    private KDTreeNode<V> root;
+
+    /** Tree node count. */
+    private int nodeCount;
+
+    /** Construct a new instance with the given precision.
+     * @param precision object used to determine floating point equality between dimension
+     *      coordinates
+     */
+    public KDTree(final Precision.DoubleEquivalence precision) {
+        this.precision = precision;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int size() {
+        return nodeCount;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V put(final Vector3D key, final V value) {
+        validateKey(key);
+
+        final KDTreeNode<V> node;
+
+        if (root == null) {
+            // first node; enter as the root
+            root = createNode(null, key, 0);
+            node = root;
+        } else {
+            // not the first node; enter into the tree
+            node = findOrInsertNodeRecursive(root, key, 0);
+        }
+
+        return node.setValue(value);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V get(final Object key) {
+        final KDTreeNode<V> node = findNodeRecursive(root, (Vector3D) key);
+        return node != null ?
+                node.value :
+                null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V remove(final Object key) {
+        final KDTreeNode<V> node = findNodeRecursive(root, (Vector3D) key);
+        if (node != null) {
+            final V prevValue = node.value;
+
+            removeKey(node);
+            --nodeCount;
+
+            return prevValue;
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<Entry<Vector3D, V>> entrySet() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** Return a string representation of this tree for debugging purposes.
+     * @return a string representation of this tree
+     */
+    public String treeString() {
+        final StringBuilder sb = new StringBuilder();
+
+        treeStringRecursive(root, 0, sb);
+
+        return sb.toString();
+    }
+
+    /** Recursively build a string representation of a subtree.
+     * @param node subtree root
+     * @param depth node depth
+     * @param sb string output
+     */
+    private void treeStringRecursive(final KDTreeNode<V> node, final int depth, final StringBuilder sb) {
+        if (node != null) {
+            for (int i = 0; i < depth; ++i) {
+                sb.append("    ");
+            }
+
+            String label = node.parent == null ?
+                    "*" :
+                    node.isLeftChild() ? "L" : "R";
+
+            sb.append("[")
+                .append(label)
+                .append(" | ")
+                .append(node.cutDimension)
+                .append("] ")
+                .append(node.key)
+                .append(" => ")
+                .append(node.value)
+                .append("\n");
+
+            treeStringRecursive(node.left, depth + 1, sb);
+            treeStringRecursive(node.right, depth + 1, sb);
+        }
+    }
+
+    /** Get the tree root node.
+     * @return tree root node
+     */
+    protected KDTreeNode<V> getRoot() {
+        return root;
+    }
+
+    /** Set the tree root.
+     * @param root tree root
+     */
+    protected void setRoot(final KDTreeNode<V> root) {
+        this.root = root;
+    }
+
+    /** Throw an exception if {@code key} is invalid.
+     * @param key map key
+     */
+    private void validateKey(final Vector3D key) {
+        Objects.requireNonNull(key);
+        if (!key.isFinite()) {
+            throw new IllegalArgumentException("Map key must be finite; was " + key);
+        }
+    }
+
+    /** Create a new {@link KDTreeNode} for entry into the tree and increment the internal
+     * node count.
+     * @param parent parent node
+     * @param key map key
+     * @param depth node depth
+     * @return the newly created node
+     */
+    private KDTreeNode<V> createNode(final KDTreeNode<V> parent, final Vector3D key, final int depth) {
+        final KDTreeNode<V> node = new KDTreeNode<>(
+                parent,
+                key,
+                getCutDimensionForDepth(depth));
+
+        ++nodeCount;
+
+        return node;
+    }
+
+    /** Find the node in the subtree rooted at {@code node} matching {@code key} or create it if not found.
+     * @param node subtree root
+     * @param key map key
+     * @param depth current node depth
+     * @return the existing or newly created node
+     */
+    private KDTreeNode<V> findOrInsertNodeRecursive(final KDTreeNode<V> node, final Vector3D key, final int depth) {
+        // pull the coordinates for the node cut dimension
+        final double nodeCoord = node.cutDimension.getCoordinate(node.key);
+        final double keyCoord = node.cutDimension.getCoordinate(key);
+
+        // perform an equivalence comparison
+        final int eqCmp = precision.compare(keyCoord, nodeCoord);
+
+        final int childDepth = depth + 1;
+
+        if (eqCmp < 0) {
+            // we definitely belong in the left subtree
+            if (node.left == null) {
+                node.left = createNode(node, key, childDepth);
+                return node.left;
+            } else {
+                return findOrInsertNodeRecursive(node.left, key, childDepth);
+            }
+        } else if (eqCmp > 0) {
+            // we definitely belong in the right subtree
+            if (node.right == null) {
+                node.right = createNode(node, key, childDepth);
+                return node.right;
+            } else {
+                return findOrInsertNodeRecursive(node.right, key, childDepth);
+            }
+        } else {
+            // check if we are equivalent to the point for this node
+            if (key.eq(node.key, precision)) {
+                return node;
+            }
+
+            // We are not equivalent and we straddle the cut line for this node,
+            // meaning that an existing node equivalent to the key could be on either
+            // side of the cut. Perform a strict comparison to determine where the
+            // node would be inserted if we were inserting. Then check, search the
+            // opposite subtree for an existing node and if not found, go ahead and
+            // try inserting into the target subtree.
+            final int strictCmp = Double.compare(keyCoord, nodeCoord);
+            if (strictCmp < 0) {
+                // insertion, if needed, would be performed in the left subtree, so
+                // check the right subtree first
+                final KDTreeNode<V> rightExistingNode = findNodeRecursive(node.right, key);
+                if (rightExistingNode != null) {
+                    return rightExistingNode;
+                }
+
+                if (node.left == null) {
+                    node.left = createNode(node, key, childDepth);
+                    return node.left;
+                }
+
+                return findOrInsertNodeRecursive(node.left, key, childDepth);
+            } else {
+                // insertion, if needed, would be performed in the right subtree, so
+                // check the left subtree first
+                final KDTreeNode<V> leftExistingNode = findNodeRecursive(node.left, key);
+                if (leftExistingNode != null) {
+                    return leftExistingNode;
+                }
+
+                if (node.right == null) {
+                    node.right = createNode(node, key, childDepth);
+                    return node.right;
+                }
+
+                return findOrInsertNodeRecursive(node.right, key, childDepth);
+            }
+        }
+    }
+
+    /** Find the node with the given {@code key} or null if not found.
+     * @param node subtree root
+     * @param key map key
+     * @return the node matching the given {@code key} or null if not found
+     */
+    private KDTreeNode<V> findNodeRecursive(final KDTreeNode<V> node, final Vector3D key) {
+        if (node != null) {
+            // pull the coordinates for the node cut dimension
+            final double nodeCoord = node.cutDimension.getCoordinate(node.key);
+            final double keyCoord = node.cutDimension.getCoordinate(key);
+
+            // perform an equivalence comparison
+            final int eqcmp = precision.compare(keyCoord, nodeCoord);
+
+            if (eqcmp < 0) {
+                return findNodeRecursive(node.left, key);
+            } else if (eqcmp > 0) {
+                return findNodeRecursive(node.right, key);
+            } else {
+                // check if we are equivalent to the point for this node
+                if (key.eq(node.key, precision)) {
+                    return node;
+                }
+
+                // Not equivalent; the matching node (if any) could be on either
+                // side of the cut so we'll need to search both subtrees. Since points with
+                // cut dimension coordinates are always inserted into the right subtree,
+                // search that subtree first.
+                final KDTreeNode<V> righttSearchResult = findNodeRecursive(node.right, key);
+                if (righttSearchResult != null) {
+                    return righttSearchResult;
+                }
+
+                return findNodeRecursive(node.left, key);
+            }
+        }
+        return null;
+    }
+
+    /** Remove the given node from the tree.
+     * @param node node to remove
+     */
+    private void removeKey(final KDTreeNode<V> node) {
+        // find a child node to replace this one
+        KDTreeNode<V> replacement = null;
+        if (node.right != null) {
+            replacement = findMin(node.right, node.cutDimension);
+        } else if (node.left != null) {
+            replacement = findMin(node.left, node.cutDimension);
+
+            // swap left and right subtrees; the replacement will
+            // contain the minimum of the entire subtree
+            node.right = node.left;
+            node.left = null;
+        }
+
+        // perform the replacement
+        if (replacement != null) {
+            node.key = replacement.key;
+            node.value = replacement.value;
+
+            removeKey(replacement);
+        } else {
+            // leaf node; disconnect from the subtree
+            if (GeometryInternalUtils.sameInstance(root, node)) {
+                this.root = null;
+            }
+
+            if (node.parent != null) {
+                if (node.isLeftChild()) {
+                    node.parent.left = null;
+                } else {
+                    node.parent.right = null;
+                }
+            }
+        }
+    }
+
+    /** Find the node with the minimum value along the given cut dimension.
+     * @param node subtree root
+     * @param cutDimension cut dimension to search along
+     * @return node with the minimum value along the cut dimension or null if {@code node}
+     *      is null
+     */
+    private KDTreeNode<V> findMin(final KDTreeNode<V> node, final CutDimension cutDimension) {
+        if (node != null) {
+            if (node.isLeaf()) {
+                // leaf node; automatically the min
+                return node;
+            } else if (node.cutDimension.equals(cutDimension)) {
+                // this node splits on the dimensions we're searching for, so we
+                // only need to search the left subtree
+                if (node.left == null) {
+                    return node;
+                }
+                return findMin(node.left, cutDimension);
+            } else {
+                // this node doesn't split on our target dimension, so search both subtrees
+                // and the current node
+                return cutDimensionMin(
+                            findMin(node.left, cutDimension),
+                            node,
+                            findMin(node.right, cutDimension),
+                            cutDimension);
+            }
+        }
+        return null;
+    }
+
+    /** Return the node containing the minimum value along the given cut dimension. Null
+     * nodes are ignored.
+     * @param a first node
+     * @param b second node
+     * @param c third node
+     * @param cutDimension search dimension
+     * @return minimum node
+     */
+    private KDTreeNode<V> cutDimensionMin(final KDTreeNode<V> a, final KDTreeNode<V> b, final KDTreeNode<V> c,
+            final CutDimension cutDimension) {
+        final KDTreeNode<V> tempMin = cutDimensionMin(a, b, cutDimension);
+        return cutDimensionMin(tempMin, c, cutDimension);
+    }
+
+    /** Return the node containing the minimum value along the given cut dimension. If one
+     * argument is null, the other argument is returned.
+     * @param a first node
+     * @param b second node
+     * @param cutDimension search dimension
+     * @return minimum node
+     */
+    private KDTreeNode<V> cutDimensionMin(final KDTreeNode<V> a, final KDTreeNode<V> b,
+            final CutDimension cutDimension) {
+        if (a == null) {
+            return b;
+        } else if (b == null) {
+            return a;
+        }
+
+        final double aCoord = cutDimension.getCoordinate(a.key);
+        final double bCoord = cutDimension.getCoordinate(b.key);
+
+        return aCoord < bCoord ? a : b;
+    }
+
+    /** Get the cut dimension for the given node depth.
+     * @param depth node depth
+     * @return cut dimension instance
+     */
+    protected CutDimension getCutDimensionForDepth(final int depth) {
+        return CUT_DIMENSIONS[depth % CUT_DIMENSIONS.length];
+    }
+
+    /** Class representing a node in a KD tree.
+     * @param <V> Value type
+     */
+    static final class KDTreeNode<V> implements Map.Entry<Vector3D, V> {
+
+        /** Parent node; may be null. */
+        private KDTreeNode<V> parent;
+
+        /** Map key value. */
+        private Vector3D key;
+
+        /** Map entry value. */
+        private V value;
+
+        /** Left child; may be null. */
+        private KDTreeNode<V> left;
+
+        /** Right child; may be null. */
+        private KDTreeNode<V> right;
+
+        /** Node cut dimension. */
+        private CutDimension cutDimension;
+
+        /** Construct a new instance.
+         * @param parent parent node; may be null
+         * @param key map key
+         * @param cutDimension cut dimension
+         */
+        private KDTreeNode(final KDTreeNode<V> parent, final Vector3D key,
+                final CutDimension cutDimension) {
+            this.parent = parent;
+            this.cutDimension = cutDimension;
+            this.key = key;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Vector3D getKey() {
+            return key;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public V getValue() {
+            return value;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public V setValue(final V newValue) {
+            final V oldValue = this.value;
+            this.value = newValue;
+            return oldValue;
+        }
+
+        /** Get the cut dimension for this node.
+         * @return cut dimension
+         */
+        public CutDimension getCutDimension() {
+            return cutDimension;
+        }
+
+        /** Set the cut dimension for this node.
+         * @param cutDimension cut dimension
+         */
+        public void setCutDimension(final CutDimension cutDimension) {
+            this.cutDimension = cutDimension;
+        }
+
+        /** Get the parent node.
+         * @return parent node; may be null
+         */
+        public KDTreeNode<V> getParent() {
+            return parent;
+        }
+
+        /** Set the parent node.
+         * @param parent parent node
+         */
+        public void setParent(final KDTreeNode<V> parent) {
+            this.parent = parent;
+        }
+
+        /** Get the left child node.
+         * @return left child node; may be null
+         */
+        public KDTreeNode<V> getLeft() {
+            return left;
+        }
+
+        /** Set the left child node.
+         * @param left left child node
+         */
+        public void setLeft(final KDTreeNode<V> left) {
+            this.left = left;
+        }
+
+        /** Get the right child node.
+         * @return right child node; may be null
+         */
+        public KDTreeNode<V> getRight() {
+            return right;
+        }
+
+        /** Set the right child node.
+         * @param right right child node
+         */
+        public void setRight(final KDTreeNode<V> right) {
+            this.right = right;
+        }
+
+        /** Return true if this node is a leaf node.
+         * @return true if this node is a leaf node
+         */
+        public boolean isLeaf() {
+            return left == null && right == null;
+        }
+
+        /** Return true if this node is the left child of its parent.
+         * @return true if this node is the left child of its
+         *      parent
+         */
+        public boolean isLeftChild() {
+            return parent != null && GeometryInternalUtils.sameInstance(parent.left, this);
+        }
+    }
+}
diff --git a/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/PointMapDataStructurePerformance.java b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/PointMapDataStructurePerformance.java
new file mode 100644
index 0000000..aa2e718
--- /dev/null
+++ b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/PointMapDataStructurePerformance.java
@@ -0,0 +1,326 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.examples.jmh.euclidean.pointmap;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.geometry.euclidean.threed.Bounds3D;
+import org.apache.commons.geometry.euclidean.threed.SphericalCoordinates;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.numbers.angle.Angle;
+import org.apache.commons.numbers.core.Precision;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/** Benchmarks for the testing candidate implementations of point map
+ * data structures.
+ */
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 1, jvmArgs = {"-server", "-Xms512M", "-Xmx512M"})
+public class PointMapDataStructurePerformance {
+
+    /** Precision context. */
+    private static final Precision.DoubleEquivalence PRECISION =
+            Precision.doubleEquivalenceOfEpsilon(1e-6);
+
+    /** Value inserted into maps during runs. */
+    private static final Integer VAL = Integer.valueOf(1);
+
+    /** Base input class for point map benchmarks. */
+    @State(Scope.Thread)
+    public static class PointMapInput {
+
+        /** Data structure implementation. */
+        @Param({"treemap", "varoctree", "kdtree", "rebuilding-kdtree", "bucket-kdtree"})
+        private String impl;
+
+        /** Point list shape. */
+        @Param({"block", "sphere"})
+        private String shape;
+
+        /** Point distribution. */
+        @Param({"none", "random", "ordered"})
+        private String dist;
+
+        /** Seed value for randomization. */
+        @Param({"1"})
+        private int randomSeed;
+
+        /** Map instance for the run. */
+        private Map<Vector3D, Integer> map;
+
+        /** List of points for the run. */
+        private List<Vector3D> points;
+
+        /** Random instance. */
+        private Random random;
+
+        /** Set up the instance for the benchmark. */
+        @Setup(Level.Iteration)
+        public void setup() {
+            random = new Random(randomSeed);
+
+            map = createMap();
+            points = createPoints();
+
+            switch (dist) {
+            case "none":
+                break;
+            case "random":
+                Collections.shuffle(points, random);
+                break;
+            case "ordered":
+                Collections.sort(points, Vector3D.COORDINATE_ASCENDING_ORDER);
+                break;
+            default:
+                throw new IllegalArgumentException("Unknown distribution: " + dist);
+            }
+        }
+
+        /** Get the map instance under test.
+         * @return map instance
+         */
+        public Map<Vector3D, Integer> getMap() {
+            return map;
+        }
+
+        /** Get the points for the run.
+         * @return list of points
+         */
+        public List<Vector3D> getPoints() {
+            return points;
+        }
+
+        /** Get the random number generator for the instance.
+         * @return random number generate
+         */
+        public Random getRandom() {
+            return random;
+        }
+
+        /** Create the map implementation for the run.
+         * @return map instance
+         */
+        private Map<Vector3D, Integer> createMap() {
+            switch (impl.toLowerCase()) {
+            case "treemap":
+                return new TreeMap<>((a, b) -> {
+                    int cmp = PRECISION.compare(a.getX(), b.getX());
+                    if (cmp == 0) {
+                        cmp = PRECISION.compare(a.getY(), b.getY());
+                        if (cmp == 0) {
+                            cmp = PRECISION.compare(a.getZ(), b.getZ());
+                        }
+                    }
+                    return cmp;
+                });
+            case "varoctree":
+                return new VariableSplitOctree<>(PRECISION);
+            case "kdtree":
+                return new KDTree<>(PRECISION);
+            case "rebuilding-kdtree":
+                return new RebuildingKDTree<>(PRECISION);
+            case "bucket-kdtree":
+                return new BucketKDTree<>(PRECISION);
+            default:
+                throw new IllegalArgumentException("Unknown map implementation: " + impl);
+            }
+        }
+
+        /** Create the list of points for the run.
+         * @return list of points
+         */
+        private List<Vector3D> createPoints() {
+            switch (shape.toLowerCase()) {
+            case "block":
+                return createPointBlock(20, 1);
+            case "sphere":
+                return createPointSphere(5, 5, 10);
+            default:
+                throw new IllegalArgumentException("Unknown point distribution " + impl);
+            }
+        }
+    }
+
+    /** Input class containing pre-inserted points. */
+    @State(Scope.Thread)
+    public static class PreInsertedPointMapInput extends PointMapInput {
+
+        /** List of test points. */
+        private List<Vector3D> testPoints;
+
+        /** {@inheritDoc} */
+        @Override
+        @Setup(Level.Iteration)
+        public void setup() {
+            super.setup();
+
+            final List<Vector3D> pts = getPoints();
+
+            // add the points to the map
+            final Map<Vector3D, Integer> map = getMap();
+            for (final Vector3D pt : pts) {
+                map.put(pt, VAL);
+            }
+
+            // compute test points
+            testPoints = new ArrayList<>(pts.size() * 2);
+            testPoints.addAll(pts);
+
+            final Random rnd = getRandom();
+            final Bounds3D bounds = Bounds3D.from(pts);
+            final Vector3D diag = bounds.getDiagonal();
+            for (int i = 0; i < pts.size(); ++i) {
+                testPoints.add(Vector3D.of(
+                        bounds.getMin().getX() + (rnd.nextDouble() * diag.getX()),
+                        bounds.getMin().getY() + (rnd.nextDouble() * diag.getY()),
+                        bounds.getMin().getZ() + (rnd.nextDouble() * diag.getZ())));
+            }
+
+            Collections.shuffle(testPoints, rnd);
+        }
+
+        /** Get a list of test points to look for in the map. The
+         * returned list contains 2x the number of points in the map,
+         * with half equal to map entries and half random.
+         * @return list of test points
+         */
+        public List<Vector3D> getTestPoints() {
+            return testPoints;
+        }
+    }
+
+    /** Create a solid block of points.
+     * @param pointsPerSide number of points along each side
+     * @param spacing spacing between each point
+     * @return list of points in a block
+     */
+    private static List<Vector3D> createPointBlock(final int pointsPerSide, final double spacing) {
+        final List<Vector3D> points = new ArrayList<>(pointsPerSide * pointsPerSide * pointsPerSide);
+
+        for (int x = 0; x < pointsPerSide; ++x) {
+            for (int y = 0; y < pointsPerSide; ++y) {
+                for (int z = 0; z < pointsPerSide; ++z) {
+                    points.add(Vector3D.of(x, y, z).multiply(spacing));
+                }
+            }
+        }
+
+        return points;
+    }
+
+    /** Create a hollow sphere of points.
+     * @param slices number of sections in the x-y plane, not counting the poles
+     * @param segments number of section perpendicular to the x-y plane for each slice
+     * @param radius sphere radius
+     * @return list of points in a hollow sphere
+     */
+    private static List<Vector3D> createPointSphere(final int slices, final int segments, final double radius) {
+        final List<Vector3D> points = new ArrayList<>();
+
+        final double polarDelta = Math.PI / (slices + 1);
+        final double azDelta = Angle.TWO_PI / segments;
+
+        // add the top pole
+        points.add(Vector3D.of(0, 0, radius));
+
+        // add the lines of latitude
+        for (int i = 1; i <= slices; ++i) {
+            for (int j = 0; j < segments; ++j) {
+                final SphericalCoordinates coords = SphericalCoordinates.of(
+                        radius,
+                        j * azDelta,
+                        i * polarDelta);
+
+                points.add(coords.toVector());
+            }
+        }
+
+        // add the bottom pole
+        points.add(Vector3D.of(0, 0, -radius));
+
+        return points;
+    }
+
+    /** Benchmark that inserts each point in the input into the target map.
+     * @param input input for the run
+     * @param bh blackhole instance
+     * @return input instance
+     */
+    @Benchmark
+    public PointMapInput put(final PointMapInput input, final Blackhole bh) {
+        final Map<Vector3D, Integer> map = input.getMap();
+
+        for (final Vector3D p : input.getPoints()) {
+            bh.consume(map.put(p, VAL));
+        }
+
+        return input;
+    }
+
+    /** Benchmark that retrieves each point in the input from the target map.
+     * @param input input for the run
+     * @param bh blackhole instance
+     * @return input instance
+     */
+    @Benchmark
+    public PointMapInput get(final PreInsertedPointMapInput input, final Blackhole bh) {
+        final Map<Vector3D, Integer> map = input.getMap();
+
+        for (final Vector3D p : input.getTestPoints()) {
+            bh.consume(map.get(p));
+        }
+
+        return input;
+    }
+
+    /** Benchmark that remove each point in the input from the target map.
+     * @param input input for the run
+     * @param bh blackhole instance
+     * @return input instance
+     */
+    @Benchmark
+    public PointMapInput remove(final PreInsertedPointMapInput input, final Blackhole bh) {
+        final Map<Vector3D, Integer> map = input.getMap();
+
+        for (final Vector3D p : input.getPoints()) {
+            bh.consume(map.remove(p));
+        }
+
+        return input;
+    }
+}
diff --git a/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/RebuildingKDTree.java b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/RebuildingKDTree.java
new file mode 100644
index 0000000..c5925cc
--- /dev/null
+++ b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/RebuildingKDTree.java
@@ -0,0 +1,248 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.examples.jmh.euclidean.pointmap;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.numbers.core.Precision.DoubleEquivalence;
+
+/** {@link KDTree} subclass that rebuilds the tree periodically in an
+ * attempt to restrict the overall height of the tree.
+ * @param <V> Value type
+ */
+public class RebuildingKDTree<V> extends KDTree<V> {
+
+    /** Default rebuild maximum. */
+    private static final int DEFAULT_REBUILD_MAX = 16;
+
+    /** Maximum size of the tree before it is rebuilt. */
+    private int rebuildMax = DEFAULT_REBUILD_MAX;
+
+    /** Minimum size of the tree before it is rebuilt. */
+    private int rebuildMin;
+
+    /** Construct a new instance.
+     * @param precision precision context
+     */
+    public RebuildingKDTree(final DoubleEquivalence precision) {
+        super(precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V put(final Vector3D key, final V value) {
+        final V result = super.put(key, value);
+
+        if (size() >= rebuildMax) {
+            rebuild();
+        }
+
+        return result;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V remove(final Object key) {
+        final V result = super.remove(key);
+
+        if (size() <= rebuildMin) {
+            rebuild();
+        }
+
+        return result;
+    }
+
+    /**
+     * Rebuild the tree, attempting to reduce the tree depth.
+     */
+    public void rebuild() {
+        int n = size();
+        if (n > 0) {
+            // construct an array list containing all of the tree nodes
+            final List<KDTreeNode<V>> nodes = collectNodes();
+
+            // rebuild recursively and set the new root
+            final KDTreeNode<V> newRoot = rebuildRecursive(nodes, 0, n, 0);
+
+            setRoot(newRoot);
+        }
+
+        rebuildMax = Math.max(DEFAULT_REBUILD_MAX, 2 * n);
+        rebuildMin = n / 2;
+    }
+
+    /** Get a list containing all nodes in the tree. The node connections are all cleared.
+     * @return list containing all nodes in the tree
+     */
+    protected List<KDTreeNode<V>> collectNodes() {
+        final List<KDTreeNode<V>> nodes = new ArrayList<>(size());
+        collectNodesRecursive(getRoot(), nodes);
+
+        return nodes;
+    }
+
+    /** Add nodes in the subtree rooted at {@code curr} to {@code nodes}. The node connection
+     * references are all cleared.
+     * @param curr subtree root node
+     * @param nodes node list
+     */
+    protected void collectNodesRecursive(final KDTreeNode<V> curr, final List<KDTreeNode<V>> nodes) {
+        if (curr != null) {
+            collectNodesRecursive(curr.getLeft(), nodes);
+            nodes.add(curr);
+            collectNodesRecursive(curr.getRight(), nodes);
+
+            curr.setParent(null);
+            curr.setLeft(null);
+            curr.setRight(null);
+        }
+    }
+
+    /** Recursively rebuild the tree using the specified node sublist.
+     * @param nodes node list
+     * @param startIdx sub list start index (inclusive)
+     * @param endIdx sub list end index (exclusive)
+     * @param depth node depth
+     * @return the root of the subtree containing the nodes between {@code startIdx} and {@code endIdx}
+     */
+    protected KDTreeNode<V> rebuildRecursive(final List<KDTreeNode<V>> nodes, final int startIdx, final int endIdx,
+            final int depth) {
+        final CutDimension cutDimension = getCutDimensionForDepth(depth);
+
+        final KDTreeNode<V> node;
+        if ((endIdx - startIdx) < 2) {
+            // only a single node here
+            node = nodes.get(startIdx);
+        } else {
+            final int splitIdx = partition(nodes, startIdx, endIdx, cutDimension);
+
+            node = nodes.get(splitIdx);
+
+            if (startIdx < splitIdx) {
+                node.setLeft(rebuildRecursive(nodes, startIdx, splitIdx, depth + 1));
+                node.getLeft().setParent(node);
+            }
+
+            if (splitIdx < endIdx - 1) {
+                node.setRight(rebuildRecursive(nodes, splitIdx + 1, endIdx, depth + 1));
+                node.getRight().setParent(node);
+            }
+        }
+
+        node.setCutDimension(cutDimension);
+
+        return node;
+    }
+
+    /** Partition the given sublist into values below the median and values above. The
+     * index of the median is returned.
+     * @param nodes node list
+     * @param startIdx start index (inclusive) of the sublist to partition
+     * @param endIdx end index (exclusive) of the sublist to partition
+     * @param cutDimension cut dimension
+     * @return index of the sublist median
+     */
+    protected int partition(final List<KDTreeNode<V>> nodes, final int startIdx, final int endIdx,
+            final CutDimension cutDimension) {
+        int n = endIdx - startIdx;
+        if (n < 2) {
+            return startIdx;
+        } else if (n == 2) {
+            final int bIdx = endIdx - 1;
+
+            final double a = cutDimension.getCoordinate(nodes.get(startIdx).getKey());
+            final double b = cutDimension.getCoordinate(nodes.get(bIdx).getKey());
+            if (a <= b) {
+                return startIdx;
+            } else {
+                Collections.swap(nodes, startIdx, bIdx);
+                return bIdx;
+            }
+        } else {
+            return findMedianStart(nodes, startIdx, endIdx, cutDimension);
+        }
+    }
+
+    /** Find the starting index of the node median value in the given list. The list is
+     * partially sorted and value less than the median come before the returned index while
+     * values greater than or equal come after.
+     * @param nodes list of node
+     * @param startIdx sublist start index (inclusive)
+     * @param endIdx sublist end index (exclusive)
+     * @param cutDimension cut dimension
+     * @return index of the median in the specific sublist of {@code nodes} along the cut dimension
+     */
+    protected int findMedianStart(final List<KDTreeNode<V>> nodes, final int startIdx, final int endIdx,
+            final CutDimension cutDimension) {
+        int k = startIdx + ((endIdx - startIdx) / 2);
+        int low = startIdx;
+        int high = endIdx - 1;
+        int lowTemp;
+        int highTemp;
+        double x;
+        while (low < high) {
+            x = cutDimension.getCoordinate(nodes.get(k).getKey());
+            lowTemp = low;
+            highTemp = high;
+            do {
+                while (cutDimension.getCoordinate(nodes.get(lowTemp).getKey()) < x) {
+                    ++lowTemp;
+                }
+                while (cutDimension.getCoordinate(nodes.get(highTemp).getKey()) > x) {
+                    --highTemp;
+                }
+
+                if (lowTemp <= highTemp) {
+                    Collections.swap(nodes, lowTemp, highTemp);
+
+                    ++lowTemp;
+                    --highTemp;
+                }
+            } while (lowTemp <= highTemp);
+
+            if (k < lowTemp) {
+                // search low part
+                high = highTemp;
+            }
+            if (k > highTemp) {
+                // search high part
+                low = lowTemp;
+            }
+        }
+
+        // back up to the start of the median value
+        x = cutDimension.getCoordinate(nodes.get(k).getKey());
+        while (k > startIdx &&
+                cutDimension.getCoordinate(nodes.get(k - 1).getKey()) == x) {
+            --k;
+        }
+
+        return k;
+    }
+
+    /** Construct a comparator the sorts by the given cut dimension.
+     * @param cutDimension cut dimension to sort by
+     * @return comparator along the cut dimension
+     */
+    protected Comparator<KDTreeNode<V>> comparator(final CutDimension cutDimension) {
+        return (a, b) -> Double.compare(cutDimension.getCoordinate(a.getKey()), cutDimension.getCoordinate(b.getKey()));
+    }
+}
diff --git a/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/VariableSplitOctree.java b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/VariableSplitOctree.java
new file mode 100644
index 0000000..e82b824
--- /dev/null
+++ b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/VariableSplitOctree.java
@@ -0,0 +1,449 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.examples.jmh.euclidean.pointmap;
+
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.numbers.core.Precision;
+
+/** Candidate point map that stores entries in a modified octree data structure.
+ * The split point for the octree child nodes is variable and is placed at the
+ * centroid of the points stored in the node at the time it is split.
+* @param <V> map value type
+*/
+public class VariableSplitOctree<V> extends AbstractMap<Vector3D, V> {
+
+    /** Precision context. */
+    private final Precision.DoubleEquivalence precision;
+
+    /** Root of the tree. */
+    private VariableSplitOctreeNode<V> root;
+
+    /** Size of the tree. */
+    private int entryCount;
+
+    public VariableSplitOctree(final Precision.DoubleEquivalence precision) {
+        this.precision = precision;
+        this.root = new VariableSplitOctreeNode<>(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int size() {
+        return entryCount;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V put(final Vector3D key, final V value) {
+        Objects.requireNonNull(key);
+        if (!key.isFinite()) {
+            throw new IllegalArgumentException("Keys must be finite");
+        }
+
+        final Vector3DEntry<V> entry = root.getEntry(key);
+        if (entry == null) {
+            root.insertEntry(key, value);
+            entryAdded();
+
+            return null;
+        }
+
+        final V prev = entry.getValue();
+        entry.setValue(value);
+
+        return prev;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V get(final Object key) {
+        final Vector3DEntry<V> entry = root.getEntry((Vector3D) key);
+        return entry != null ?
+                entry.getValue() :
+                null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V remove(final Object key) {
+        final Vector3DEntry<V> entry = root.removeEntry((Vector3D) key);
+        if (entry != null) {
+            entryRemoved();
+            return entry.getValue();
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<Entry<Vector3D, V>> entrySet() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** Method called when a new entry is added to the tree.
+     */
+    private void entryAdded() {
+        ++entryCount;
+    }
+
+    /** Method called when an entry is removed from the tree.
+     */
+    private void entryRemoved() {
+        --entryCount;
+    }
+
+    /** Octree node class.
+     * @param <V> Value type
+     */
+    private static final class VariableSplitOctreeNode<V> {
+
+        /** X negative octant flag. */
+        private static final int XNEG = 1 << 5;
+
+        /** X postive octant flag. */
+        private static final int XPOS = 1 << 4;
+
+        /** Y negative octant flag. */
+        private static final int YNEG = 1 << 3;
+
+        /** Y positive octant flag. */
+        private static final int YPOS = 1 << 2;
+
+        /** Z negative octant flag. */
+        private static final int ZNEG = 1 << 1;
+
+        /** Z positive octant flag. */
+        private static final int ZPOS = 1;
+
+        /** Octant location flags for child nodes. */
+        private static final int[] CHILD_LOCATIONS = {
+            XNEG | YNEG | ZNEG,
+            XNEG | YNEG | ZPOS,
+            XNEG | YPOS | ZNEG,
+            XNEG | YPOS | ZPOS,
+
+            XPOS | YNEG | ZNEG,
+            XPOS | YNEG | ZPOS,
+            XPOS | YPOS | ZNEG,
+            XPOS | YPOS | ZPOS
+        };
+
+        /** Max entries per node. */
+        private static final int MAX_ENTRIES = 16;
+
+        /** Number of children for internal nodes. */
+        private static final int NUM_CHILDREN = 8;
+
+        /** Owning map. */
+        private final VariableSplitOctree<V> map;
+
+        /** Child nodes. */
+        private List<VariableSplitOctreeNode<V>> children;
+
+        /** Points stored in the node; this will only be populated for leaf nodes. */
+        private List<Vector3DEntry<V>> entries = new ArrayList<>(MAX_ENTRIES);
+
+        /** The split point of the node; will be null for leaf nodes. */
+        private Vector3D splitPoint;
+
+        VariableSplitOctreeNode(final VariableSplitOctree<V> map) {
+            this.map = map;
+        }
+
+        /** Return true if the node is a leaf.
+         * @return true if the node is a leaf
+         */
+        public boolean isLeaf() {
+            return splitPoint == null;
+        }
+
+        /** Return true if this node is a leaf node and contains no entries.
+         * @return true if this node is a leaf node and contains no entries
+         */
+        public boolean isEmpty() {
+            return isLeaf() && entries.isEmpty();
+        }
+
+        /** Insert a new entry containing the given key and value. No check
+         * is made as to whether or not an entry already exists for the key.
+         * @param key key to insert
+         * @param value value to insert
+         */
+        public void insertEntry(final Vector3D key, final V value) {
+            if (isLeaf()) {
+                if (entries.size() < MAX_ENTRIES) {
+                    // we have an open spot here so just add the entry
+                    entries.add(new Vector3DEntry<>(key, value));
+                    return;
+                }
+
+                // no available entries; split the node and add to a child
+                splitNode();
+            }
+
+            // non-leaf node
+            // determine the relative location of the key
+            final int loc = getLocation(key);
+
+            // insert into the first child that can contain the key
+            for (int i = 0; i < NUM_CHILDREN; ++i) {
+                if (testChildLocation(i, loc)) {
+                    getOrCreateChild(i).insertEntry(key, value);
+                    break;
+                }
+            }
+        }
+
+        /** Get the entry matching the given key or null if not found.
+         * @param key key to search for
+         * @return the entry matching the given key or null if not found
+         */
+        public Vector3DEntry<V> getEntry(final Vector3D key) {
+            if (isLeaf()) {
+                // check the list of entries for a match
+                for (final Vector3DEntry<V> entry : entries) {
+                    if (key.eq(entry.getKey(), map.precision)) {
+                        return entry;
+                    }
+                }
+                // not found
+                return null;
+            }
+
+            // delegate to each child that could possibly contain the
+            // point or an equivalent point
+            final int loc = getLocation(key);
+            for (int i = 0; i < NUM_CHILDREN; ++i) {
+                if (testChildLocation(i, loc)) {
+                    final Vector3DEntry<V> entry = getEntryInChild(i, key);
+                    if (entry != null) {
+                        return entry;
+                    }
+                }
+            }
+
+            // not found
+            return null;
+        }
+
+        /** Remove the given key, returning the previously mapped entry.
+         * @param key key to remove
+         * @return the value previously mapped to the key or null if no
+         *       value was mapped
+         */
+        public Vector3DEntry<V> removeEntry(final Vector3D key) {
+            if (isLeaf()) {
+                // check the existing entries for a match
+                final Iterator<Vector3DEntry<V>> it = entries.iterator();
+                while (it.hasNext()) {
+                    final Vector3DEntry<V> entry = it.next();
+                    if (key.eq(entry.getKey(), map.precision)) {
+                        it.remove();
+                        return entry;
+                    }
+                }
+
+                // not found
+                return null;
+            }
+
+            // look through children
+            final int loc = getLocation(key);
+            for (int i = 0; i < NUM_CHILDREN; ++i) {
+                if (testChildLocation(i, loc)) {
+                    final Vector3DEntry<V> entry = removeFromChild(i, key);
+                    if (entry != null) {
+
+                        checkMakeLeaf();
+
+                        return entry;
+                    }
+                }
+            }
+
+            // not found
+            return null;
+        }
+
+        /** Get the given entry in the child at {@code idx} or null if not found.
+         * @param idx child index
+         * @param key key to search for
+         * @return entry matching {@code key} in child or null if not found
+         */
+        private Vector3DEntry<V> getEntryInChild(final int idx, final Vector3D key) {
+            final VariableSplitOctreeNode<V> child = children.get(idx);
+            if (child != null) {
+                return child.getEntry(key);
+            }
+            return null;
+        }
+
+        /** Remove the given key from the child at {@code idx}.
+         * @param idx index of the child
+         * @param key key to remove
+         * @return entry removed from the child or null if not found
+         */
+        private Vector3DEntry<V> removeFromChild(final int idx, final Vector3D key) {
+            final VariableSplitOctreeNode<V> child = children.get(idx);
+            if (child != null) {
+                return child.removeEntry(key);
+            }
+            return null;
+        }
+
+        /** Split the node and place all entries into the new child nodes.
+         * This node becomes an internal node.
+         */
+        private void splitNode() {
+            splitPoint = computeCentroid();
+
+            children = new ArrayList<>(NUM_CHILDREN);
+            // add null placeholders entries for children these will be replaced
+            // with actual nodes
+            for (int i = 0; i < NUM_CHILDREN; ++i) {
+                children.add(null);
+            }
+
+            for (final Vector3DEntry<V> entry : entries) {
+                moveToChild(entry);
+            }
+
+            entries.clear();
+        }
+
+        /** Attempt to condense the subtree rooted at this internal node by converting
+         * it to a leaf if no children contain entries.
+         */
+        private void checkMakeLeaf() {
+         // go through all children and remove empty ones
+            boolean empty = true;
+            for (int i = 0; i < NUM_CHILDREN; ++i) {
+                final VariableSplitOctreeNode<V> child = children.get(i);
+                if (child != null && !child.isEmpty()) {
+                    empty = false;
+                    break;
+                }
+            }
+
+            if (empty) {
+                makeLeaf();
+            }
+        }
+
+        /** Make this node a leaf node.
+         */
+        private void makeLeaf() {
+            splitPoint = null;
+            children = null;
+        }
+
+        /** Move the previously created entry to a child node.
+         * @param entry entry to mode
+         */
+        private void moveToChild(final Vector3DEntry<V> entry) {
+            final int loc = getLocation(entry.getKey());
+
+            for (int i = 0; i < NUM_CHILDREN; ++i) {
+                // place the entry in the first child that contains it
+                if (testChildLocation(i, loc)) {
+                    getOrCreateChild(i).entries.add(entry);
+                    break;
+                }
+            }
+        }
+
+        /** Get the child node at the given index, creating it if needed.
+         * @param idx index of the child node
+         * @return child node at the given index
+         */
+        private VariableSplitOctreeNode<V> getOrCreateChild(final int idx) {
+            VariableSplitOctreeNode<V> child = children.get(idx);
+            if (child == null) {
+                child = new VariableSplitOctreeNode<>(map);
+                children.set(idx, child);
+            }
+            return child;
+        }
+
+        /** Get an int encoding the location of {@code pt} relative to the
+         * node split point.
+         * @param pt point to determine the relative location of
+         * @return encoded point location
+         */
+        private int getLocation(final Vector3D pt) {
+            int loc = getLocationValue(
+                    map.precision.compare(pt.getX(), splitPoint.getX()),
+                    XNEG,
+                    XPOS);
+            loc |= getLocationValue(
+                    map.precision.compare(pt.getY(), splitPoint.getY()),
+                    YNEG,
+                    YPOS);
+            loc |= getLocationValue(
+                    map.precision.compare(pt.getZ(), splitPoint.getZ()),
+                    ZNEG,
+                    ZPOS);
+
+            return loc;
+        }
+
+        /** Get the encoded location value for the given comparison value.
+         * @param cmp comparison result
+         * @param neg negative flag
+         * @param pos positive flag
+         * @return encoded location value
+         */
+        private int getLocationValue(final int cmp, final int neg, final int pos) {
+            if (cmp < 0) {
+                return neg;
+            } else if (cmp > 0) {
+                return pos;
+            }
+            return neg | pos;
+        }
+
+        /** Return true if the child node at {@code childIdx} matches the given
+         * encoded point location.
+         * @param childIdx child index to test
+         * @param loc encoded relative point location
+         * @return true if the child node a {@code childIdx} matches the location
+         */
+        private boolean testChildLocation(final int childIdx, final int loc) {
+            final int childLoc = CHILD_LOCATIONS[childIdx];
+            return (childLoc & loc) == childLoc;
+        }
+
+        /** Compute the centroid of all points currently in the node.
+         * @return centroid of the node points
+         */
+        private Vector3D computeCentroid() {
+            Vector3D.Sum sum = Vector3D.Sum.create();
+            for (Vector3DEntry<V> entry : entries) {
+                sum.add(entry.getKey());
+            }
+
+            return sum.get().multiply(1.0 / entries.size());
+        }
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/Vector3DEntry.java
similarity index 53%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java
copy to commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/Vector3DEntry.java
index 8ba46f3..cd03581 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java
+++ b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/Vector3DEntry.java
@@ -14,24 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.internal;
+package org.apache.commons.geometry.examples.jmh.euclidean.pointmap;
 
-/** Internal utility methods for <em>commons-geometry</em>.
+import java.util.AbstractMap;
+
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+
+/** Map entry class with a {@link Vector3D} key.
+ * @param <V> Map entry value type
  */
-public final class GeometryInternalUtils {
+public class Vector3DEntry<V> extends AbstractMap.SimpleEntry<Vector3D, V> {
 
-    /** Utility class; no instantiation. */
-    private GeometryInternalUtils() {}
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20211121L;
 
-    /** Return {@code true} if {@code a} is the same instance as {@code b}, as
-     * determined by the {@code ==} operator. This method exists primarily to
-     * document the fact that reference equality was intended and is not a
-     * programming error.
-     * @param a first instance
-     * @param b second instance
-     * @return {@code true} if the arguments are the exact same instance
+    /** Construct a new map entry.
+     * @param key entry key
+     * @param value entry value
      */
-    public static boolean sameInstance(final Object a, final Object b) {
-        return a == b;
+    public Vector3DEntry(final Vector3D key, final V value) {
+        super(key, value);
     }
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/package-info.java
similarity index 51%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java
copy to commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/package-info.java
index 8ba46f3..0a0aeea 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalUtils.java
+++ b/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/package-info.java
@@ -14,24 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.internal;
-
-/** Internal utility methods for <em>commons-geometry</em>.
+/**
+ * Benchmarks for candidate point map data structures and algorithms. The
+ * classes in this package are not complete implementations. Rather, they
+ * include just enough code to evaluate the algorithms in question.
  */
-public final class GeometryInternalUtils {
-
-    /** Utility class; no instantiation. */
-    private GeometryInternalUtils() {}
-
-    /** Return {@code true} if {@code a} is the same instance as {@code b}, as
-     * determined by the {@code ==} operator. This method exists primarily to
-     * document the fact that reference equality was intended and is not a
-     * programming error.
-     * @param a first instance
-     * @param b second instance
-     * @return {@code true} if the arguments are the exact same instance
-     */
-    public static boolean sameInstance(final Object a, final Object b) {
-        return a == b;
-    }
-}
+package org.apache.commons.geometry.examples.jmh.euclidean.pointmap;
diff --git a/commons-geometry-examples/examples-jmh/src/test/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/PointMapDataStructureTest.java b/commons-geometry-examples/examples-jmh/src/test/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/PointMapDataStructureTest.java
new file mode 100644
index 0000000..fc3a37b
--- /dev/null
+++ b/commons-geometry-examples/examples-jmh/src/test/java/org/apache/commons/geometry/examples/jmh/euclidean/pointmap/PointMapDataStructureTest.java
@@ -0,0 +1,235 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.examples.jmh.euclidean.pointmap;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.numbers.core.Precision;
+import org.apache.commons.numbers.core.Precision.DoubleEquivalence;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Base class for potential point map data structures. These tests are not
+ * meant to be complete. The are only intended to perform enough assertions
+ * to ensure that a potential algorithm is not missing any critical functionality.
+ */
+abstract class PointMapDataStructureTest {
+
+    private static final double EPS = 1e-1;
+
+    private static final Precision.DoubleEquivalence PRECISION =
+            Precision.doubleEquivalenceOfEpsilon(EPS);
+
+    /** Get a new map instance for testing.
+     * @param precision precision context to determine floating point equality
+     * @return a new map instance for testing.
+     */
+    abstract Map<Vector3D, Integer> getMap(Precision.DoubleEquivalence precision);
+
+    @Test
+    void testMapOperations_simple() {
+        // -- arrange
+        final Map<Vector3D, Integer> map = getMap(PRECISION);
+        final Vector3D a = Vector3D.of(1, 2, 3);
+        final Vector3D b = Vector3D.of(3, 4, 5);
+        final Vector3D c = Vector3D.of(6, 7, 8);
+
+        final Vector3D aAlt = Vector3D.of(1.09, 2.09, 3.09);
+        final Vector3D bAlt = Vector3D.of(2.91, 3.91, 4.91);
+        final Vector3D cAlt = Vector3D.of(6.09, 6.91, 8.09);
+
+        // -- act/assert
+        Assertions.assertNull(map.put(a, 1));
+        Assertions.assertNull(map.put(b, 2));
+        Assertions.assertNull(map.put(c, 3));
+
+        Assertions.assertEquals(3, map.size());
+
+        Assertions.assertEquals(1, map.get(a));
+        Assertions.assertEquals(1, map.get(aAlt));
+        Assertions.assertEquals(2, map.get(b));
+        Assertions.assertEquals(2, map.get(bAlt));
+        Assertions.assertEquals(3, map.get(c));
+        Assertions.assertEquals(3, map.get(cAlt));
+
+        Assertions.assertEquals(1, map.put(aAlt, -1));
+        Assertions.assertEquals(2, map.put(bAlt, -2));
+        Assertions.assertEquals(3, map.put(cAlt, -3));
+
+        Assertions.assertEquals(3, map.size());
+
+        Assertions.assertEquals(-1, map.get(a));
+        Assertions.assertEquals(-1, map.get(aAlt));
+        Assertions.assertEquals(-2, map.get(b));
+        Assertions.assertEquals(-2, map.get(bAlt));
+        Assertions.assertEquals(-3, map.get(c));
+        Assertions.assertEquals(-3, map.get(cAlt));
+
+        Assertions.assertEquals(-1, map.remove(aAlt));
+        Assertions.assertEquals(-2, map.remove(bAlt));
+        Assertions.assertEquals(-3, map.remove(cAlt));
+
+        Assertions.assertEquals(0, map.size());
+
+        Assertions.assertNull(map.get(a));
+        Assertions.assertNull(map.get(aAlt));
+        Assertions.assertNull(map.get(b));
+        Assertions.assertNull(map.get(bAlt));
+        Assertions.assertNull(map.get(c));
+        Assertions.assertNull(map.get(cAlt));
+    }
+
+    @Test
+    void testGetResolution_simple() {
+        // -- arrange
+        final Map<Vector3D, Integer> map = getMap(PRECISION);
+        final Vector3D v = Vector3D.ZERO;
+        final double smallDelta = 0.05;
+        final double largeDelta = 0.15;
+
+        map.put(v, 1);
+
+        // -- act/assert
+        EuclideanTestUtils.permute(-1, 1, 1, (x, y, z) -> {
+            final Vector3D pt = Vector3D.of(x, y, z).multiply(smallDelta);
+            Assertions.assertEquals(1, map.get(pt), () -> "Point " + pt + " not found in map");
+        });
+
+        EuclideanTestUtils.permuteSkipZero(-1, 1, 1, (x, y, z) -> {
+            final Vector3D pt = Vector3D.of(x, y, z).multiply(largeDelta);
+            Assertions.assertNull(map.get(pt), () -> "Point " + pt + " found in map");
+        });
+    }
+
+    @Test
+    void testGetResolution_populatedMap() {
+        // -- arrange
+        final Map<Vector3D, Integer> map = getMap(PRECISION);
+        final Vector3D v = Vector3D.ZERO;
+        final double smallDelta = 0.05;
+        final double largeDelta = 0.15;
+
+        // add a number of points about the origin to make sure the map is populated
+        // and we're not just dealing with trivial cases
+        final double insertDelta = 0.3;
+        EuclideanTestUtils.permuteSkipZero(-4, 4, 1, (x, y, z) ->
+            map.put(Vector3D.of(x, y, z).multiply(insertDelta), 0));
+
+        // add a point exactly at the origin
+        map.put(v, 1);
+
+        // -- act/assert
+        EuclideanTestUtils.permute(-1, 1, 1, (x, y, z) -> {
+            final Vector3D pt = Vector3D.of(x, y, z).multiply(smallDelta);
+            Assertions.assertEquals(1, map.get(pt), () -> "Point " + pt + " not found in map");
+        });
+
+        EuclideanTestUtils.permuteSkipZero(-1, 1, 1, (x, y, z) -> {
+            final Vector3D pt = Vector3D.of(x, y, z).multiply(largeDelta);
+            Assertions.assertNull(map.get(pt), () -> "Point " + pt + " found in map");
+        });
+    }
+
+    @Test
+    void testMapOperations_randomOrder() {
+        // -- arrange
+        final Map<Vector3D, Integer> map = getMap(PRECISION);
+        final Vector3D v = Vector3D.of(1, 2, -1);
+
+        final double start = -3.0;
+        final double stop = 3.0;
+        final double step = 0.3;
+
+        // -- act/assert
+
+        // populate the map with entries in a random order
+        final List<Vector3D> points = new ArrayList<>();
+        EuclideanTestUtils.permute(start, stop, step, (x, y, z) -> points.add(Vector3D.of(x, y, z)));
+        Collections.shuffle(points, new Random(1L));
+
+        points.forEach(p -> map.put(p, -1));
+        map.put(v, 1);
+
+        Assertions.assertEquals(1, map.get(v));
+
+        // assert that each entry has a value
+        EuclideanTestUtils.permute(start, stop, step, (x, y, z) -> {
+            final Vector3D k = Vector3D.of(x, y, z);
+            final int val = k.eq(v, PRECISION) ?
+                    1 :
+                    -1;
+
+            Assertions.assertEquals(val, map.get(k));
+        });
+
+        // remove entries
+        EuclideanTestUtils.permute(start, stop, step, (x, y, z) -> {
+            map.remove(Vector3D.of(x, y, z));
+        });
+        map.remove(v);
+
+        // check that we don't have anything left
+        Assertions.assertEquals(0, map.size());
+        Assertions.assertNull(map.get(v));
+    }
+
+    /** Unit test for the {@link VariableSplitOctree} data structure.
+     */
+    static class VariableSplitOctreeTest extends PointMapDataStructureTest {
+        /** {@inheritDoc} */
+        @Override
+        Map<Vector3D, Integer> getMap(final DoubleEquivalence precision) {
+            return new VariableSplitOctree<>(PRECISION);
+        }
+    }
+
+    /** Unit test for the {@link KDTreeTest} data structure.
+     */
+    static class KDTreeTest extends PointMapDataStructureTest {
+        /** {@inheritDoc} */
+        @Override
+        Map<Vector3D, Integer> getMap(final DoubleEquivalence precision) {
+            return new KDTree<>(PRECISION);
+        }
+    }
+
+    /** Unit test for the {@link RebuildingKDTreeTest} data structure.
+     */
+    static class RebuildingKDTreeTest extends PointMapDataStructureTest {
+        /** {@inheritDoc} */
+        @Override
+        Map<Vector3D, Integer> getMap(final DoubleEquivalence precision) {
+            return new RebuildingKDTree<>(PRECISION);
+        }
+    }
+
+    /** Unit test for the {@link BucketKDTree} data structure.
+     */
+    static class BucketKDTreeTest extends PointMapDataStructureTest {
+        /** {@inheritDoc} */
+        @Override
+        Map<Vector3D, Integer> getMap(final DoubleEquivalence precision) {
+            return new BucketKDTree<>(PRECISION);
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/PointMap1SImpl.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/PointMap1SImpl.java
new file mode 100644
index 0000000..879a217
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/PointMap1SImpl.java
@@ -0,0 +1,299 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.spherical;
+
+import java.util.AbstractSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Objects;
+import java.util.Set;
+
+import org.apache.commons.geometry.core.internal.AbstractPointMap1D;
+import org.apache.commons.geometry.spherical.oned.Point1S;
+import org.apache.commons.numbers.angle.Angle;
+import org.apache.commons.numbers.core.Precision;
+
+/** Internal {@link org.apache.commons.geometry.core.collection.PointMap PointMap}
+ * implementation for 1D spherical space. This class uses a {@link NavigableMap}
+ * internally with special logic to handle wrap around.
+ * @param <V> Map value type
+ */
+final class PointMap1SImpl<V>
+    extends AbstractPointMap1D<Point1S, V> {
+
+    /** Precision context used to determine floating point equality. */
+    private final Precision.DoubleEquivalence precision;
+
+    /** Minimum key in the map, or null if not known. */
+    private Point1S minKey;
+
+    /** Maximum key in the map, or null if not known. */
+    private Point1S maxKey;
+
+    /** Construct a new instance using the given precision object to determine
+     * floating point equality.
+     * @param precision object used to determine floating point equality
+     */
+    PointMap1SImpl(final Precision.DoubleEquivalence precision) {
+        super((a, b) -> precision.compare(a.getNormalizedAzimuth(), b.getNormalizedAzimuth()));
+        this.precision = precision;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean containsKey(final Object key) {
+        return getEntryInternal((Point1S) key) != null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V get(final Object key) {
+        return getValue(getEntryInternal((Point1S) key));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public V remove(final Object key) {
+        final Map.Entry<Point1S, V> entry = getEntryInternal((Point1S) key);
+        if (entry != null) {
+            final V result = getMap().remove(entry.getKey());
+
+            mapUpdated();
+
+            return result;
+        }
+
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void clear() {
+        getMap().clear();
+        mapUpdated();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<Point1S> keySet() {
+        return new KeySet();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<Entry<Point1S, V>> entrySet() {
+        return new EntrySet();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected Map.Entry<Point1S, V> getEntryInternal(final Point1S pt) {
+        final NavigableMap<Point1S, V> map = getMap();
+
+        final Map.Entry<Point1S, V> floor = map.floorEntry(pt);
+        if (floor != null && keyEq(pt, floor)) {
+            return floor;
+        } else {
+            if (pt.getNormalizedAzimuth() < Math.PI) {
+                if (wrapsLowToHigh(pt)) {
+                    return map.lastEntry();
+                }
+            } else if (wrapsHighToLow(pt)) {
+                return map.firstEntry();
+            }
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected V putInternal(final Point1S key, final V value) {
+        final NavigableMap<Point1S, V> map = getMap();
+
+        final Map.Entry<Point1S, V> entry = getEntryInternal(key);
+        if (entry != null) {
+            return map.put(entry.getKey(), value);
+        }
+
+        final V result = map.put(key, value);
+        mapUpdated();
+
+        return result;
+    }
+
+    /** Method called when the map is updated.
+     */
+    private void mapUpdated() {
+        minKey = null;
+        maxKey = null;
+    }
+
+    /** Return true if {@code pt} is directly equivalent to the key for {@code entry},
+     * without considering wrap around.
+     * @param pt point
+     * @param entry map entry
+     * @return true if {@code pt} is directly equivalent to the key for {@code entry},
+     *      without considering wrap around
+     */
+    private boolean keyEq(final Point1S pt, final Map.Entry<Point1S, V> entry) {
+        return precision.eq(pt.getNormalizedAzimuth(), entry.getKey().getNormalizedAzimuth());
+    }
+
+    /** Return true if the given point wraps around the zero point from high to low
+     * and is equivalent to the first point in the map.
+     * @param pt point to check
+     * @return true if the normalized azimuth of {@code pt} plus 2pi is equivalent
+     *      to the first key in the map
+     */
+    private boolean wrapsHighToLow(final Point1S pt) {
+        if (size() > 0) {
+            if (minKey == null) {
+                minKey = getMap().firstKey();
+            }
+
+            final double adjustedAz = pt.getNormalizedAzimuth() - Angle.TWO_PI;
+            return precision.eq(adjustedAz, minKey.getNormalizedAzimuth());
+        }
+
+        return false;
+    }
+
+    /** Return true if the given point wraps around the zero point from low to high
+     * and is equivalent to the last point in the map.
+     * @param pt point to check
+     * @return true if the normalized azimuth of {@code pt} minus 2pi is equivalent
+     *      to the first key in the map
+     */
+    private boolean wrapsLowToHigh(final Point1S pt) {
+        if (size() > 0) {
+            if (maxKey == null) {
+                maxKey = getMap().lastKey();
+            }
+
+            final double adjustedAz = pt.getNormalizedAzimuth() + Angle.TWO_PI;
+            return precision.eq(adjustedAz, maxKey.getNormalizedAzimuth());
+        }
+
+        return false;
+    }
+
+    /** Null-safe method to get the value from a map entry.
+     * @param <V> Value type
+     * @param entry map entry
+     * @return map value or null if {@code entry} is null
+     */
+    private static <V> V getValue(final Map.Entry<?, V> entry) {
+        return entry != null ?
+                entry.getValue() :
+                null;
+    }
+
+    /** Key set view of the map.
+     */
+    private final class KeySet
+        extends AbstractSet<Point1S> {
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean contains(final Object obj) {
+            return PointMap1SImpl.this.containsKey(obj);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int size() {
+            return PointMap1SImpl.this.size();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Iterator<Point1S> iterator() {
+            return new MapIterator<>(getMap().keySet().iterator());
+        }
+    }
+
+    /** Entry set view of the map.
+     */
+    private final class EntrySet
+        extends AbstractSet<Map.Entry<Point1S, V>> {
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean contains(final Object obj) {
+            if (obj instanceof Map.Entry) {
+                final Map.Entry<?, ?> search = (Map.Entry<?, ?>) obj;
+                final Object key = search.getKey();
+
+                final Map.Entry<Point1S, V> actual = getEntry((Point1S) key);
+                if (actual != null) {
+                    return actual.getKey().eq((Point1S) search.getKey(), precision) &&
+                            Objects.equals(actual.getValue(), search.getValue());
+                }
+            }
+            return false;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int size() {
+            return PointMap1SImpl.this.size();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Iterator<Entry<Point1S, V>> iterator() {
+            return new MapIterator<>(getMap().entrySet().iterator());
+        }
+    }
+
+    /** Iterator for iterating through elements in the map.
+     * @param <E> Element type
+     */
+    private final class MapIterator<E>
+        implements Iterator<E> {
+
+        /** Underlying iterator. */
+        private final Iterator<E> it;
+
+        /** Construct a new instance that wraps the given iterator.
+         * @param it underlying iterator
+         */
+        MapIterator(final Iterator<E> it) {
+            this.it = it;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean hasNext() {
+            return it.hasNext();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public E next() {
+            return it.next();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void remove() {
+            it.remove();
+            mapUpdated();
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/PointMap2SImpl.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/PointMap2SImpl.java
new file mode 100644
index 0000000..a3c3484
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/PointMap2SImpl.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.geometry.spherical;
+
+import java.util.List;
+
+import org.apache.commons.geometry.core.collection.PointMap;
+import org.apache.commons.geometry.core.internal.AbstractBucketPointMap;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.spherical.twod.GreatCircle;
+import org.apache.commons.geometry.spherical.twod.GreatCircles;
+import org.apache.commons.geometry.spherical.twod.Point2S;
+import org.apache.commons.numbers.core.Precision;
+
+/** Internal {@link PointMap} implementation for 2D spherical space.
+ * @param <V> Map value type
+ */
+final class PointMap2SImpl<V>
+    extends AbstractBucketPointMap<Point2S, V>
+    implements PointMap<Point2S, V> {
+
+    /** Number of children per node. */
+    private static final int NODE_CHILD_COUNT = 4;
+
+    /** Max entries per node. */
+    private static final int MAX_ENTRIES_PER_NODE = 16;
+
+    /** First negative quadrant flag. */
+    private static final int NEG1 = 1 << 3;
+
+    /** First positive quadrant flag. */
+    private static final int POS1 = 1 << 2;
+
+    /** Second negative quadrant flag. */
+    private static final int NEG2 = 1 << 1;
+
+    /** Second positive quadrant flag. */
+    private static final int POS2 = 1;
+
+    /** Location flags for child nodes. */
+    private static final int[] CHILD_LOCATIONS = {
+        NEG1 | NEG2,
+        NEG1 | POS2,
+        POS1 | NEG2,
+        POS1 | POS2
+    };
+
+    PointMap2SImpl(final Precision.DoubleEquivalence precision) {
+        super(MapNode2S::new,
+                MAX_ENTRIES_PER_NODE,
+                NODE_CHILD_COUNT,
+                precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean pointsEq(final Point2S a, final Point2S b) {
+        return a.eq(b, getPrecision());
+    }
+
+    /** Tree node class for {@link PointMap2SImpl}.
+     * @param <V> Map value type
+     */
+    private static final class MapNode2S<V>
+        extends BucketNode<Point2S, V> {
+
+        /** First hyperplane split. */
+        private GreatCircle firstSplit;
+
+        /** Second hyperplane split. */
+        private GreatCircle secondSplit;
+
+        MapNode2S(
+                final AbstractBucketPointMap<Point2S, V> map,
+                final BucketNode<Point2S, V> parent) {
+            super(map, parent);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected void computeSplit() {
+            final Vector3D.Sum sum = Vector3D.Sum.create();
+
+            for (Entry<Point2S, V> entry : this) {
+                sum.add(entry.getKey().getVector());
+            }
+
+            // construct an orthonormal basis
+            Vector3D.Unit u = sum.get().multiply(1.0 / MAX_ENTRIES_PER_NODE)
+                    .normalizeOrNull();
+            if (u == null) {
+                u = Vector3D.Unit.PLUS_X;
+            }
+
+            Vector3D.Unit v =  Vector3D.Unit.PLUS_Z.cross(u)
+                    .normalizeOrNull();
+            if (v == null) {
+                v = Vector3D.Unit.PLUS_Y.cross(u)
+                        .normalizeOrNull();
+            }
+            final Vector3D.Unit w = u.cross(v).normalize();
+
+            // construct the two great circles
+            firstSplit = GreatCircles.fromPole(v.add(w), getPrecision());
+            secondSplit = GreatCircles.fromPole(v.negate().add(w), getPrecision());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected int getSearchLocation(final Point2S pt) {
+            int loc = getSearchLocationValue(firstSplit.classify(pt), NEG1, POS1);
+
+            loc |= getSearchLocationValue(secondSplit.classify(pt), NEG2, POS2);
+
+            return loc;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected int getInsertLocation(final Point2S pt) {
... 840 lines suppressed ...