Distinguish duplicate feature locations during the index sort - #402
Open
e-n-f wants to merge 2 commits into
Open
Distinguish duplicate feature locations during the index sort#402e-n-f wants to merge 2 commits into
e-n-f wants to merge 2 commits into
Conversation
Tippecanoe's behavior with as-needed dropping or coalescing has been to treat features that share a representative point with some other feature as having infinite density, therefore being the first to be dropped or coalesced when the tile size needs to be reduced. That interacts badly with datasets that intentionally repeat geometries with different sets of attributes, expecting the client to filter for one set or another, since only the first instance of each location survives. The new --distinguish-duplicates option defers the features that share a location to later passes through the feature sequence: all the distinct locations are written first, then the first duplicate of each location, then the second, and so on, so that each duplicate is dropped or coalesced within its own pass instead of into its twin. The deferral happens where the features are already being put into index order, in radix1()/merge(), rather than in tile.cpp: the duplicates are set aside in a temporary file per pass and appended to the sorted geometry once the rest of it has been written. The index stays in index order, with the records for the deferred features corrected once their geometry has been placed, so the maxzoom, base zoom, and drop rate guessing that reads the index afterward is unaffected. Up to 50 duplicates of each location are distinguished. Beyond that, they are all deferred to one final pass, where some of them will still have to be dropped or coalesced in some sequence, since otherwise a location with a huge number of duplicates would need an unbounded number of passes. Folding the two feature-writing paths in radix1() into one write_feature() also fixes a latent bug in the path taken for a single oversized feature or maximum radix recursion depth, which wrote the feature's whole serialized length and then appended another minzoom byte, corrupting the geometry stream. Before this, --prefer-radix-sort failed with "wrong length decoding feature" on several of the existing test inputs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011wLk2itWETPBAS9a9yE8zu
This was referenced Aug 5, 2026
There was a problem hiding this comment.
Pull request overview
This PR reintroduces --distinguish-duplicates by deferring duplicate feature locations during the radix/index sort so duplicate geometries are processed in later passes, improving “as-needed” dropping/coalescing behavior for intentionally duplicated geometries. It also updates docs/tests and bumps the release version.
Changes:
- Implement duplicate-location deferral during
radix1()/merge()sorting inmain.cpp, with deferred-pass replay that preserves index order. - Add the
--distinguish-duplicatesoption plumbing (options.hpp,serial.cpp) and update documentation (README.md,man/tippecanoe.1,CHANGELOG.md). - Add a dedicated regression test fixture (
tests/distinguish-duplicates/*) and bump version to 2.81.0.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
main.cpp |
Adds duplicate deferral/replay mechanism during index sort and suppresses “Mismatched index” diagnostic under the option. |
serial.cpp |
Ensures feature index is preserved when --distinguish-duplicates is enabled. |
options.hpp |
Introduces A_DISTINGUISH_DUPLICATES flag constant. |
README.md |
Documents the new --distinguish-duplicates option. |
man/tippecanoe.1 |
Adds manpage entry for --distinguish-duplicates. |
tests/distinguish-duplicates/in.json |
Adds new test input with repeated point geometries. |
tests/distinguish-duplicates/out/-z1_-r1_-b0_--distinguish-duplicates.json |
Adds expected output for the new test. |
CHANGELOG.md |
Adds 2.81.0 entry noting the new option. |
version.hpp |
Bumps version string to v2.81.0. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reimplements #349 (
--distinguish-duplicates) by deferring duplicate locations in the index sort inmain.cppinstead of buffering them into temporary files per tile intile.cpp.Background
Tippecanoe's behavior with as-needed dropping or coalescing has been to treat features that share a representative point with some other feature as having infinite density, therefore being the first to be dropped or coalesced when the tile size needs to be reduced. That interacts badly with datasets that intentionally repeat geometries with different sets of attributes, expecting the client to filter for one set or another, since only the first instance of each location survives.
--distinguish-duplicatesdefers the features that share a location to later passes through the feature sequence: all the distinct locations are written first, then the first duplicate of each location, then the second, and so on, so that each duplicate is dropped or coalesced within its own pass instead of into its twin.Approach
The features are already being put into index order by
radix1()/merge(), so the deferral happens there. A feature whose index matches the previous feature's is set aside in a temporary file for its pass, andreplay_deferrals()appends the passes in order once the whole radix traversal has finished.tile.cppis not touched, and the deferral applies uniformly to every zoom level rather than only to the first one.Up to 50 duplicates of each location are distinguished. Beyond that they are all deferred to one final pass, where some of them will still have to be dropped or coalesced in some sequence, since otherwise a location with a huge number of duplicates would need an unbounded number of passes.
The index stays in index order
The geometry ends up in deferred order, but the index is still written in index order, because three later passes over it assume quadkey order:
-zgmaxzoom guessing, which measures the gaps between consecutive distinct indices-Bg/-rgbase zoom and drop rate guessing, which groups consecutive features into tiles — this runs whenever the base zoom is unspecified, so it is the common path, and writing the index in deferred order silently spoils it, sincetile[z]resets on every pass and a tile's count gets measured per pass instead of per tile--drop-denserKeeping index order also means
calc_feature_minzoom()still assigns feature minzooms in index order, matching #349.The cost is that a deferred feature's index record is written before its geometry has a location, so
replay_deferrals()correctsstart/endwith apwriteat a remembered offset. TheMismatched indexdiagnostic inread_input()is suppressed under the option, since features are legitimately no longer consecutive in the geometry.Verification
The test input from #349 decodes byte-for-byte identically to the expected output on that branch, including the structure of the final pass. (The limit has to be "50 distinguished plus one overflow pass" rather than 50 total to match.)
Separately, on a mixed input of 3000 scattered points plus 3 locations with 5 copies each, forced to drop by tile size limit:
--drop-densest-as-needed--drop-densest-as-needed --distinguish-duplicates--coalesce-densest-as-needed--coalesce-densest-as-needed --distinguish-duplicatesAlso checked:
make testpasses;-Pparallel reading matches serial;--temporary-directoryis respected;-zg,-Bg,-rg, and-aCall behave with duplicate-heavy input; 4000 features sharing one location complete without running out of files.Incidental bug fix
Folding the two feature-writing paths in
radix1()into onewrite_feature()fixes a latent bug in the path taken for a single oversized feature or maximum radix recursion depth. It wroteix.end - ix.startbytes — the feature's whole serialized length, including the trailing minzoom byte — and then appended another minzoom byte, one byte more thanmerge()writes, which desynchronizes the length-prefixed geometry stream.Before this change,
--prefer-radix-sortfails withwrong length decoding featureontests/ne_110m_ocean,tests/border,tests/epsg-3857,tests/loop, andtests/tl_2022_11_tract. All five now succeed and produce output identical to the non-radix path.There is no test for that, because
--prefer-radix-sorthas a second, unrelated crash (unboundedradix1()recursion oncesplitscollapses to 1) and the number of splits depends on the machine's file descriptor limit, so a test on that flag would risk being flaky. That crash is fixed separately.Generated by Claude Code