FlatCityBuf

August 13, 2026 · View on GitHub

A from-scratch C++17 implementation of FlatCityBuf: a reader and a writer, parsing and producing the bytes directly. It replaces the previous CXX-bridge bindings over the Rust core, so there is no Rust toolchain, no generated bridge source to compile, and no TLS dependency. Output is validated against the shared conformance corpus, and the writer's output is compared byte-for-byte against real Rust-written files.

Source: src/cpp/.

Status

Readingconformant against the corpus in conformance/
Writingfcb::FcbWriter, checked byte-for-byte against Rust-written files
StandardC++17; no Rust toolchain, no CXX bridge, no TLS dependency
Transportslocal file, libcurl HTTP range requests, or your own fcb::RangeReader

Why native

The FFI bindings were awkward precisely where it mattered: the Rust side owned a tokio runtime, and bridging that to C++ callers leaked complexity in both directions. This implementation has no async runtime at all. All IO goes through one synchronous, user-implementable fcb::RangeReader interface with a batched read, so local files and HTTP share a single traversal path and host applications keep their own threading model. Batching, not asynchrony, is the concurrency primitive: a blocking interface is trivially wrapped by whatever threading model an application already has, whereas an imposed async runtime is not.

Build and install

The packaged route is vcpkg: the flatcitybuf port lives in a custom registry (github.com/HideBa/vcpkg), builds from the cpp-v<version> tag, and installs the same flatcitybuf::flatcitybuf target the manual build does — registry configuration and current baselines in src/cpp/INSTALL.md § Install via vcpkg. Feature curl adds the HTTP range-request reader.

From source:

cd src/cpp
cmake -B build -S .
cmake --build build
cmake --install build --prefix /your/prefix

CMake options (CMakeLists.txt:8-11):

OptionDefaultEffect
FCB_WITH_JSONONCityJSON conversion, and the writer
FCB_WITH_CURLOFFthe libcurl HTTP range reader
FCB_BUILD_TESTSONthe doctest suite
FCB_BUILD_EXAMPLESONthe example programs

Dependencies, package names per platform, find_package integration, HTTP usage, and implementing your own transport: src/cpp/INSTALL.md.

Reading

#include <fcb/reader.hpp>
#include <fcb/cityjson.hpp>

fcb::FcbReader r = fcb::FcbReader::open_file("city.fcb");
auto it = r.select_bbox({84500, 445800, 85000, 446500});
while (it.next()) {
    std::cout << fcb::to_cityjson_feature(it.current(), r.header()).dump() << "\n";
}

select_all() iterates everything in stored (Hilbert) order and select_attr() queries the static B+tree; all three return a FeatureIterator (include/fcb/reader.hpp:67-99). Reading over HTTP is the same code against a different transport — build with FCB_WITH_CURL=ON and see INSTALL.md.

Field access is nlohmann::json, not a bespoke API, and vertices stay quantized integers — the runnable programs in src/cpp/examples/README.md walk through header inspection, CityJSON conversion, attribute queries, raw feature access, custom transports, HTTP, and writing, each with its real output.

Writing

fcb::FcbWriter produces .fcb natively; it needs FCB_WITH_JSON (on by default). Building a file is two passes over the CityJSONSeq input: first fcb::add_attributes accumulates an fcb::AttributeSchema (include/fcb/writer/attribute.hpp:42,50), then each feature goes through FcbWriter::add_feature, and FcbWriter::write(std::ostream&) streams the result out (include/fcb/writer/fcb_writer.hpp:80,92,105). add_feature spools each encoded feature to a temp file and write reads it back in chunks, so peak memory does not grow with the number of features — the std::vector-returning write() overload (fcb_writer.hpp:111) is a convenience for small files and does not have that property.

Two traps are worth knowing before you start: input lines must be parsed as nlohmann::ordered_json (plain nlohmann::json sorts object members alphabetically and silently renumbers the columns), and column numbering follows the order add_attributes first sees a name — document order, never alphabetical.

The full procedure, including index options, is in INSTALL.md § Writing a file; the complete runnable program is examples/write_cityjson.cpp, described in examples/README.md.

There is no CLI here — this is a library. The Rust CLI covers conversion; see rust.md.

Testing

cd src/cpp
just check        # lint + build + test + test-http, read-only
just test         # native build + the doctest suite (no HTTP adapter)
just test-http    # the libcurl adapter, in its own build tree

test-http configures a separate build tree so the default build stays curl-free and TLS-free, and it starts the range-capable test server that exports FCB_TEST_HTTP_URL — without that variable every HTTP test silently skips, which is why just test does not cover them.

Extras beyond the five standard verbs (just --list in src/cpp):

RecipeWhat it does
test-remoteopt-in live 3DBAG HTTP test against the published ~68 GB file
hardenthe two gates CI enforces and no other recipe does: the default build must link neither curl nor a TLS stack, and the suite must be clean under ASan/UBSan
tidyclang-tidy static analysis; not part of check yet
gen-fbsregenerate the committed FlatBuffers headers
docsdoxygen HTML API docs (needs doxygen on PATH)

What the suite proves

  • Conformancetests/test_conformance.cpp replays the shared corpus at repo-root conformance/, whose .expected.jsonl files hold the Rust reader's own output. Python (conformant, src/py) and TypeScript (conformant, src/ts) validate against the same corpus. It covers edge cases the Delft fixture never reaches: single-feature files, prefix-colliding strings, duplicate keys forcing payload entries, zero-area extents, and geometry templates.
  • The writer oracletests/test_writer_oracle.cpp compares this writer's bytes against real Rust-written .fcb files, not merely checking that the result decodes. Decoding correctly is a weaker claim: two implementations can agree on a wrong answer. Offsets are derived from the header's own computed layout, never hardcoded, because the corpus is not byte-reproducible.
  • The full fixture — beyond the automated suite, the manual procedure in TESTING.md dumps the whole Delft fixture through this reader and diffs it line by line against the Rust reader's own dump, comparing parsed JSON trees rather than text since key order and float formatting legitimately differ between languages.

Remote (HTTP range) verification is in TESTING.md too.

Deliberate divergences from the Rust reader

Two behaviours here are stricter than the reference, on purpose:

  • select_attr post-filters fixed-width string candidates against the full, untruncated value. Keys are truncated to 50 bytes (100 for Json/Binary) and zero-padded, so the index yields candidates rather than answers. Pass AttrQueryOptions{true} to skip verification — faster, and wrong for long strings.
  • Range operators are evaluated as strict-or-inclusive bounds at the leaf rather than as "range minus equal", which drops genuine matches when one feature carries several values of an indexed attribute.

Porting surfaced several defects in the Rust implementation, most now fixed upstream — each is recorded in upstream-findings.md.

See also