From 7d57ce2608b07f03762d912ae4034667a4aa321e Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 28 Dec 2025 00:44:07 -0500 Subject: [PATCH] feat: Implement SpatialHash for O(1) entity spatial queries (closes #115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SpatialHash class for efficient spatial queries on entities: - New SpatialHash.h/cpp with bucket-based spatial hashing - Grid.entities_in_radius(x, y, radius) method for O(k) queries - Automatic spatial hash updates on entity add/remove/move Benchmark results at 2,000 entities: - Single query: 16.2× faster (0.044ms → 0.003ms) - N×N visibility: 104.8× faster (74ms → 1ms) This enables efficient range queries for AI, visibility, and collision detection without scanning all entities. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/SpatialHash.cpp | 193 +++++++++++++++++++++ src/SpatialHash.h | 81 +++++++++ src/UIEntity.cpp | 34 +++- src/UIGrid.cpp | 93 +++++++++- src/UIGrid.h | 5 + tests/benchmarks/entity_scale_benchmark.py | 86 ++++++++- 6 files changed, 477 insertions(+), 15 deletions(-) create mode 100644 src/SpatialHash.cpp create mode 100644 src/SpatialHash.h diff --git a/src/SpatialHash.cpp b/src/SpatialHash.cpp new file mode 100644 index 0000000..9902d94 --- /dev/null +++ b/src/SpatialHash.cpp @@ -0,0 +1,193 @@ +#include "SpatialHash.h" +#include "UIEntity.h" +#include + +SpatialHash::SpatialHash(int bucket_size) + : bucket_size(bucket_size) +{ +} + +void SpatialHash::insert(std::shared_ptr entity) +{ + if (!entity) return; + + auto bucket_coord = getBucket(entity->position.x, entity->position.y); + buckets[bucket_coord].push_back(entity); +} + +void SpatialHash::remove(std::shared_ptr entity) +{ + if (!entity) return; + + auto bucket_coord = getBucket(entity->position.x, entity->position.y); + auto it = buckets.find(bucket_coord); + if (it == buckets.end()) return; + + auto& bucket = it->second; + + // Remove the entity from the bucket + bucket.erase( + std::remove_if(bucket.begin(), bucket.end(), + [&entity](const std::weak_ptr& wp) { + auto sp = wp.lock(); + return !sp || sp == entity; + }), + bucket.end() + ); + + // Remove empty buckets to save memory + if (bucket.empty()) { + buckets.erase(it); + } +} + +void SpatialHash::update(std::shared_ptr entity, float old_x, float old_y) +{ + if (!entity) return; + + auto old_bucket = getBucket(old_x, old_y); + auto new_bucket = getBucket(entity->position.x, entity->position.y); + + // Only update if bucket changed + if (old_bucket == new_bucket) return; + + // Remove from old bucket + auto it = buckets.find(old_bucket); + if (it != buckets.end()) { + auto& bucket = it->second; + bucket.erase( + std::remove_if(bucket.begin(), bucket.end(), + [&entity](const std::weak_ptr& wp) { + auto sp = wp.lock(); + return !sp || sp == entity; + }), + bucket.end() + ); + if (bucket.empty()) { + buckets.erase(it); + } + } + + // Add to new bucket + buckets[new_bucket].push_back(entity); +} + +std::vector> SpatialHash::getBucketsInRadius(float x, float y, float radius) const +{ + std::vector> result; + + // Get bounding box in bucket coordinates + int min_bx = static_cast(std::floor((x - radius) / bucket_size)); + int max_bx = static_cast(std::floor((x + radius) / bucket_size)); + int min_by = static_cast(std::floor((y - radius) / bucket_size)); + int max_by = static_cast(std::floor((y + radius) / bucket_size)); + + // Iterate all buckets in the bounding box + for (int bx = min_bx; bx <= max_bx; ++bx) { + for (int by = min_by; by <= max_by; ++by) { + result.emplace_back(bx, by); + } + } + + return result; +} + +std::vector> SpatialHash::getBucketsInRect(float x, float y, float width, float height) const +{ + std::vector> result; + + int min_bx = static_cast(std::floor(x / bucket_size)); + int max_bx = static_cast(std::floor((x + width) / bucket_size)); + int min_by = static_cast(std::floor(y / bucket_size)); + int max_by = static_cast(std::floor((y + height) / bucket_size)); + + for (int bx = min_bx; bx <= max_bx; ++bx) { + for (int by = min_by; by <= max_by; ++by) { + result.emplace_back(bx, by); + } + } + + return result; +} + +std::vector> SpatialHash::queryRadius(float x, float y, float radius) const +{ + std::vector> result; + float radius_sq = radius * radius; + + auto bucket_coords = getBucketsInRadius(x, y, radius); + + for (const auto& coord : bucket_coords) { + auto it = buckets.find(coord); + if (it == buckets.end()) continue; + + for (const auto& wp : it->second) { + auto entity = wp.lock(); + if (!entity) continue; + + // Check if entity is actually within the circular radius + float dx = entity->position.x - x; + float dy = entity->position.y - y; + if (dx * dx + dy * dy <= radius_sq) { + result.push_back(entity); + } + } + } + + return result; +} + +std::vector> SpatialHash::queryRect(float x, float y, float width, float height) const +{ + std::vector> result; + + auto bucket_coords = getBucketsInRect(x, y, width, height); + + for (const auto& coord : bucket_coords) { + auto it = buckets.find(coord); + if (it == buckets.end()) continue; + + for (const auto& wp : it->second) { + auto entity = wp.lock(); + if (!entity) continue; + + // Check if entity is within the rectangle + float ex = entity->position.x; + float ey = entity->position.y; + if (ex >= x && ex < x + width && ey >= y && ey < y + height) { + result.push_back(entity); + } + } + } + + return result; +} + +void SpatialHash::clear() +{ + buckets.clear(); +} + +size_t SpatialHash::totalEntities() const +{ + size_t count = 0; + for (const auto& [coord, bucket] : buckets) { + for (const auto& wp : bucket) { + if (wp.lock()) { + ++count; + } + } + } + return count; +} + +void SpatialHash::cleanBucket(std::vector>& bucket) +{ + bucket.erase( + std::remove_if(bucket.begin(), bucket.end(), + [](const std::weak_ptr& wp) { + return wp.expired(); + }), + bucket.end() + ); +} diff --git a/src/SpatialHash.h b/src/SpatialHash.h new file mode 100644 index 0000000..8bc9828 --- /dev/null +++ b/src/SpatialHash.h @@ -0,0 +1,81 @@ +#pragma once +#include +#include +#include +#include + +class UIEntity; + +/** + * SpatialHash - O(1) average spatial queries for entities (#115) + * + * Divides the grid into buckets and tracks which entities are in each bucket. + * Queries only check entities in nearby buckets instead of all entities. + * + * Performance characteristics: + * - Insert: O(1) + * - Remove: O(n) where n = entities in bucket (typically small) + * - Update position: O(n) where n = entities in bucket + * - Query radius: O(k) where k = entities in checked buckets (vs O(N) for all entities) + */ +class SpatialHash { +public: + // Default bucket size of 32 cells balances memory and query performance + explicit SpatialHash(int bucket_size = 32); + + // Insert entity into spatial hash based on current position + void insert(std::shared_ptr entity); + + // Remove entity from spatial hash + void remove(std::shared_ptr entity); + + // Update entity position - call when entity moves + // This removes from old bucket and inserts into new bucket if needed + void update(std::shared_ptr entity, float old_x, float old_y); + + // Query all entities within radius of a point + // Returns entities whose positions are within the circular radius + std::vector> queryRadius(float x, float y, float radius) const; + + // Query all entities within a rectangular region + std::vector> queryRect(float x, float y, float width, float height) const; + + // Clear all entities from the hash + void clear(); + + // Get statistics for debugging + size_t bucketCount() const { return buckets.size(); } + size_t totalEntities() const; + +private: + int bucket_size; + + // Hash function for bucket coordinates + struct PairHash { + size_t operator()(const std::pair& p) const { + // Combine hash of both coordinates + return std::hash()(p.first) ^ (std::hash()(p.second) << 16); + } + }; + + // Map from bucket coordinates to list of entities in that bucket + // Using weak_ptr to avoid preventing entity deletion + std::unordered_map, std::vector>, PairHash> buckets; + + // Get bucket coordinates for a world position + std::pair getBucket(float x, float y) const { + return { + static_cast(std::floor(x / bucket_size)), + static_cast(std::floor(y / bucket_size)) + }; + } + + // Get all bucket coordinates that overlap with a radius query + std::vector> getBucketsInRadius(float x, float y, float radius) const; + + // Get all bucket coordinates that overlap with a rectangle + std::vector> getBucketsInRect(float x, float y, float width, float height) const; + + // Clean expired weak_ptrs from a bucket + void cleanBucket(std::vector>& bucket); +}; diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index b4beeed..bc6b5dc 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -370,6 +370,10 @@ PyObject* UIEntity::get_position(PyUIEntityObject* self, void* closure) { } int UIEntity::set_position(PyUIEntityObject* self, PyObject* value, void* closure) { + // Save old position for spatial hash update (#115) + float old_x = self->data->position.x; + float old_y = self->data->position.y; + if (reinterpret_cast(closure) == 0) { sf::Vector2f vec = PyObject_to_sfVector2f(value); if (PyErr_Occurred()) { @@ -382,9 +386,15 @@ int UIEntity::set_position(PyUIEntityObject* self, PyObject* value, void* closur if (PyErr_Occurred()) { return -1; // Error already set by PyObject_to_sfVector2i } - self->data->position = sf::Vector2f(static_cast(vec.x), + self->data->position = sf::Vector2f(static_cast(vec.x), static_cast(vec.y)); } + + // Update spatial hash if grid exists (#115) + if (self->data->grid) { + self->data->grid->spatial_hash.update(self->data, old_x, old_y); + } + return 0; } @@ -438,6 +448,11 @@ int UIEntity::set_float_member(PyUIEntityObject* self, PyObject* value, void* cl PyErr_SetString(PyExc_TypeError, "Position must be a number (int or float)"); return -1; } + + // Save old position for spatial hash update (#115) + float old_x = self->data->position.x; + float old_y = self->data->position.y; + if (member_ptr == 0) // x { self->data->position.x = val; @@ -446,6 +461,12 @@ int UIEntity::set_float_member(PyUIEntityObject* self, PyObject* value, void* cl { self->data->position.y = val; } + + // Update spatial hash if grid exists (#115) + if (self->data->grid) { + self->data->grid->spatial_hash.update(self->data, old_x, old_y); + } + return 0; } @@ -541,23 +562,26 @@ PyObject* UIEntity::die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)) if (!self->data || !self->data->grid) { Py_RETURN_NONE; // Entity not on a grid, nothing to do } - + // Remove entity from grid's entity list auto grid = self->data->grid; auto& entities = grid->entities; - + // Find and remove this entity from the list auto it = std::find_if(entities->begin(), entities->end(), [self](const std::shared_ptr& e) { return e.get() == self->data.get(); }); - + if (it != entities->end()) { + // Remove from spatial hash before erasing (#115) + grid->spatial_hash.remove(self->data); + entities->erase(it); // Clear the grid reference self->data->grid.reset(); } - + Py_RETURN_NONE; } diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index e44454a..b58fcec 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -1673,6 +1673,63 @@ PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) { Py_RETURN_NONE; } +// #115 - Spatial hash query for entities in radius +PyObject* UIGrid::py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"x", "y", "radius", NULL}; + float x, y, radius; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "fff", const_cast(kwlist), + &x, &y, &radius)) { + return NULL; + } + + if (radius < 0) { + PyErr_SetString(PyExc_ValueError, "radius must be non-negative"); + return NULL; + } + + // Query spatial hash for entities in radius + auto entities = self->data->spatial_hash.queryRadius(x, y, radius); + + // Create result list + PyObject* result = PyList_New(entities.size()); + if (!result) return PyErr_NoMemory(); + + // Cache Entity type for efficiency + static PyTypeObject* cached_entity_type = nullptr; + if (!cached_entity_type) { + cached_entity_type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); + if (!cached_entity_type) { + Py_DECREF(result); + return NULL; + } + Py_INCREF(cached_entity_type); + } + + for (size_t i = 0; i < entities.size(); i++) { + auto& entity = entities[i]; + + // Return stored Python object if it exists + if (entity->self != nullptr) { + Py_INCREF(entity->self); + PyList_SET_ITEM(result, i, entity->self); + } else { + // Create new Python Entity wrapper + auto pyEntity = (PyUIEntityObject*)cached_entity_type->tp_alloc(cached_entity_type, 0); + if (!pyEntity) { + Py_DECREF(result); + return PyErr_NoMemory(); + } + pyEntity->data = entity; + pyEntity->weakreflist = NULL; + PyList_SET_ITEM(result, i, (PyObject*)pyEntity); + } + } + + return result; +} + PyMethodDef UIGrid::methods[] = { {"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS}, {"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS, @@ -1752,6 +1809,15 @@ PyMethodDef UIGrid::methods[] = { "remove_layer(layer: ColorLayer | TileLayer) -> None"}, {"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS, "layer(z_index: int) -> ColorLayer | TileLayer | None"}, + {"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS, + "entities_in_radius(x: float, y: float, radius: float) -> list[Entity]\n\n" + "Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n" + "Args:\n" + " x: Center X coordinate\n" + " y: Center Y coordinate\n" + " radius: Search radius\n\n" + "Returns:\n" + " List of Entity objects within the radius."}, {NULL, NULL, 0, NULL} }; @@ -1854,6 +1920,15 @@ PyMethodDef UIGrid_all_methods[] = { " z_index: The z_index of the layer to find.\n\n" "Returns:\n" " The layer with the specified z_index, or None if not found."}, + {"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS, + "entities_in_radius(x: float, y: float, radius: float) -> list[Entity]\n\n" + "Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n" + "Args:\n" + " x: Center X coordinate\n" + " y: Center Y coordinate\n" + " radius: Search radius\n\n" + "Returns:\n" + " List of Entity objects within the radius."}, {NULL} // Sentinel }; @@ -2414,13 +2489,16 @@ PyObject* UIEntityCollection::append(PyUIEntityCollectionObject* self, PyObject* // Remove from old grid first (if different from target grid) // This implements the documented "single grid only" behavior if (entity->data->grid && entity->data->grid != self->grid) { - auto& old_entities = entity->data->grid->entities; + auto old_grid = entity->data->grid; + auto& old_entities = old_grid->entities; auto it = std::find_if(old_entities->begin(), old_entities->end(), [entity](const std::shared_ptr& e) { return e.get() == entity->data.get(); }); if (it != old_entities->end()) { old_entities->erase(it); + // Remove from old grid's spatial hash (#115) + old_grid->spatial_hash.remove(entity->data); } } @@ -2428,6 +2506,10 @@ PyObject* UIEntityCollection::append(PyUIEntityCollectionObject* self, PyObject* if (entity->data->grid != self->grid) { self->data->push_back(entity->data); entity->data->grid = self->grid; + // Add to spatial hash for O(1) queries (#115) + if (self->grid) { + self->grid->spatial_hash.insert(entity->data); + } } // Initialize gridstate if not already done @@ -2471,12 +2553,17 @@ PyObject* UIEntityCollection::remove(PyUIEntityCollectionObject* self, PyObject* auto it = list->begin(); while (it != list->end()) { if (it->get() == entity->data.get()) { + // Remove from spatial hash before clearing grid (#115) + if (self->grid) { + self->grid->spatial_hash.remove(*it); + } + // Found it - clear grid reference before removing (*it)->grid = nullptr; - + // Remove from the list self->data->erase(it); - + Py_INCREF(Py_None); return Py_None; } diff --git a/src/UIGrid.h b/src/UIGrid.h index 8c10491..2af03b7 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -22,6 +22,7 @@ #include "UIBase.h" #include "GridLayers.h" #include "GridChunk.h" +#include "SpatialHash.h" class UIGrid: public UIDrawable { @@ -87,6 +88,9 @@ public: std::shared_ptr>> entities; + // Spatial hash for O(1) entity queries (#115) + SpatialHash spatial_hash; + // UIDrawable children collection (speech bubbles, effects, overlays, etc.) std::shared_ptr>> children; bool children_need_sort = true; // Dirty flag for z_index sorting @@ -165,6 +169,7 @@ public: static PyObject* py_get_dijkstra_distance(PyUIGridObject* self, PyObject* args); static PyObject* py_get_dijkstra_path(PyUIGridObject* self, PyObject* args); static PyObject* py_compute_astar_path(PyUIGridObject* self, PyObject* args, PyObject* kwds); + static PyObject* py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds); // #115 static PyMethodDef methods[]; static PyGetSetDef getsetters[]; static PyObject* get_entities(PyUIGridObject* self, void* closure); diff --git a/tests/benchmarks/entity_scale_benchmark.py b/tests/benchmarks/entity_scale_benchmark.py index e53f9e3..3bfae69 100644 --- a/tests/benchmarks/entity_scale_benchmark.py +++ b/tests/benchmarks/entity_scale_benchmark.py @@ -138,6 +138,16 @@ def benchmark_range_query(entity, radius): return elapsed * 1000, len(visible) # ms, count +def benchmark_range_query_spatial(grid, x, y, radius): + """B3b: Measure grid.entities_in_radius call (SpatialHash O(k) implementation).""" + start = time.perf_counter() + + visible = grid.entities_in_radius(x, y, radius) + + elapsed = time.perf_counter() - start + return elapsed * 1000, len(visible) # ms, count + + def benchmark_n_to_n_visibility(grid, radius, sample_size): """B4: Measure visibility queries for a sample of entities. @@ -162,6 +172,29 @@ def benchmark_n_to_n_visibility(grid, radius, sample_size): return elapsed * 1000, actual_sample, avg_visible # ms, sample_size, avg_found +def benchmark_n_to_n_visibility_spatial(grid, radius, sample_size): + """B4b: Measure N×N visibility using SpatialHash. + + Same test as B4 but uses grid.entities_in_radius() instead of entity.visible_entities(). + """ + entities_list = list(grid.entities) + n = len(entities_list) + actual_sample = min(sample_size, n) + sample = random.sample(entities_list, actual_sample) + + start = time.perf_counter() + + total_visible = 0 + for entity in sample: + visible = grid.entities_in_radius(entity.x, entity.y, radius) + total_visible += len(visible) + + elapsed = time.perf_counter() - start + avg_visible = total_visible / actual_sample if actual_sample > 0 else 0 + + return elapsed * 1000, actual_sample, avg_visible # ms, sample_size, avg_found + + def benchmark_movement(grid, move_percent): """B5: Move a percentage of entities to random positions. @@ -232,8 +265,19 @@ def run_single_scale(n_entities): print(f" Found: {found} entities in range") print(f" Checked: {n_entities} entities (O(n) scan)") + # B3b: SpatialHash range query + print(f"\n[B3b] SpatialHash Range Query (radius={QUERY_RADIUS})...") + spatial_query_ms, spatial_found = benchmark_range_query_spatial( + grid, test_entity.x, test_entity.y, QUERY_RADIUS + ) + print(f" Time: {spatial_query_ms:.3f}ms") + print(f" Found: {spatial_found} entities in range") + if query_ms > 0: + speedup = query_ms / spatial_query_ms if spatial_query_ms > 0 else float('inf') + print(f" Speedup: {speedup:.1f}× faster than O(n) scan") + # B4: N-to-N visibility - print(f"\n[B4] N×N Visibility (sample={N2N_SAMPLE_SIZE})...") + print(f"\n[B4] N×N Visibility O(n) (sample={N2N_SAMPLE_SIZE})...") n2n_ms, sample_size, avg_visible = benchmark_n_to_n_visibility( grid, QUERY_RADIUS, N2N_SAMPLE_SIZE ) @@ -246,6 +290,20 @@ def run_single_scale(n_entities): full_n2n_ms = per_query_ms * n_entities print(f" Estimated full N×N: {full_n2n_ms:,.0f}ms ({full_n2n_ms/1000:.1f}s)") + # B4b: N-to-N visibility with SpatialHash + print(f"\n[B4b] N×N Visibility SpatialHash (sample={N2N_SAMPLE_SIZE})...") + n2n_spatial_ms, _, _ = benchmark_n_to_n_visibility_spatial( + grid, QUERY_RADIUS, N2N_SAMPLE_SIZE + ) + per_query_spatial_ms = n2n_spatial_ms / sample_size if sample_size > 0 else 0 + print(f" Sample time: {n2n_spatial_ms:.2f}ms ({sample_size} queries)") + print(f" Per query: {per_query_spatial_ms:.3f}ms") + full_n2n_spatial_ms = per_query_spatial_ms * n_entities + print(f" Estimated full N×N: {full_n2n_spatial_ms:,.0f}ms ({full_n2n_spatial_ms/1000:.1f}s)") + if n2n_ms > 0: + n2n_speedup = n2n_ms / n2n_spatial_ms if n2n_spatial_ms > 0 else float('inf') + print(f" Speedup: {n2n_speedup:.1f}× faster than O(n)") + # B5: Movement print(f"\n[B5] Movement ({MOVEMENT_PERCENT*100:.0f}% of entities)...") move_ms, moved = benchmark_movement(grid, MOVEMENT_PERCENT) @@ -261,10 +319,14 @@ def run_single_scale(n_entities): 'iter_coll_ms': iter_coll_ms, 'query_ms': query_ms, 'query_found': found, + 'spatial_query_ms': spatial_query_ms, + 'spatial_query_found': spatial_found, 'n2n_sample_ms': n2n_ms, 'n2n_per_query_ms': per_query_ms, 'n2n_avg_visible': avg_visible, 'n2n_full_estimate_ms': full_n2n_ms, + 'n2n_spatial_ms': n2n_spatial_ms, + 'n2n_spatial_full_estimate_ms': full_n2n_spatial_ms, 'move_ms': move_ms, 'move_count': moved, } @@ -272,19 +334,29 @@ def run_single_scale(n_entities): def print_summary_table(): """Print a summary table of all results.""" - print("\n" + "=" * 80) + print("\n" + "=" * 100) print("SUMMARY TABLE") - print("=" * 80) + print("=" * 100) - header = f"{'Entities':>10} {'Create':>10} {'Iterate':>10} {'Query':>10} {'N×N Est':>12} {'Move':>10}" + header = f"{'Entities':>10} {'Create':>10} {'Iterate':>10} {'Query O(n)':>12} {'Query Hash':>12} {'N×N O(n)':>12} {'N×N Hash':>12}" print(header) - print(f"{'':>10} {'(ms)':>10} {'(ms)':>10} {'(ms)':>10} {'(ms)':>12} {'(ms)':>10}") - print("-" * 80) + print(f"{'':>10} {'(ms)':>10} {'(ms)':>10} {'(ms)':>12} {'(ms)':>12} {'(ms)':>12} {'(ms)':>12}") + print("-" * 100) for n in sorted(results.keys()): r = results[n] + speedup_q = r['query_ms'] / r['spatial_query_ms'] if r['spatial_query_ms'] > 0 else 0 + speedup_n = r['n2n_full_estimate_ms'] / r['n2n_spatial_full_estimate_ms'] if r['n2n_spatial_full_estimate_ms'] > 0 else 0 print(f"{r['n']:>10,} {r['create_ms']:>10.1f} {r['iter_ms']:>10.2f} " - f"{r['query_ms']:>10.2f} {r['n2n_full_estimate_ms']:>12,.0f} {r['move_ms']:>10.2f}") + f"{r['query_ms']:>12.3f} {r['spatial_query_ms']:>12.3f} " + f"{r['n2n_full_estimate_ms']:>12,.0f} {r['n2n_spatial_full_estimate_ms']:>12,.0f}") + + print("\nSpatialHash Speedups:") + for n in sorted(results.keys()): + r = results[n] + speedup_q = r['query_ms'] / r['spatial_query_ms'] if r['spatial_query_ms'] > 0 else float('inf') + speedup_n = r['n2n_full_estimate_ms'] / r['n2n_spatial_full_estimate_ms'] if r['n2n_spatial_full_estimate_ms'] > 0 else float('inf') + print(f" {r['n']:>6,} entities: Query {speedup_q:>5.1f}×, N×N {speedup_n:>5.1f}×") def print_analysis():