diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e33019b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(python3:*)", + "Bash(cargo check:*)", + "Bash(cargo clean:*)", + "Bash(cargo build:*)", + "Bash(git checkout:*)", + "Bash(git add:*)", + "Bash(git rm:*)" + ] + } +} diff --git a/.github/workflows/build-go.yml b/.github/workflows/build-go.yml new file mode 100644 index 0000000..e747591 --- /dev/null +++ b/.github/workflows/build-go.yml @@ -0,0 +1,209 @@ +name: Build Go Static Library + +on: + workflow_dispatch: + inputs: + version_tag: + description: "Version tag (e.g., v0.6.0)" + required: false + type: string + create_release: + description: "Create GitHub release" + required: false + type: boolean + default: false + + release: + types: [created] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + # Build static libraries for multiple architectures + build-staticlib: + name: Build ${{ matrix.os }} ${{ matrix.arch }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + # Linux x86_64 + - os: ubuntu-latest + arch: x86_64 + target: x86_64-unknown-linux-gnu + + # Linux ARM64 (cross-compile) + - os: ubuntu-latest + arch: aarch64 + target: aarch64-unknown-linux-gnu + cross_compile: true + + # macOS x86_64 (Intel) + - os: macos-latest + arch: x86_64 + target: x86_64-apple-darwin + + # macOS ARM64 (Apple Silicon) + - os: macos-latest + arch: aarch64 + target: aarch64-apple-darwin + + # Windows x86_64 + - os: windows-latest + arch: x86_64 + target: x86_64-pc-windows-msvc + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src/rust + shared-key: "fcb-go-${{ matrix.target }}" + + - name: Add target (if needed) + shell: bash + run: | + target="${{ matrix.target }}" + if [ "$target" != "x86_64-unknown-linux-gnu" ] && [ "$target" != "x86_64-apple-darwin" ] && [ "$target" != "x86_64-pc-windows-msvc" ]; then + rustup target add "$target" + fi + + - name: Install cross-compilation tools (ARM64 Linux) + if: matrix.cross_compile == true + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build static library + run: | + cd src/rust + cargo build -p fcb_go --release --target "${{ matrix.target }}" + + - name: Rename artifact (Unix) + if: runner.os != 'Windows' + run: | + cd src/rust/target + archive_name="libfcb_go-${{ matrix.os }}-${{ matrix.arch }}.a" + if [ "${{ matrix.arch }}" = "x86_64" ]; then + mv release/libfcb_go.a "$archive_name" + else + mv "${{ matrix.target }}/release/libfcb_go.a" "$archive_name" + fi + echo "ARCHIVE_NAME=$archive_name" >> $GITHUB_ENV + + - name: Rename artifact (Windows) + if: runner.os == 'Windows' + run: | + cd src/rust/target + $archive_name = "libfcb_go-${{ matrix.os }}-${{ matrix.arch }}.lib" + if ("${{ matrix.arch }}" -eq "x86_64") { + Move-Item -Path "release\fcb_go.lib" -Destination "$archive_name" + } else { + Move-Item -Path "${{ matrix.target }}\release\fcb_go.lib" -Destination "$archive_name" + } + "ARCHIVE_NAME=$archive_name" | Out-File -Encoding ASCII -FilePath $env:GITHUB_ENV + + - name: Upload static library + uses: actions/upload-artifact@v4 + with: + name: go-${{ matrix.os }}-${{ matrix.arch }} + path: | + src/rust/target/libfcb_go-*.a + src/rust/target/libfcb_go-*.lib + + # Create Go release with prebuilt static libraries + create-release: + name: Create Go Release + needs: build-staticlib + runs-on: ubuntu-latest + if: | + github.event_name == 'release' || + (github.event_name == 'workflow_dispatch' && inputs.create_release == 'true') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Organize artifacts + run: | + mkdir -p release/libs + mv go-*-x64.* release/libs/ || true + mv go-*-aarch64.* release/libs/ || true + ls -la release/libs/ + + - name: Generate C header + run: | + cd src/rust/fcb_go + cargo build --release + # Header is generated to src/go/include by build.rs + + - name: Copy Go source to release + run: | + mkdir -p release/src/go + cp -r src/go/* release/src/go/ + rm -f release/src/go/include # Users should generate their own + + - name: Create README for release + run: | + cat > release/README.md << 'EOF' + # FlatCityBuf Go Bindings - Prebuilt Release + + This release contains prebuilt static libraries for multiple platforms. + + ## Quick Start + + 1. Download the appropriate `libfcb_go-*.a` file for your platform + 2. Place it in your project's `lib/` directory + 3. Install the Go package: `go get github.com/cityjson/flatcitybuf-go/fcb` + 4. Update your cgo directives: + + \`\`\`go + // #cgo LDFLAGS: -L$(SRCDIR)/../lib -lfcb_go -lm -ldl -lpthread -lssl -lcrypto + \`\`\` + + ## Platform Files + + | Platform | Architecture | File | + |-----------|-------------|-----| + | Linux | x86_64 | libfcb_go-Linux-x86_64.a | + | Linux | ARM64 | libfcb_go-Linux-aarch64.a | + | macOS | x86_64 | libfcb_go-Darwin-x86_64.a | + | macOS | ARM64 | libfcb_go-Darwin-aarch64.a | + | Windows | x86_64 | libfcb_go-Windows-x86_64.lib | + + ## Building from Source + + See [github.com/cityjson/flatcitybuf](https://github.com/cityjson/flatcitybuf) for full build instructions. + EOF + + - name: Create release (manual) + if: github.event_name == 'workflow_dispatch' + env: + GA_TOKEN: ${{ secrets.GA_TOKEN }} + run: | + tag="${{ github.event.inputs.version_tag }}" + if [ -z "$tag" ]; then + echo "Error: version_tag is required for manual releases" + exit 1 + fi + gh release create "$tag" --title "$tag" --notes-from-file release/README.md + + - name: Upload release assets + if: github.event_name == 'release' + env: + GA_TOKEN: ${{ secrets.GA_TOKEN }} + run: | + gh release upload '${{ github.ref_name }}' release/libs/* diff --git a/.github/workflows/build-nodejs.yml b/.github/workflows/build-nodejs.yml new file mode 100644 index 0000000..a93a7f1 --- /dev/null +++ b/.github/workflows/build-nodejs.yml @@ -0,0 +1,192 @@ +name: Build Node.js Native Bindings + +on: + workflow_dispatch: + inputs: + version_bump: + description: "Version bump type" + required: true + type: choice + options: + - patch + - minor + - major + publish: + description: "Publish to npm" + required: false + type: boolean + default: false + + release: + types: [created] + +# Run tests on push to main (no build) +on: + push: + branches: [main] + paths: + - "src/rust/nodejs/**" + - "src/ts/**" + - ".github/workflows/build-nodejs.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + # Build native bindings for multiple platforms + build-native: + name: Build ${{ matrix.platform }} (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + # Linux x86_64 + - os: ubuntu-latest + platform: linux + target: x86_64-unknown-linux-gnu + arch: x64 + + # Linux ARM64 + - os: ubuntu-latest + platform: linux + target: aarch64-unknown-linux-gnu + arch: arm64 + + # macOS x86_64 (Intel) + - os: macos-latest + platform: darwin + target: x86_64-apple-darwin + arch: x64 + + # macOS ARM64 (Apple Silicon) + - os: macos-latest + platform: darwin + target: aarch64-apple-darwin + arch: arm64 + + # Windows x86_64 + - os: windows-latest + platform: win32 + target: x86_64-pc-windows-msvc + arch: x64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src/rust + shared-key: "fcb-nodejs-${{ matrix.target }}" + + - name: Add target (if needed) + shell: bash + run: | + target="${{ matrix.target }}" + if [ "$target" != "x86_64-unknown-linux-gnu" ] && [ "$target" != "x86_64-apple-darwin" ] && [ "$target" != "x86_64-pc-windows-msvc" ]; then + rustup target add "$target" + fi + + - name: Install napi-cli + run: | + npm install -g @napi-rs/cli + + - name: Build native bindings + run: | + cd src/rust/nodejs + napi build --platform --release --target "$target" --js ../../ts/node/index.js --dts ../../ts/node/index.d.ts + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: nodejs-${{ matrix.platform }}-${{ matrix.arch }} + path: | + src/ts/node/index.node + src/ts/node/index.d.ts + src/ts/node/*/index.node + src/ts/node/*/index.d.ts + if-no-files-found: error + + # Publish to npm (manual trigger or on release) + publish-npm: + name: Publish to npm + needs: build-native + runs-on: ubuntu-latest + if: | + github.event_name == 'release' || + (github.event_name == 'workflow_dispatch' && inputs.publish == 'true') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ secrets.GA_TOKEN }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Prepare package layout + run: | + mkdir -p src/ts/node/linux-x64 + mkdir -p src/ts/node/linux-arm64 + mkdir -p src/ts/node/darwin-x64 + mkdir -p src/ts/node/darwin-arm64 + mkdir -p src/ts/node/win32-x64 + + # Move artifacts to correct locations + mv nodejs-linux-x64/*/index.node src/ts/node/linux-x64/ || true + mv nodejs-linux-arm64/*/index.node src/ts/node/linux-arm64/ || true + mv nodejs-darwin-x64/*/index.node src/ts/node/darwin-x64/ || true + mv nodejs-darwin-arm64/*/index.node src/ts/node/darwin-arm64/ || true + mv nodejs-win32-x64/*/index.node src/ts/node/win32-x64/ || true + + # Copy shared files to each platform dir + for dir in src/ts/node/*/; do + cp src/ts/node/index.js "$dir/" || true + cp src/ts/node/index.d.ts "$dir/" || true + done + + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "actions[bot]@github.com" + + - name: Bump version + if: github.event_name == 'workflow_dispatch' + run: | + cd src/ts + npm version ${{ github.event.inputs.version_bump }} --no-git-tag-version + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + cd src/ts + npm publish --access public + + - name: Create git tag (if manual) + if: | + github.event_name == 'workflow_dispatch' && github.event.inputs.version_bump != '' + run: | + git tag -a "v$(node -p 'require(\"./package.json\"); console.log(require(\"./package.json\").version)' src/ts)" + git push origin "v$(node -p 'require(\"./package.json\"); console.log(require(\"./package.json\").version)' src/ts)" diff --git a/.github/workflows/ci-nodejs-go.yml b/.github/workflows/ci-nodejs-go.yml new file mode 100644 index 0000000..c824a23 --- /dev/null +++ b/.github/workflows/ci-nodejs-go.yml @@ -0,0 +1,103 @@ +name: CI - Node.js & Go + +on: + push: + branches: [main] + paths: + - "src/rust/nodejs/**" + - "src/rust/fcb_go/**" + - "src/ts/**" + - "src/go/**" + - ".github/workflows/ci-nodejs-go.yml" + + pull_request: + branches: [main] + paths: + - "src/rust/nodejs/**" + - "src/rust/fcb_go/**" + - "src/ts/**" + - "src/go/**" + - ".github/workflows/ci-nodejs-go.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Node.js tests (bindings must be built first) + test-nodejs: + name: Test Node.js + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + # Note: native bindings require platform-specific builds + # For CI, we test on Ubuntu only + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src/rust + shared-key: "fcb-nodejs-ci" + + - name: Install napi-cli + run: | + npm install -g @napi-rs/cli + + - name: Build Node.js bindings (debug) + run: | + cd src/rust/nodejs && napi build --platform --js ../../ts/node/index.js --dts ../../ts/node/index.d.ts + + - name: Run Node.js tests + run: | + cd src/ts && node --test test/node.test.mjs + + # Go tests + test-go: + name: Test Go + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev build-essential + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src/rust + shared-key: "fcb-go-ci" + + - name: Build Go static library (debug) + run: | + cd src/rust && cargo build -p fcb_go + + - name: Run Go tests + run: | + cd src/go && CGO_ENABLED=1 go test ./... diff --git a/.llm/docs/progress.md b/.llm/docs/progress.md new file mode 100644 index 0000000..de67aeb --- /dev/null +++ b/.llm/docs/progress.md @@ -0,0 +1,66 @@ +# FlatCityBuf Go & Node.js Bindings - Progress + +## Status: Phase 1 & 2 Implementation Complete + +### Phase 1: Node.js Native Bindings (napi-rs) - DONE + +| Task | Status | Files | +|------|--------|-------| +| Add workspace deps & member | Done | `src/rust/Cargo.toml` | +| Create nodejs crate | Done | `src/rust/nodejs/Cargo.toml` | +| Implement error handling | Done | `src/rust/nodejs/src/error.rs` | +| Implement type conversions | Done | `src/rust/nodejs/src/types.rs` | +| Implement FcbReader (HTTP) | Done | `src/rust/nodejs/src/reader.rs` | +| Implement FeatureIter | Done | `src/rust/nodejs/src/iter.rs` | +| Implement query types | Done | `src/rust/nodejs/src/query.rs` | +| Main entry point | Done | `src/rust/nodejs/src/lib.rs` | +| Update package.json | Done | `src/ts/package.json` | +| Build targets | Done | `justfile` | + +**Design decisions:** +- Used `Mutex` for thread-safe async iteration (napi-rs doesn't allow `&mut self` in async) +- Reader caches metadata on open, re-opens HTTP connection per query (matches Python async pattern) +- Uses `serde-json` feature for seamless Rust ↔ JS object conversion + +### Phase 2: Go Bindings (CGO + C staticlib) - DONE + +| Task | Status | Files | +|------|--------|-------| +| Add fcb_go workspace member | Done | `src/rust/Cargo.toml` | +| Create fcb_go FFI crate | Done | `src/rust/fcb_go/Cargo.toml` | +| Implement C FFI layer | Done | `src/rust/fcb_go/src/lib.rs` | +| cbindgen config | Done | `src/rust/fcb_go/cbindgen.toml` | +| Auto-generate C header | Done | `src/go/include/fcb_core.h` | +| Go module | Done | `src/go/go.mod` | +| Go FFI wrapper | Done | `src/go/fcb/fcb.go` | +| Go types | Done | `src/go/fcb/types.go` | +| Go tests | Done | `src/go/fcb/fcb_test.go` | +| Build targets | Done | `justfile` | + +**Design decisions:** +- Type-erased iterators (same pattern as C++ bindings) to avoid exposing Rust generics +- Error handling via error_out pointer parameters (idiomatic C FFI) +- Reader consumed on selection (ownership transfer to iterator) +- Go tests use standard `testing` package with table-driven patterns + +### Phase 3: Tests, Examples & Documentation - DONE + +| Task | Status | Files | +|------|--------|-------| +| Node.js integration tests | Done | `src/ts/test/node.test.mjs` | +| Node.js basic example | Done | `src/ts/examples/node-basic.mjs` | +| Node.js reference implementation | Done | `src/ts/examples/node-reference.mjs` | +| Node.js README | Done | `src/rust/nodejs/README.md` | +| Go tests | Done | `src/go/fcb/fcb_test.go` | +| Go basic example | Done | `src/go/cmd/example/main.go` | +| Go reference implementation | Done | `src/go/cmd/reference/main.go` | +| Go README | Done | `src/go/README.md` | + +### Phase 4: Remaining Work + +- [ ] Install napi-cli and test Node.js build (`napi build`) +- [ ] Install Go and run Go tests (`go test ./...`) +- [ ] Add GitHub Actions CI for both bindings +- [ ] Test WASM + Node.js conditional exports work correctly +- [ ] Benchmark performance vs Python bindings +- [ ] Go HTTP reader (requires async FFI - separate effort) diff --git a/justfile b/justfile index d4b8002..6a3e988 100644 --- a/justfile +++ b/justfile @@ -70,11 +70,11 @@ clean: # Run tests test: - cd src/rust && cargo test + cd src/rust && just test # Run tests with output test-verbose: - cd src/rust && cargo test -- --nocapture + cd src/rust && cargo test -- --nocapture # Run clippy linter clippy: @@ -91,6 +91,54 @@ fmt-check: # Full CI check (format, clippy, test) ci: fmt-check clippy test +# Build C++ bindings +build-cpp: + cd src/cpp && cmake -B build -S . && cmake --build build + +# Clean and rebuild C++ bindings +clean-cpp: build-cpp + rm -rf src/cpp/build + +# Build WASM package +build-wasm: + cd src/rust/wasm && wasm-pack build --dev + +# Build WASM for production +build-wasm-release: + cd src/rust/wasm && wasm-pack build --release + +# Build Node.js native bindings +build-nodejs: + cd src/rust/nodejs && napi build --platform --release --js ../../ts/node/index.js --dts ../../ts/node/index.d.ts + +# Build Node.js native bindings (debug) +build-nodejs-dev: + cd src/rust/nodejs && napi build --platform --js ../../ts/node/index.js --dts ../../ts/node/index.d.ts + +# Build Go FFI static library (release) +build-go-lib: + cd src/rust && cargo build -p fcb_go --release + +# Build Go FFI static library (debug) +build-go-lib-dev: + cd src/rust && cargo build -p fcb_go + +# Run Go tests (requires build-go-lib first) +test-go: build-go-lib + cd src/go && CGO_ENABLED=1 go test ./... + +# Run the API server +run-api: + cd src/rust && cargo run --bin fcb_api + +# Watch for changes and re-run tests +watch: + cd src/rust && cargo watch -x test + +# Watch and run clippy +watch-clippy: + cd src/rust && cargo watch -x clippy + # Update dependencies update: cd src/rust && cargo update diff --git a/src/go/README.md b/src/go/README.md new file mode 100644 index 0000000..add59a0 --- /dev/null +++ b/src/go/README.md @@ -0,0 +1,239 @@ +# FlatCityBuf Go Bindings + +Go bindings for [FlatCityBuf](https://github.com/cityjson/flatcitybuf) — a binary format for CityJSON data with built-in spatial and attribute indexing. + +Built with CGO linking to a Rust static library, these bindings provide file-based read access to FCB files with spatial query support. + +## Requirements + +- Go 1.21+ +- Rust toolchain (for building the static library) +- [just](https://github.com/casey/just) (task runner) + +## Building + +Build the Rust static library first, then use `go build` or `go test` as normal: + +```bash +# From the project root +just build-go-lib # release build +just build-go-lib-dev # debug build + +# Run tests +just test-go +``` + +## Quick Start + +```go +package main + +import ( + "encoding/json" + "fmt" + "log" + + "github.com/cityjson/flatcitybuf-go/fcb" +) + +func main() { + // Open a local FCB file + reader, err := fcb.Open("path/to/file.fcb") + if err != nil { + log.Fatal(err) + } + defer reader.Close() + + fmt.Printf("Features: %d\n", reader.FeaturesCount()) + fmt.Printf("Has spatial index: %v\n", reader.HasSpatialIndex()) + + // Read CityJSON metadata + meta, _ := reader.CityJSONMetadata() + fmt.Printf("CityJSON type: %s\n", meta["type"]) + + // Iterate over all features + iter, err := reader.SelectAll() + if err != nil { + log.Fatal(err) + } + defer iter.Close() + + for iter.Next() { + feature, err := iter.Feature() + if err != nil { + log.Fatal(err) + } + fmt.Printf("Feature: %s\n", feature.ID) + } + if err := iter.Err(); err != nil { + log.Fatal(err) + } +} +``` + +## API Reference + +### `fcb.Open(path string) (*Reader, error)` + +Opens a local FCB file for reading. Returns a Reader that must be closed when done. + +```go +reader, err := fcb.Open("/data/buildings.fcb") +if err != nil { + log.Fatal(err) +} +defer reader.Close() +``` + +### `Reader` + +#### `reader.FeaturesCount() uint64` + +Returns the total number of features in the file. + +#### `reader.HasSpatialIndex() bool` + +Returns true if the file includes a spatial index (required for `SelectBBox`). + +#### `reader.CityJSONMetadata() (map[string]interface{}, error)` + +Returns the CityJSON metadata as a parsed JSON map. Contains type, version, transform, and other metadata fields. + +```go +meta, err := reader.CityJSONMetadata() +version := meta["version"] // e.g., "2.0" +transform := meta["transform"] // { scale: [...], translate: [...] } +``` + +#### `reader.SelectAll() (*FeatureIter, error)` + +Select all features for iteration. **Consumes the reader** — after calling `SelectAll`, the reader should not be used again. + +```go +iter, err := reader.SelectAll() +if err != nil { + log.Fatal(err) +} +defer iter.Close() +// reader.Close() is now a no-op +``` + +#### `reader.SelectBBox(bbox BBox) (*FeatureIter, error)` + +Select features within a 2D bounding box. Requires the file to have a spatial index. **Consumes the reader.** + +```go +bbox := fcb.BBox{ + MinX: 84400.0, + MinY: 447200.0, + MaxX: 84600.0, + MaxY: 447400.0, +} +iter, err := reader.SelectBBox(bbox) +``` + +#### `reader.Close()` + +Frees the reader resources. Safe to call multiple times. Becomes a no-op after `SelectAll` or `SelectBBox`. + +### `FeatureIter` + +Iterator over selected features, following Go's `rows.Next()` / `rows.Scan()` pattern. + +#### `iter.Next() bool` + +Advances to the next feature. Returns `true` if a feature is available, `false` when iteration is complete or an error occurred. Always check `Err()` after the loop. + +#### `iter.Feature() (*CityFeature, error)` + +Returns the current feature. Must be called after `Next()` returns `true`. + +```go +for iter.Next() { + feature, err := iter.Feature() + if err != nil { + log.Fatal(err) + } + fmt.Println(feature.ID) + + // Parse the JSON if needed + var parsed map[string]interface{} + json.Unmarshal([]byte(feature.JSON), &parsed) +} +``` + +#### `iter.Err() error` + +Returns the first error encountered during iteration, or `nil`. + +#### `iter.FeaturesCount() uint64` + +Returns the total number of features matching the selection. + +#### `iter.Close()` + +Frees the iterator resources. Safe to call multiple times. + +### Types + +#### `BBox` + +```go +type BBox struct { + MinX float64 + MinY float64 + MaxX float64 + MaxY float64 +} +``` + +#### `CityFeature` + +```go +type CityFeature struct { + ID string // Feature identifier + JSON string // Full CityJSONFeature as a JSON string +} +``` + +## Ownership Model + +The Go bindings follow a **consume-on-select** pattern that mirrors the underlying Rust ownership: + +1. `Open()` returns a `Reader` +2. `SelectAll()` or `SelectBBox()` **consumes** the reader and returns a `FeatureIter` +3. After selection, the reader pointer is set to `nil` (calling `Close()` is safe but a no-op) +4. You must `Close()` the iterator when done + +This prevents double-free errors and ensures memory safety at the FFI boundary. + +```go +reader, _ := fcb.Open("file.fcb") +// reader is valid here + +iter, _ := reader.SelectAll() +// reader is now consumed (nil internally) +// reader.Close() is safe but does nothing + +defer iter.Close() // this frees the memory +``` + +## Architecture + +``` +Go application + │ + ▼ +fcb/fcb.go (Go wrapper with CGO) + │ + ▼ +fcb_core.h (auto-generated C header via cbindgen) + │ + ▼ +libfcb_go.a (Rust static library) + │ + ▼ +fcb_core (Rust core library) +``` + +The Rust FFI layer (`src/rust/fcb_go`) uses type-erased iterators (`Box`) to avoid exposing Rust generics across the C boundary. Error handling uses C-style `error_out` pointer parameters. diff --git a/src/go/cmd/example/main.go b/src/go/cmd/example/main.go new file mode 100644 index 0000000..d616372 --- /dev/null +++ b/src/go/cmd/example/main.go @@ -0,0 +1,192 @@ +// Example demonstrates the FlatCityBuf Go bindings API. +// +// Build the Rust static library first: +// +// just build-go-lib +// +// Then run: +// +// cd src/go && go run cmd/example/main.go ../../examples/data/delft.fcb +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + + "github.com/cityjson/flatcitybuf-go/fcb" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) + os.Exit(1) + } + path := os.Args[1] + + // ─── Open FCB File ────────────────────────────────────── + fmt.Println("=== Opening FCB File ===") + reader, err := fcb.Open(path) + if err != nil { + log.Fatalf("Failed to open: %v", err) + } + defer reader.Close() + + fmt.Printf("Feature count: %d\n", reader.FeaturesCount()) + fmt.Printf("Has spatial index: %v\n", reader.HasSpatialIndex()) + + // ─── CityJSON Metadata ────────────────────────────────── + fmt.Println("\n=== CityJSON Metadata ===") + meta, err := reader.CityJSONMetadata() + if err != nil { + log.Fatalf("Failed to get metadata: %v", err) + } + + fmt.Printf("Type: %v\n", meta["type"]) + fmt.Printf("Version: %v\n", meta["version"]) + + if transform, ok := meta["transform"].(map[string]interface{}); ok { + fmt.Printf("Transform scale: %v\n", transform["scale"]) + fmt.Printf("Transform translate: %v\n", transform["translate"]) + } + + // ─── Select All Features ──────────────────────────────── + fmt.Println("\n=== Select All Features (first 5) ===") + selectAllExample(path) + + // ─── Spatial Query: BBox ──────────────────────────────── + fmt.Println("\n=== Spatial Query: BBox ===") + selectBBoxExample(path) + + // ─── Full Feature JSON ────────────────────────────────── + fmt.Println("\n=== Feature JSON Structure ===") + featureJsonExample(path) + + fmt.Println("\nDone!") +} + +func selectAllExample(path string) { + reader, err := fcb.Open(path) + if err != nil { + log.Fatalf("Failed to open: %v", err) + } + // Reader is consumed by SelectAll, no need to defer Close + + iter, err := reader.SelectAll() + if err != nil { + log.Fatalf("Failed to select all: %v", err) + } + defer iter.Close() + + fmt.Printf("Total features: %d\n", iter.FeaturesCount()) + + count := 0 + for iter.Next() { + feature, err := iter.Feature() + if err != nil { + log.Fatalf("Failed to get feature: %v", err) + } + fmt.Printf(" [%d] ID: %s (JSON length: %d bytes)\n", + count, feature.ID, len(feature.JSON)) + count++ + if count >= 5 { + break + } + } + if err := iter.Err(); err != nil { + log.Fatalf("Iteration error: %v", err) + } + fmt.Printf("Read %d features\n", count) +} + +func selectBBoxExample(path string) { + reader, err := fcb.Open(path) + if err != nil { + log.Fatalf("Failed to open: %v", err) + } + + if !reader.HasSpatialIndex() { + fmt.Println("File does not have a spatial index, skipping bbox query") + reader.Close() + return + } + + // Bounding box covering part of Delft (Netherlands RD coordinates) + bbox := fcb.BBox{ + MinX: 84400.0, + MinY: 447200.0, + MaxX: 84600.0, + MaxY: 447400.0, + } + + iter, err := reader.SelectBBox(bbox) + if err != nil { + log.Fatalf("Failed to select bbox: %v", err) + } + defer iter.Close() + + count := 0 + for iter.Next() { + feature, err := iter.Feature() + if err != nil { + log.Fatalf("Failed to get feature: %v", err) + } + if count < 3 { + fmt.Printf(" [%d] ID: %s\n", count, feature.ID) + } + count++ + } + if err := iter.Err(); err != nil { + log.Fatalf("Iteration error: %v", err) + } + fmt.Printf("BBox query returned %d features\n", count) +} + +func featureJsonExample(path string) { + reader, err := fcb.Open(path) + if err != nil { + log.Fatalf("Failed to open: %v", err) + } + + iter, err := reader.SelectAll() + if err != nil { + log.Fatalf("Failed to select all: %v", err) + } + defer iter.Close() + + if iter.Next() { + feature, err := iter.Feature() + if err != nil { + log.Fatalf("Failed to get feature: %v", err) + } + + // Parse JSON to inspect structure + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(feature.JSON), &parsed); err != nil { + log.Fatalf("Invalid JSON: %v", err) + } + + fmt.Printf("Feature ID: %s\n", feature.ID) + fmt.Printf("JSON top-level keys: ") + for key := range parsed { + fmt.Printf("%s ", key) + } + fmt.Println() + + // Pretty-print a trimmed version + if cityObjects, ok := parsed["CityObjects"].(map[string]interface{}); ok { + fmt.Printf("CityObjects count: %d\n", len(cityObjects)) + for id, obj := range cityObjects { + if co, ok := obj.(map[string]interface{}); ok { + fmt.Printf(" CityObject '%s': type=%v\n", id, co["type"]) + } + break // just show first one + } + } + + if vertices, ok := parsed["vertices"].([]interface{}); ok { + fmt.Printf("Vertices count: %d\n", len(vertices)) + } + } +} diff --git a/src/go/cmd/reference/main.go b/src/go/cmd/reference/main.go new file mode 100644 index 0000000..e21f755 --- /dev/null +++ b/src/go/cmd/reference/main.go @@ -0,0 +1,311 @@ +// Reference implementation demonstrating every public API of the FlatCityBuf Go bindings. +// +// Build the Rust static library first: +// +// just build-go-lib +// +// Then run: +// +// cd src/go && go run cmd/reference/main.go ../../examples/data/delft.fcb +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "strings" + + "github.com/cityjson/flatcitybuf-go/fcb" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) + os.Exit(1) + } + path := os.Args[1] + + demoOpenAndInspect(path) + demoSelectAll(path) + demoSelectBBox(path) + demoFeatureJSON(path) + demoOwnershipModel(path) + demoErrorHandling() + + fmt.Println("\n=== All demos complete ===") +} + +// ───────────────────────────────────────────────────────────── +// 1. Opening and Inspecting FCB Files +// ───────────────────────────────────────────────────────────── +func demoOpenAndInspect(path string) { + fmt.Println("=== 1. Opening and Inspecting ===") + + reader, err := fcb.Open(path) + if err != nil { + log.Fatalf("Failed to open: %v", err) + } + defer reader.Close() + + // Basic file properties + fmt.Printf("Feature count: %d\n", reader.FeaturesCount()) + fmt.Printf("Has spatial index: %v\n", reader.HasSpatialIndex()) + + // CityJSON metadata is returned as a parsed JSON map + meta, err := reader.CityJSONMetadata() + if err != nil { + log.Fatalf("Failed to get metadata: %v", err) + } + + fmt.Printf("CityJSON type: %v\n", meta["type"]) + fmt.Printf("CityJSON version: %v\n", meta["version"]) + + if transform, ok := meta["transform"].(map[string]interface{}); ok { + fmt.Printf("Transform scale: %v\n", transform["scale"]) + fmt.Printf("Transform translate: %v\n", transform["translate"]) + } + + // Access other metadata fields + for key := range meta { + if key != "type" && key != "version" && key != "transform" { + fmt.Printf("Extra metadata key: %s\n", key) + } + } + fmt.Println() +} + +// ───────────────────────────────────────────────────────────── +// 2. Iterating Over All Features +// ───────────────────────────────────────────────────────────── +func demoSelectAll(path string) { + fmt.Println("=== 2. Select All Features ===") + + reader, err := fcb.Open(path) + if err != nil { + log.Fatalf("Failed to open: %v", err) + } + // Don't defer reader.Close() — SelectAll consumes it + + iter, err := reader.SelectAll() + if err != nil { + log.Fatalf("Failed to select all: %v", err) + } + defer iter.Close() + + // FeaturesCount() returns the total count from the header + fmt.Printf("Features available: %d\n", iter.FeaturesCount()) + + // Standard Go iteration pattern: Next() + Feature() + count := 0 + for iter.Next() { + feature, err := iter.Feature() + if err != nil { + log.Fatalf("Failed to get feature %d: %v", count, err) + } + + // CityFeature has ID and JSON fields + fmt.Printf(" [%d] ID: %s (JSON: %d bytes)\n", + count, feature.ID, len(feature.JSON)) + + count++ + if count >= 5 { + break + } + } + + // Always check Err() after the iteration loop + if err := iter.Err(); err != nil { + log.Fatalf("Iteration error: %v", err) + } + + fmt.Printf("Read %d of %d features\n\n", count, iter.FeaturesCount()) +} + +// ───────────────────────────────────────────────────────────── +// 3. Spatial Query: Bounding Box +// ───────────────────────────────────────────────────────────── +func demoSelectBBox(path string) { + fmt.Println("=== 3. Spatial Query: BBox ===") + + reader, err := fcb.Open(path) + if err != nil { + log.Fatalf("Failed to open: %v", err) + } + + if !reader.HasSpatialIndex() { + fmt.Println("No spatial index — skipping bbox demo") + reader.Close() + return + } + + // BBox coordinates are in the CRS of the FCB file + // For the Delft dataset, this is Dutch RD (EPSG:7415) + bbox := fcb.BBox{ + MinX: 84400.0, + MinY: 447200.0, + MaxX: 84600.0, + MaxY: 447400.0, + } + + // SelectBBox consumes the reader + iter, err := reader.SelectBBox(bbox) + if err != nil { + log.Fatalf("Failed to select bbox: %v", err) + } + defer iter.Close() + + count := 0 + for iter.Next() { + feature, err := iter.Feature() + if err != nil { + log.Fatalf("Failed to get feature: %v", err) + } + if count < 3 { + fmt.Printf(" [%d] %s\n", count, feature.ID) + } + count++ + } + if err := iter.Err(); err != nil { + log.Fatalf("Iteration error: %v", err) + } + + fmt.Printf("BBox query returned %d features\n\n", count) +} + +// ───────────────────────────────────────────────────────────── +// 4. Working with Feature JSON +// ───────────────────────────────────────────────────────────── +func demoFeatureJSON(path string) { + fmt.Println("=== 4. Feature JSON Structure ===") + + reader, err := fcb.Open(path) + if err != nil { + log.Fatalf("Failed to open: %v", err) + } + + iter, err := reader.SelectAll() + if err != nil { + log.Fatalf("Failed to select all: %v", err) + } + defer iter.Close() + + if !iter.Next() { + fmt.Println("No features found") + return + } + + feature, err := iter.Feature() + if err != nil { + log.Fatalf("Failed to get feature: %v", err) + } + + // Parse the CityJSONFeature JSON + var cjFeature map[string]interface{} + if err := json.Unmarshal([]byte(feature.JSON), &cjFeature); err != nil { + log.Fatalf("Invalid JSON: %v", err) + } + + // Standard CityJSONFeature fields + fmt.Printf("Feature ID: %s\n", feature.ID) + fmt.Printf("Type: %v\n", cjFeature["type"]) + + // Top-level keys + keys := make([]string, 0) + for k := range cjFeature { + keys = append(keys, k) + } + fmt.Printf("Top-level keys: [%s]\n", strings.Join(keys, ", ")) + + // CityObjects contain the semantic city model data + if cityObjects, ok := cjFeature["CityObjects"].(map[string]interface{}); ok { + fmt.Printf("CityObjects count: %d\n", len(cityObjects)) + for id, obj := range cityObjects { + if co, ok := obj.(map[string]interface{}); ok { + fmt.Printf(" '%s': type=%v", id, co["type"]) + if attrs, ok := co["attributes"].(map[string]interface{}); ok { + fmt.Printf(", attributes=%d", len(attrs)) + } + fmt.Println() + } + break // only show first + } + } + + // Vertices array + if vertices, ok := cjFeature["vertices"].([]interface{}); ok { + fmt.Printf("Vertices: %d points\n", len(vertices)) + } + + fmt.Println() +} + +// ───────────────────────────────────────────────────────────── +// 5. Ownership Model Demo +// ───────────────────────────────────────────────────────────── +func demoOwnershipModel(path string) { + fmt.Println("=== 5. Ownership Model ===") + + // Demonstrate the consume-on-select pattern: + reader, err := fcb.Open(path) + if err != nil { + log.Fatalf("Failed to open: %v", err) + } + + // At this point, reader is valid + fmt.Printf("Before SelectAll: FeaturesCount = %d\n", reader.FeaturesCount()) + + iter, err := reader.SelectAll() + if err != nil { + log.Fatalf("Failed to select all: %v", err) + } + + // After SelectAll, reader is consumed (pointer set to nil internally). + // Calling methods on it returns zero values safely: + fmt.Printf("After SelectAll: FeaturesCount = %d (reader consumed)\n", reader.FeaturesCount()) + + // Close() is safe to call on a consumed reader (no-op): + reader.Close() + + // The iterator owns the resources now: + fmt.Printf("Iterator FeaturesCount: %d\n", iter.FeaturesCount()) + + // Don't forget to close the iterator when done: + iter.Close() + + // After closing, the iterator returns safe defaults: + fmt.Printf("After iter.Close(): FeaturesCount = %d\n", iter.FeaturesCount()) + fmt.Printf("After iter.Close(): Next() = %v\n\n", iter.Next()) +} + +// ───────────────────────────────────────────────────────────── +// 6. Error Handling +// ───────────────────────────────────────────────────────────── +func demoErrorHandling() { + fmt.Println("=== 6. Error Handling ===") + + // Invalid file path + _, err := fcb.Open("/nonexistent/path.fcb") + if err != nil { + fmt.Printf("Invalid path error: %v\n", err) + } + + // Accessing a closed reader + reader, err := fcb.Open(os.Args[1]) + if err != nil { + log.Fatalf("Failed to open: %v", err) + } + reader.Close() + // After Close, methods return zero values safely + fmt.Printf("Closed reader FeaturesCount: %d\n", reader.FeaturesCount()) + fmt.Printf("Closed reader HasSpatialIndex: %v\n", reader.HasSpatialIndex()) + + // Attempting to use a consumed reader + reader2, _ := fcb.Open(os.Args[1]) + iter, _ := reader2.SelectAll() + _, err = reader2.SelectAll() // reader2 is consumed + if err != nil { + fmt.Printf("Consumed reader error: %v\n", err) + } + iter.Close() +} diff --git a/src/go/fcb/fcb.go b/src/go/fcb/fcb.go new file mode 100644 index 0000000..d3df86f --- /dev/null +++ b/src/go/fcb/fcb.go @@ -0,0 +1,232 @@ +// Package fcb provides Go bindings for reading FlatCityBuf files. +// +// FlatCityBuf (FCB) is a binary format for CityJSON data that supports +// spatial and attribute indexing for efficient queries. +// +// # Usage +// +// reader, err := fcb.Open("path/to/file.fcb") +// if err != nil { +// log.Fatal(err) +// } +// defer reader.Close() +// +// iter, err := reader.SelectAll() +// if err != nil { +// log.Fatal(err) +// } +// defer iter.Close() +// +// for iter.Next() { +// feature, err := iter.Feature() +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(feature.ID, feature.JSON) +// } +package fcb + +/* +#cgo CFLAGS: -I${SRCDIR}/../include +#cgo LDFLAGS: -L${SRCDIR}/../../rust/target/release -lfcb_go -lm -ldl -lpthread +#include "fcb_core.h" +#include +*/ +import "C" + +import ( + "encoding/json" + "fmt" + "unsafe" +) + +// Reader represents an open FCB file for reading. +type Reader struct { + ptr *C.struct_FcbFileReader +} + +// Open opens an FCB file at the given path for reading. +func Open(path string) (*Reader, error) { + cpath := C.CString(path) + defer C.free(unsafe.Pointer(cpath)) + + var errPtr *C.char + ptr := C.fcb_reader_open(cpath, &errPtr) + if ptr == nil { + return nil, extractError(errPtr) + } + + return &Reader{ptr: ptr}, nil +} + +// FeaturesCount returns the total number of features in the file. +func (r *Reader) FeaturesCount() uint64 { + if r.ptr == nil { + return 0 + } + return uint64(C.fcb_reader_features_count(r.ptr)) +} + +// HasSpatialIndex returns true if the file has a spatial index. +func (r *Reader) HasSpatialIndex() bool { + if r.ptr == nil { + return false + } + return bool(C.fcb_reader_has_spatial_index(r.ptr)) +} + +// CityJSONMetadata returns the CityJSON metadata as a parsed JSON object. +func (r *Reader) CityJSONMetadata() (map[string]interface{}, error) { + if r.ptr == nil { + return nil, fmt.Errorf("reader is closed") + } + + var errPtr *C.char + cjson := C.fcb_reader_cityjson_metadata(r.ptr, &errPtr) + if cjson == nil { + return nil, extractError(errPtr) + } + defer C.fcb_free_string(cjson) + + jsonStr := C.GoString(cjson) + var result map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + return nil, fmt.Errorf("failed to parse metadata: %w", err) + } + return result, nil +} + +// SelectAll selects all features for iteration. Consumes the reader. +// After calling SelectAll, the reader should not be used again (Close becomes a no-op). +func (r *Reader) SelectAll() (*FeatureIter, error) { + if r.ptr == nil { + return nil, fmt.Errorf("reader is closed") + } + + var errPtr *C.char + ptr := C.fcb_reader_select_all(r.ptr, &errPtr) + r.ptr = nil // Reader is consumed + if ptr == nil { + return nil, extractError(errPtr) + } + + return &FeatureIter{ptr: ptr}, nil +} + +// SelectBBox selects features within a bounding box. Consumes the reader. +func (r *Reader) SelectBBox(bbox BBox) (*FeatureIter, error) { + if r.ptr == nil { + return nil, fmt.Errorf("reader is closed") + } + + var errPtr *C.char + ptr := C.fcb_reader_select_bbox( + r.ptr, + C.double(bbox.MinX), + C.double(bbox.MinY), + C.double(bbox.MaxX), + C.double(bbox.MaxY), + &errPtr, + ) + r.ptr = nil // Reader is consumed + if ptr == nil { + return nil, extractError(errPtr) + } + + return &FeatureIter{ptr: ptr}, nil +} + +// Close frees the reader. Safe to call multiple times. +func (r *Reader) Close() { + if r.ptr != nil { + C.fcb_reader_free(r.ptr) + r.ptr = nil + } +} + +// FeatureIter iterates over selected features. +type FeatureIter struct { + ptr *C.struct_FcbFileIterator + hasNext bool + err error +} + +// Next advances to the next feature. Returns true if a feature is available. +// After Next returns false, check Err() for any errors. +func (it *FeatureIter) Next() bool { + if it.ptr == nil { + return false + } + + var errPtr *C.char + result := C.fcb_iterator_next(it.ptr, &errPtr) + switch result { + case 1: + it.hasNext = true + return true + case 0: + it.hasNext = false + return false + default: // -1 + it.hasNext = false + it.err = extractError(errPtr) + return false + } +} + +// Feature returns the current feature. Call after Next() returns true. +func (it *FeatureIter) Feature() (*CityFeature, error) { + if it.ptr == nil || !it.hasNext { + return nil, fmt.Errorf("no current feature - call Next() first") + } + + var errPtr *C.char + + cjson := C.fcb_iterator_current_json(it.ptr, &errPtr) + if cjson == nil { + return nil, extractError(errPtr) + } + defer C.fcb_free_string(cjson) + + cid := C.fcb_iterator_current_id(it.ptr, &errPtr) + if cid == nil { + return nil, extractError(errPtr) + } + defer C.fcb_free_string(cid) + + return &CityFeature{ + ID: C.GoString(cid), + JSON: C.GoString(cjson), + }, nil +} + +// Err returns any error encountered during iteration. +func (it *FeatureIter) Err() error { + return it.err +} + +// FeaturesCount returns the number of selected features. +func (it *FeatureIter) FeaturesCount() uint64 { + if it.ptr == nil { + return 0 + } + return uint64(C.fcb_iterator_features_count(it.ptr)) +} + +// Close frees the iterator. Safe to call multiple times. +func (it *FeatureIter) Close() { + if it.ptr != nil { + C.fcb_iterator_free(it.ptr) + it.ptr = nil + } +} + +// extractError converts a C error string to a Go error and frees the C string. +func extractError(errPtr *C.char) error { + if errPtr == nil { + return fmt.Errorf("unknown error") + } + msg := C.GoString(errPtr) + C.fcb_free_string(errPtr) + return fmt.Errorf("%s", msg) +} diff --git a/src/go/fcb/fcb_test.go b/src/go/fcb/fcb_test.go new file mode 100644 index 0000000..33516a8 --- /dev/null +++ b/src/go/fcb/fcb_test.go @@ -0,0 +1,166 @@ +package fcb + +import ( + "encoding/json" + "path/filepath" + "runtime" + "testing" +) + +func testDataPath() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "..", "..", "..", "examples", "data", "delft.fcb") +} + +func TestOpen(t *testing.T) { + path := testDataPath() + reader, err := Open(path) + if err != nil { + t.Fatalf("Failed to open FCB file: %v", err) + } + defer reader.Close() + + count := reader.FeaturesCount() + if count == 0 { + t.Error("Expected non-zero feature count") + } + t.Logf("Feature count: %d", count) +} + +func TestOpenInvalidPath(t *testing.T) { + _, err := Open("/nonexistent/path.fcb") + if err == nil { + t.Error("Expected error for invalid path") + } +} + +func TestHasSpatialIndex(t *testing.T) { + reader, err := Open(testDataPath()) + if err != nil { + t.Fatalf("Failed to open: %v", err) + } + defer reader.Close() + + hasSpatial := reader.HasSpatialIndex() + t.Logf("Has spatial index: %v", hasSpatial) +} + +func TestCityJSONMetadata(t *testing.T) { + reader, err := Open(testDataPath()) + if err != nil { + t.Fatalf("Failed to open: %v", err) + } + defer reader.Close() + + meta, err := reader.CityJSONMetadata() + if err != nil { + t.Fatalf("Failed to get metadata: %v", err) + } + + if meta == nil { + t.Fatal("Expected non-nil metadata") + } + + // Check that common CityJSON fields exist + if _, ok := meta["type"]; !ok { + t.Error("Expected 'type' field in metadata") + } + t.Logf("Metadata keys: %v", keys(meta)) +} + +func TestSelectAll(t *testing.T) { + reader, err := Open(testDataPath()) + if err != nil { + t.Fatalf("Failed to open: %v", err) + } + // Don't defer Close - reader is consumed by SelectAll + + iter, err := reader.SelectAll() + if err != nil { + t.Fatalf("Failed to select all: %v", err) + } + defer iter.Close() + + totalCount := iter.FeaturesCount() + t.Logf("Total features: %d", totalCount) + + count := 0 + for iter.Next() { + feature, err := iter.Feature() + if err != nil { + t.Fatalf("Failed to get feature %d: %v", count, err) + } + if feature.ID == "" { + t.Errorf("Feature %d has empty ID", count) + } + if feature.JSON == "" { + t.Errorf("Feature %d has empty JSON", count) + } + // Verify JSON is valid + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(feature.JSON), &parsed); err != nil { + t.Errorf("Feature %d has invalid JSON: %v", count, err) + } + count++ + if count >= 5 { + break // Just test first 5 features + } + } + + if err := iter.Err(); err != nil { + t.Fatalf("Iteration error: %v", err) + } + + if count == 0 { + t.Error("Expected at least one feature") + } + t.Logf("Read %d features successfully", count) +} + +func TestSelectBBox(t *testing.T) { + reader, err := Open(testDataPath()) + if err != nil { + t.Fatalf("Failed to open: %v", err) + } + + if !reader.HasSpatialIndex() { + t.Skip("File has no spatial index") + } + + // Use a bbox that covers part of Delft (Netherlands) + bbox := BBox{ + MinX: 84400.0, + MinY: 447200.0, + MaxX: 84600.0, + MaxY: 447400.0, + } + + iter, err := reader.SelectBBox(bbox) + if err != nil { + t.Fatalf("Failed to select bbox: %v", err) + } + defer iter.Close() + + count := 0 + for iter.Next() { + _, err := iter.Feature() + if err != nil { + t.Fatalf("Failed to get feature: %v", err) + } + count++ + } + + if err := iter.Err(); err != nil { + t.Fatalf("Iteration error: %v", err) + } + + t.Logf("BBox query returned %d features", count) +} + +func keys(m map[string]interface{}) []string { + k := make([]string, 0, len(m)) + for key := range m { + k = append(k, key) + } + return k +} diff --git a/src/go/fcb/types.go b/src/go/fcb/types.go new file mode 100644 index 0000000..886681e --- /dev/null +++ b/src/go/fcb/types.go @@ -0,0 +1,17 @@ +package fcb + +// BBox represents a 2D bounding box for spatial queries. +type BBox struct { + MinX float64 + MinY float64 + MaxX float64 + MaxY float64 +} + +// CityFeature represents a single CityJSON feature. +type CityFeature struct { + // ID is the feature identifier. + ID string + // JSON is the full CityJSONFeature serialized as a JSON string. + JSON string +} diff --git a/src/go/go.mod b/src/go/go.mod new file mode 100644 index 0000000..d44fb76 --- /dev/null +++ b/src/go/go.mod @@ -0,0 +1,3 @@ +module github.com/cityjson/flatcitybuf-go + +go 1.21 diff --git a/src/go/include/fcb_core.h b/src/go/include/fcb_core.h new file mode 100644 index 0000000..1d9c969 --- /dev/null +++ b/src/go/include/fcb_core.h @@ -0,0 +1,95 @@ +#ifndef FCB_CORE_H +#define FCB_CORE_H + +#include +#include +#include +#include + +/** + * Opaque iterator type exposed to C/Go + */ +typedef struct FcbFileIterator FcbFileIterator; + +/** + * Opaque reader type exposed to C/Go + */ +typedef struct FcbFileReader FcbFileReader; + +/** + * Open an FCB file for reading. Returns null on error. + * On error, `error_out` is set to an error message (caller must free with `fcb_free_string`). + */ +struct FcbFileReader *fcb_reader_open(const char *path, char **error_out); + +/** + * Get the feature count from an open reader. + */ +uint64_t fcb_reader_features_count(const struct FcbFileReader *reader); + +/** + * Check if the reader has a spatial index. + */ +bool fcb_reader_has_spatial_index(const struct FcbFileReader *reader); + +/** + * Get CityJSON metadata as a JSON string. + * Caller must free the returned string with `fcb_free_string`. + */ +char *fcb_reader_cityjson_metadata(const struct FcbFileReader *reader, char **error_out); + +/** + * Select all features. Consumes the reader. + * Returns null on error. + */ +struct FcbFileIterator *fcb_reader_select_all(struct FcbFileReader *reader, char **error_out); + +/** + * Select features within a bounding box. Consumes the reader. + * Returns null on error. + */ +struct FcbFileIterator *fcb_reader_select_bbox(struct FcbFileReader *reader, + double min_x, + double min_y, + double max_x, + double max_y, + char **error_out); + +/** + * Advance to the next feature. Returns 1 if a feature is available, 0 if done, -1 on error. + */ +int32_t fcb_iterator_next(struct FcbFileIterator *iter, char **error_out); + +/** + * Get the current feature as a JSON string. + * Caller must free the returned string with `fcb_free_string`. + */ +char *fcb_iterator_current_json(const struct FcbFileIterator *iter, char **error_out); + +/** + * Get the current feature ID. + * Caller must free the returned string with `fcb_free_string`. + */ +char *fcb_iterator_current_id(const struct FcbFileIterator *iter, char **error_out); + +/** + * Get the total features count from the iterator. + */ +uint64_t fcb_iterator_features_count(const struct FcbFileIterator *iter); + +/** + * Free a reader. Must be called when done with the reader. + */ +void fcb_reader_free(struct FcbFileReader *reader); + +/** + * Free an iterator. Must be called when done with the iterator. + */ +void fcb_iterator_free(struct FcbFileIterator *iter); + +/** + * Free a C string returned by any fcb_ function. + */ +void fcb_free_string(char *s); + +#endif /* FCB_CORE_H */ diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index bb35b5f..9971ba6 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -1,6 +1,6 @@ [workspace] -members = ["cli", "fcb_core", "fcb_cpp", "wasm", "fcb_api", "fcb_py"] +members = ["cli", "fcb_core", "fcb_cpp", "wasm", "fcb_api", "fcb_py", "nodejs", "fcb_go"] resolver = "2" [workspace.dependencies] @@ -55,6 +55,10 @@ console = "0.15" cxx = "1.0" cxx-build = "1.0" +#---Node.js (napi-rs) dependencies--- +napi = { version = "3.0.0-alpha.24", features = ["async", "tokio_rt", "serde-json"] } +napi-derive = "3.0.0-alpha.24" + #---WASM dependencies--- getrandom = { version = "0.3.3" } gloo-net = "0.6.0" diff --git a/src/rust/fcb_go/Cargo.toml b/src/rust/fcb_go/Cargo.toml new file mode 100644 index 0000000..98b80e8 --- /dev/null +++ b/src/rust/fcb_go/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "fcb_go" +version = "0.6.0" +edition = "2021" +authors = ["Hidemichi Baba "] +license = "MIT" +description = "C FFI layer for FlatCityBuf Go bindings" + +[lib] +crate-type = ["staticlib"] + +[dependencies] +fcb_core = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[build-dependencies] +cbindgen = "0.27" diff --git a/src/rust/fcb_go/build.rs b/src/rust/fcb_go/build.rs new file mode 100644 index 0000000..e8abaa8 --- /dev/null +++ b/src/rust/fcb_go/build.rs @@ -0,0 +1,20 @@ +fn main() { + let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let output_dir = std::path::Path::new(&crate_dir) + .parent() + .unwrap() + .parent() + .unwrap() + .join("go") + .join("include"); + + std::fs::create_dir_all(&output_dir).ok(); + + cbindgen::Builder::new() + .with_crate(crate_dir) + .with_language(cbindgen::Language::C) + .with_include_guard("FCB_CORE_H") + .generate() + .expect("Unable to generate bindings") + .write_to_file(output_dir.join("fcb_core.h")); +} diff --git a/src/rust/fcb_go/cbindgen.toml b/src/rust/fcb_go/cbindgen.toml new file mode 100644 index 0000000..bffbb35 --- /dev/null +++ b/src/rust/fcb_go/cbindgen.toml @@ -0,0 +1,14 @@ +language = "C" +include_guard = "FCB_CORE_H" +autogen_warning = "/* Warning: this file is autogenerated by cbindgen. Do not modify manually. */" + +[export] +include = [ + "FcbError", + "FcbMetadata", + "FcbBBox", + "FcbFeatureData", +] + +[fn] +rename_args = "SnakeCase" diff --git a/src/rust/fcb_go/src/lib.rs b/src/rust/fcb_go/src/lib.rs new file mode 100644 index 0000000..2887156 --- /dev/null +++ b/src/rust/fcb_go/src/lib.rs @@ -0,0 +1,348 @@ +//! C FFI layer for FlatCityBuf Go bindings. +//! +//! Provides C-compatible functions for reading FCB files from Go via CGO. +//! All functions follow the pattern of returning a status code (0 = success) +//! with output via pointer parameters, or returning opaque pointer types. + +use fcb_core::{FcbReader, SpatialQuery}; +use std::ffi::{c_char, CStr, CString}; +use std::fs::File; +use std::io::BufReader; +use std::ptr; + +/// Opaque reader type exposed to C/Go +pub struct FcbFileReader { + inner: FcbReader>, +} + +/// Opaque iterator type exposed to C/Go +pub struct FcbFileIterator { + inner: Box, + features_count: u64, +} + +/// Trait for type-erased iterator operations (same pattern as C++ bindings) +trait IteratorHelper { + fn advance(&mut self) -> Result; + fn current_json(&self) -> Result<*mut c_char, String>; + fn current_id(&self) -> Result<*mut c_char, String>; +} + +/// Concrete implementation wrapping the seekable FeatureIter +struct SeekableIterHelper { + iter: fcb_core::FeatureIter, fcb_core::reader_trait::Seekable>, + cur_id: Option, + cur_json: Option, + finished: bool, +} + +// Single-threaded access from Go; safe for our use case. +unsafe impl Send for SeekableIterHelper {} + +impl IteratorHelper for SeekableIterHelper { + fn advance(&mut self) -> Result { + if self.finished { + return Ok(false); + } + + match self.iter.next() { + Ok(Some(_)) => { + let cj_feature = self + .iter + .cur_cj_feature() + .map_err(|e| format!("Failed to get feature: {e}"))?; + self.cur_id = Some(cj_feature.id.clone()); + self.cur_json = Some( + serde_json::to_string(&cj_feature) + .map_err(|e| format!("Failed to serialize: {e}"))?, + ); + Ok(true) + } + Ok(None) => { + self.finished = true; + self.cur_id = None; + self.cur_json = None; + Ok(false) + } + Err(e) => Err(format!("Failed to advance: {e}")), + } + } + + fn current_json(&self) -> Result<*mut c_char, String> { + match &self.cur_json { + Some(json) => CString::new(json.as_str()) + .map(|cs| cs.into_raw()) + .map_err(|e| format!("Invalid JSON string: {e}")), + None => Err("No current feature".to_string()), + } + } + + fn current_id(&self) -> Result<*mut c_char, String> { + match &self.cur_id { + Some(id) => CString::new(id.as_str()) + .map(|cs| cs.into_raw()) + .map_err(|e| format!("Invalid ID string: {e}")), + None => Err("No current feature".to_string()), + } + } +} + +// ============ Reader API ============ + +/// Open an FCB file for reading. Returns null on error. +/// On error, `error_out` is set to an error message (caller must free with `fcb_free_string`). +#[no_mangle] +pub unsafe extern "C" fn fcb_reader_open( + path: *const c_char, + error_out: *mut *mut c_char, +) -> *mut FcbFileReader { + let path_str = match CStr::from_ptr(path).to_str() { + Ok(s) => s, + Err(e) => { + set_error(error_out, &format!("Invalid UTF-8 path: {e}")); + return ptr::null_mut(); + } + }; + + let file = match File::open(path_str) { + Ok(f) => f, + Err(e) => { + set_error(error_out, &format!("Failed to open file: {e}")); + return ptr::null_mut(); + } + }; + + let buf_reader = BufReader::new(file); + match FcbReader::open(buf_reader) { + Ok(reader) => Box::into_raw(Box::new(FcbFileReader { inner: reader })), + Err(e) => { + set_error(error_out, &format!("Failed to parse FCB header: {e}")); + ptr::null_mut() + } + } +} + +/// Get the feature count from an open reader. +#[no_mangle] +pub unsafe extern "C" fn fcb_reader_features_count(reader: *const FcbFileReader) -> u64 { + if reader.is_null() { + return 0; + } + (*reader).inner.header().features_count() +} + +/// Check if the reader has a spatial index. +#[no_mangle] +pub unsafe extern "C" fn fcb_reader_has_spatial_index(reader: *const FcbFileReader) -> bool { + if reader.is_null() { + return false; + } + (*reader).inner.header().index_node_size() > 0 +} + +/// Get CityJSON metadata as a JSON string. +/// Caller must free the returned string with `fcb_free_string`. +#[no_mangle] +pub unsafe extern "C" fn fcb_reader_cityjson_metadata( + reader: *const FcbFileReader, + error_out: *mut *mut c_char, +) -> *mut c_char { + if reader.is_null() { + set_error(error_out, "Null reader pointer"); + return ptr::null_mut(); + } + let header = (*reader).inner.header(); + match fcb_core::deserializer::to_cj_metadata(&header) { + Ok(cj) => match serde_json::to_string(&cj) { + Ok(json) => CString::new(json).unwrap_or_default().into_raw(), + Err(e) => { + set_error(error_out, &format!("Serialization error: {e}")); + ptr::null_mut() + } + }, + Err(e) => { + set_error(error_out, &format!("Metadata error: {e}")); + ptr::null_mut() + } + } +} + +// ============ Selection API ============ + +/// Select all features. Consumes the reader. +/// Returns null on error. +#[no_mangle] +pub unsafe extern "C" fn fcb_reader_select_all( + reader: *mut FcbFileReader, + error_out: *mut *mut c_char, +) -> *mut FcbFileIterator { + if reader.is_null() { + set_error(error_out, "Null reader pointer"); + return ptr::null_mut(); + } + let reader = Box::from_raw(reader); + match reader.inner.select_all() { + Ok(iter) => { + let features_count = iter.features_count().unwrap_or(0) as u64; + let helper = SeekableIterHelper { + iter, + cur_id: None, + cur_json: None, + finished: false, + }; + Box::into_raw(Box::new(FcbFileIterator { + inner: Box::new(helper), + features_count, + })) + } + Err(e) => { + set_error(error_out, &format!("Failed to select all: {e}")); + ptr::null_mut() + } + } +} + +/// Select features within a bounding box. Consumes the reader. +/// Returns null on error. +#[no_mangle] +pub unsafe extern "C" fn fcb_reader_select_bbox( + reader: *mut FcbFileReader, + min_x: f64, + min_y: f64, + max_x: f64, + max_y: f64, + error_out: *mut *mut c_char, +) -> *mut FcbFileIterator { + if reader.is_null() { + set_error(error_out, "Null reader pointer"); + return ptr::null_mut(); + } + let reader = Box::from_raw(reader); + let query = SpatialQuery::BBox(min_x, min_y, max_x, max_y); + match reader.inner.select_query(query, None, None) { + Ok(iter) => { + let features_count = iter.features_count().unwrap_or(0) as u64; + let helper = SeekableIterHelper { + iter, + cur_id: None, + cur_json: None, + finished: false, + }; + Box::into_raw(Box::new(FcbFileIterator { + inner: Box::new(helper), + features_count, + })) + } + Err(e) => { + set_error(error_out, &format!("Failed to select bbox: {e}")); + ptr::null_mut() + } + } +} + +// ============ Iterator API ============ + +/// Advance to the next feature. Returns 1 if a feature is available, 0 if done, -1 on error. +#[no_mangle] +pub unsafe extern "C" fn fcb_iterator_next( + iter: *mut FcbFileIterator, + error_out: *mut *mut c_char, +) -> i32 { + if iter.is_null() { + set_error(error_out, "Null iterator pointer"); + return -1; + } + match (*iter).inner.advance() { + Ok(true) => 1, + Ok(false) => 0, + Err(e) => { + set_error(error_out, &e); + -1 + } + } +} + +/// Get the current feature as a JSON string. +/// Caller must free the returned string with `fcb_free_string`. +#[no_mangle] +pub unsafe extern "C" fn fcb_iterator_current_json( + iter: *const FcbFileIterator, + error_out: *mut *mut c_char, +) -> *mut c_char { + if iter.is_null() { + set_error(error_out, "Null iterator pointer"); + return ptr::null_mut(); + } + match (*iter).inner.current_json() { + Ok(ptr) => ptr, + Err(e) => { + set_error(error_out, &e); + ptr::null_mut() + } + } +} + +/// Get the current feature ID. +/// Caller must free the returned string with `fcb_free_string`. +#[no_mangle] +pub unsafe extern "C" fn fcb_iterator_current_id( + iter: *const FcbFileIterator, + error_out: *mut *mut c_char, +) -> *mut c_char { + if iter.is_null() { + set_error(error_out, "Null iterator pointer"); + return ptr::null_mut(); + } + match (*iter).inner.current_id() { + Ok(ptr) => ptr, + Err(e) => { + set_error(error_out, &e); + ptr::null_mut() + } + } +} + +/// Get the total features count from the iterator. +#[no_mangle] +pub unsafe extern "C" fn fcb_iterator_features_count(iter: *const FcbFileIterator) -> u64 { + if iter.is_null() { + return 0; + } + (*iter).features_count +} + +// ============ Memory Management ============ + +/// Free a reader. Must be called when done with the reader. +#[no_mangle] +pub unsafe extern "C" fn fcb_reader_free(reader: *mut FcbFileReader) { + if !reader.is_null() { + drop(Box::from_raw(reader)); + } +} + +/// Free an iterator. Must be called when done with the iterator. +#[no_mangle] +pub unsafe extern "C" fn fcb_iterator_free(iter: *mut FcbFileIterator) { + if !iter.is_null() { + drop(Box::from_raw(iter)); + } +} + +/// Free a C string returned by any fcb_ function. +#[no_mangle] +pub unsafe extern "C" fn fcb_free_string(s: *mut c_char) { + if !s.is_null() { + drop(CString::from_raw(s)); + } +} + +// ============ Helper ============ + +unsafe fn set_error(error_out: *mut *mut c_char, msg: &str) { + if !error_out.is_null() { + if let Ok(cs) = CString::new(msg) { + *error_out = cs.into_raw(); + } + } +} diff --git a/src/rust/justfile b/src/rust/justfile new file mode 100644 index 0000000..9240ae7 --- /dev/null +++ b/src/rust/justfile @@ -0,0 +1,116 @@ +# FlatCityBuf Rust Workspace Justfile + +# Default recipe - list all available commands +default: + @just --list + +# Run all pre-commit checks (common, wasm, python) +pre-commit: check-common check-wasm check-py + +# Run common workspace checks (format, clippy, test, build) +check-common: + cargo fmt + cargo clippy --fix --allow-dirty --workspace --all-targets --all-features --exclude fcb_wasm --exclude fcb_py + cargo clippy --fix --allow-dirty -p fcb_wasm --target wasm32-unknown-unknown + cargo nextest run --all-features --workspace --exclude fcb_wasm --exclude fcb_py + cargo check --all-features --workspace --exclude fcb_wasm --exclude fcb_py + cargo build --workspace --all-features --exclude fcb_wasm --exclude fcb_py + +# Run WASM-specific checks +check-wasm: + cargo clippy --fix --allow-dirty -p fcb_wasm --target wasm32-unknown-unknown + cargo check -p fcb_wasm --target wasm32-unknown-unknown + cargo build -p fcb_wasm --target wasm32-unknown-unknown + +# Run Python-specific checks (sync, develop, lint, test) +check-py: + cd fcb_py && uv sync --extra dev + cd fcb_py && uv run maturin develop + cd fcb_py && uv run ruff check --fix . + cd fcb_py && uv run ruff format . + cd fcb_py && uv run pytest tests/ + +# Run FCB info command on test data +fcb_info: + cargo run -p fcb_cli info -i fcb_core/tests/data/delft.fcb + +# Generate file statistics (CSV output) +file_stats: + cargo run -p fcb_core --bin stats -- -d fcb_core/benchmark_data/ -f csv + +# Run benchmarks +bench: + cargo bench -p fcb_core --bench read -- --release + +# Build fcb_core release binary +build-fcb_core: + cargo build --release -p fcb_core + +# Build WASM package (web target, debug) +wasm-build: + cd wasm && wasm-pack build --target web --debug --no-pack + +# Build Python package (release) +py-build: + cd fcb_py && maturin build --release + +# Install Python package in development mode +py-develop: + cd fcb_py && maturin develop + +# Run Python tests +py-test: + cd fcb_py && uv run pytest tests/ + +# Clean Python build artifacts +py-clean: + cd fcb_py && cargo clean + cd fcb_py && rm -rf target/ + cd fcb_py && rm -rf *.so + cd fcb_py && rm -rf build/ + cd fcb_py && rm -rf dist/ + cd fcb_py && rm -rf *.egg-info + +# Serialize CityJSON to FCB (with attribute branching) +ser: + cargo run -p fcb_cli ser -i fcb_core/tests/data/delft.city.jsonl -o fcb_core/tests/data/delft.fcb -A -g --attr-branching-factor 64 + +# Format code +fmt: + cargo fmt + +# Check formatting without making changes +fmt-check: + cargo fmt --check + +# Run clippy (allow auto-fix) +clippy: + cargo clippy --fix --allow-dirty --workspace --all-targets + +# Run clippy without auto-fix +clippy-check: + cargo clippy --workspace --all-targets + +# Run all tests +test: + cargo nextest run --all-features --workspace --exclude fcb_wasm --exclude fcb_py + +# Run all tests with standard cargo +test-cargo: + cargo test --all-features --workspace --exclude fcb_wasm --exclude fcb_py + +# Clean build artifacts +clean: + cargo clean + +# Update dependencies +update: + cargo update + +# Check for security vulnerabilities +audit: + cargo audit + +# Generate documentation +docs: + cargo doc --no-deps --workspace --exclude fcb_wasm --exclude fcb_py --open diff --git a/src/rust/nodejs/Cargo.toml b/src/rust/nodejs/Cargo.toml new file mode 100644 index 0000000..1662314 --- /dev/null +++ b/src/rust/nodejs/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "fcb_nodejs" +version = "0.6.0" +edition = "2021" +authors = ["Hidemichi Baba "] +license = "MIT" +description = "Node.js native bindings for FlatCityBuf" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi = { workspace = true } +napi-derive = { workspace = true } +fcb_core = { workspace = true, features = ["http"] } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +bytes = { workspace = true } +chrono = { workspace = true } +ordered-float = { workspace = true } +thiserror = { workspace = true } +reqwest = { workspace = true } + +[build-dependencies] +napi-build = "2.1" diff --git a/src/rust/nodejs/README.md b/src/rust/nodejs/README.md new file mode 100644 index 0000000..781a55b --- /dev/null +++ b/src/rust/nodejs/README.md @@ -0,0 +1,214 @@ +# FlatCityBuf Node.js Native Bindings + +Native Node.js bindings for [FlatCityBuf](https://github.com/cityjson/flatcitybuf) — a binary format for CityJSON data with built-in spatial and attribute indexing. + +Built with [napi-rs](https://napi.rs/) for near-native performance, these bindings provide HTTP range request support for reading remote FCB files without downloading them entirely. + +## Installation + +The bindings are part of the `@cityjson/flatcitybuf` package. When running in Node.js, the native bindings are automatically used instead of the WASM fallback. + +```bash +npm install @cityjson/flatcitybuf +``` + +## Quick Start + +```javascript +import { FcbReader, NodeSpatialQuery, NodeAttrQuery } from "@cityjson/flatcitybuf"; + +// Open a remote FCB file +const reader = await FcbReader.open( + "https://storage.googleapis.com/flatcitybuf/delft.city.fcb" +); + +console.log(`Features: ${reader.featuresCount}`); +console.log(`CityJSON version: ${reader.cityjson().version}`); + +// Query by bounding box +const query = NodeSpatialQuery.bbox(84400, 447200, 84600, 447400); +const iter = await reader.selectSpatial(query); + +let feature; +while ((feature = await iter.next()) !== null) { + console.log(`Feature: ${feature.id}`); +} +``` + +## API Reference + +### `FcbReader` + +The main entry point for reading remote FCB files over HTTP. + +#### `FcbReader.open(url: string): Promise` + +Factory method that opens a remote FCB file. Fetches the header and spatial index metadata via HTTP range requests. + +```javascript +const reader = await FcbReader.open("https://example.com/city.fcb"); +``` + +#### `reader.featuresCount: number` + +Getter that returns the total number of features in the file. + +#### `reader.cityjson(): object` + +Returns CityJSON metadata (type, version, transform, CRS, metadata) extracted from the FCB header. + +```javascript +const cj = reader.cityjson(); +// { type: "CityJSON", version: "2.0", transform: { scale: [...], translate: [...] } } +``` + +#### `reader.selectAll(): Promise` + +Select all features for iteration. + +```javascript +const iter = await reader.selectAll(); +const features = await iter.collect(); // get all at once +``` + +#### `reader.selectSpatial(query: NodeSpatialQuery): Promise` + +Select features matching a spatial query (bounding box, point intersect, or nearest point). + +#### `reader.selectSpatialPaged(query, limit?, offset?): Promise` + +Select features matching a spatial query with pagination. + +```javascript +const query = NodeSpatialQuery.bbox(84400, 447200, 84600, 447400); +const iter = await reader.selectSpatialPaged(query, 10, 0); // first 10 +``` + +#### `reader.selectAttrQuery(query: NodeAttrQuery): Promise` + +Select features matching an attribute query. + +#### `reader.selectAttrQueryPaged(query, limit?, offset?): Promise` + +Select features matching an attribute query with pagination. + +### `FeatureIter` + +Async iterator over selected features. + +#### `iter.featuresCount(): number` + +Returns the number of features matching the query. + +#### `iter.next(): Promise` + +Returns the next CityJSONFeature object, or `null` when iteration is complete. + +Each feature has the structure: +```javascript +{ + type: "CityJSONFeature", + id: "NL.IMBAG.Pand.0503100000012869", + CityObjects: { ... }, + vertices: [[...], ...] +} +``` + +#### `iter.collect(): Promise` + +Collects all remaining features into an array. Useful for small result sets. + +```javascript +const features = await iter.collect(); +console.log(`Got ${features.length} features`); +``` + +### `NodeSpatialQuery` + +Factory class for creating spatial queries. + +#### `NodeSpatialQuery.bbox(minX, minY, maxX, maxY): NodeSpatialQuery` + +Create a bounding box query. Coordinates should be in the CRS of the FCB file. + +```javascript +const query = NodeSpatialQuery.bbox(84227.77, 445377.33, 85323.23, 446334.69); +``` + +#### `NodeSpatialQuery.pointIntersects(x, y): NodeSpatialQuery` + +Create a point intersection query — finds features whose bounding box contains the given point. + +```javascript +const query = NodeSpatialQuery.pointIntersects(84700.0, 446000.0); +``` + +#### `NodeSpatialQuery.pointNearest(x, y): NodeSpatialQuery` + +Create a nearest-point query — finds the feature closest to the given point. + +```javascript +const query = NodeSpatialQuery.pointNearest(84700.0, 446000.0); +``` + +#### `query.queryType: string` + +Returns the query type as a string: `"bbox"`, `"pointIntersects"`, or `"pointNearest"`. + +### `NodeAttrQuery` + +Query features by attribute values. + +#### `new NodeAttrQuery(conditions: Array<[field, operator, value]>)` + +Create an attribute query from an array of condition tuples. + +- **field**: Attribute name (string) +- **operator**: One of `"Eq"`, `"Gt"`, `"Ge"`, `"Lt"`, `"Le"`, `"Ne"` +- **value**: Comparison value (string, number, or boolean) + +```javascript +// Find a specific building by ID +const query = new NodeAttrQuery([ + ["identificatie", "Eq", "NL.IMBAG.Pand.0503100000012869"], +]); + +// Find tall buildings +const query = new NodeAttrQuery([["b3_h_dak_50p", "Gt", 20.0]]); + +// Multiple conditions (AND) +const query = new NodeAttrQuery([ + ["b3_h_dak_50p", "Gt", 10.0], + ["b3_h_dak_50p", "Lt", 30.0], +]); +``` + +## Building from Source + +Requires [Rust](https://rustup.rs/) and the napi-cli: + +```bash +npm install -g @napi-rs/cli + +# Debug build +just build-nodejs-dev + +# Release build +just build-nodejs +``` + +## Running Tests + +```bash +cd src/ts +node --test test/node.test.mjs +``` + +## Architecture + +The bindings wrap `fcb_core`'s `HttpFcbReader` via napi-rs. Key design decisions: + +- **HTTP range requests**: Only fetches the bytes needed for each query, enabling efficient access to large remote files. +- **Connection re-opening**: Each query method re-opens the HTTP connection because `HttpFcbReader::select_*()` consumes `self` (Rust ownership). Metadata is cached on `open()` to avoid redundant fetches. +- **Thread-safe iteration**: `FeatureIter` uses `Mutex` internally because napi-rs requires `&self` (not `&mut self`) for async methods. +- **Conditional exports**: When installed as `@cityjson/flatcitybuf`, Node.js automatically uses these native bindings while browsers use the WASM version. diff --git a/src/rust/nodejs/build.rs b/src/rust/nodejs/build.rs new file mode 100644 index 0000000..9fc2367 --- /dev/null +++ b/src/rust/nodejs/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/src/rust/nodejs/src/error.rs b/src/rust/nodejs/src/error.rs new file mode 100644 index 0000000..66b42c5 --- /dev/null +++ b/src/rust/nodejs/src/error.rs @@ -0,0 +1,6 @@ +use napi::Error as NapiError; + +/// Convert fcb_core errors to napi errors +pub fn to_napi_error(err: fcb_core::Error) -> NapiError { + NapiError::from_reason(err.to_string()) +} diff --git a/src/rust/nodejs/src/iter.rs b/src/rust/nodejs/src/iter.rs new file mode 100644 index 0000000..726726a --- /dev/null +++ b/src/rust/nodejs/src/iter.rs @@ -0,0 +1,66 @@ +use crate::error::to_napi_error; + +use fcb_core::http_reader::AsyncFeatureIter; +use napi::bindgen_prelude::*; +use napi_derive::napi; +use tokio::sync::Mutex; + +/// Async iterator over FCB features. +/// +/// Each call to `next()` fetches the next feature from the remote file +/// and returns it as a CityJSON feature object. +#[napi] +pub struct FeatureIter { + inner: Mutex>, + count: Option, +} + +impl FeatureIter { + pub fn new(inner: AsyncFeatureIter) -> Self { + let count = inner.features_count(); + Self { + inner: Mutex::new(inner), + count, + } + } +} + +#[napi] +impl FeatureIter { + /// Get the number of selected features, if known. + #[napi] + pub fn features_count(&self) -> Option { + self.count.map(|c| c as u32) + } + + /// Read the next feature. Returns null when iteration is complete. + #[napi] + pub async fn next(&self) -> Result> { + let mut iter = self.inner.lock().await; + let Some(_buffer) = iter.next().await.map_err(to_napi_error)? else { + return Ok(None); + }; + + let cj_feature = iter.cur_cj_feature().map_err(to_napi_error)?; + let value = + serde_json::to_value(&cj_feature).map_err(|e| Error::from_reason(e.to_string()))?; + Ok(Some(value)) + } + + /// Collect all remaining features into an array. + #[napi] + pub async fn collect(&self) -> Result> { + let mut iter = self.inner.lock().await; + let mut features = Vec::new(); + loop { + let Some(_buffer) = iter.next().await.map_err(to_napi_error)? else { + break; + }; + let cj_feature = iter.cur_cj_feature().map_err(to_napi_error)?; + let value = serde_json::to_value(&cj_feature) + .map_err(|e| Error::from_reason(e.to_string()))?; + features.push(value); + } + Ok(features) + } +} diff --git a/src/rust/nodejs/src/lib.rs b/src/rust/nodejs/src/lib.rs new file mode 100644 index 0000000..8b8f91e --- /dev/null +++ b/src/rust/nodejs/src/lib.rs @@ -0,0 +1,7 @@ +#![deny(clippy::all)] + +mod error; +mod iter; +mod query; +mod reader; +mod types; diff --git a/src/rust/nodejs/src/query.rs b/src/rust/nodejs/src/query.rs new file mode 100644 index 0000000..b5aed6b --- /dev/null +++ b/src/rust/nodejs/src/query.rs @@ -0,0 +1,127 @@ +use crate::types::{js_value_to_keytype, parse_operator}; + +use fcb_core::packed_rtree::Query as SpatialQuery; +use fcb_core::AttrQuery; +use napi::bindgen_prelude::*; +use napi_derive::napi; + +/// Spatial query for filtering features by location. +/// +/// Supports three query types: +/// - `bbox`: Bounding box query with minX, minY, maxX, maxY +/// - `pointIntersects`: Point intersection query with x, y +/// - `pointNearest`: Nearest point query with x, y +#[napi] +pub struct NodeSpatialQuery { + inner: SpatialQuery, +} + +#[napi] +impl NodeSpatialQuery { + /// Create a bounding box query. + #[napi(factory)] + pub fn bbox(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> NodeSpatialQuery { + NodeSpatialQuery { + inner: SpatialQuery::BBox(min_x, min_y, max_x, max_y), + } + } + + /// Create a point intersection query. + #[napi(factory)] + pub fn point_intersects(x: f64, y: f64) -> NodeSpatialQuery { + NodeSpatialQuery { + inner: SpatialQuery::PointIntersects(x, y), + } + } + + /// Create a nearest point query. + #[napi(factory)] + pub fn point_nearest(x: f64, y: f64) -> NodeSpatialQuery { + NodeSpatialQuery { + inner: SpatialQuery::PointNearest(x, y), + } + } + + /// Get the query type as a string. + #[napi(getter)] + pub fn query_type(&self) -> String { + match self.inner { + SpatialQuery::BBox(_, _, _, _) => "bbox".to_string(), + SpatialQuery::PointIntersects(_, _) => "pointIntersects".to_string(), + SpatialQuery::PointNearest(_, _) => "pointNearest".to_string(), + } + } +} + +impl NodeSpatialQuery { + pub fn to_core_query(&self) -> Result { + Ok(match self.inner { + SpatialQuery::BBox(a, b, c, d) => SpatialQuery::BBox(a, b, c, d), + SpatialQuery::PointIntersects(x, y) => SpatialQuery::PointIntersects(x, y), + SpatialQuery::PointNearest(x, y) => SpatialQuery::PointNearest(x, y), + }) + } +} + +/// Attribute query for filtering features by attribute values. +/// +/// Constructed from an array of conditions, where each condition +/// is a tuple of [field, operator, value]. +/// +/// Operators: "Eq", "Gt", "Ge", "Lt", "Le", "Ne" +/// +/// Example: +/// ```js +/// const query = new NodeAttrQuery([ +/// ["height", "Gt", 10.0], +/// ["name", "Eq", "building-1"] +/// ]); +/// ``` +#[napi] +pub struct NodeAttrQuery { + inner: AttrQuery, +} + +#[napi] +impl NodeAttrQuery { + /// Create an attribute query from an array of condition tuples. + /// + /// Each condition is [field: string, operator: string, value: any]. + #[napi(constructor)] + pub fn new(conditions: Vec) -> Result { + let mut inner: AttrQuery = Vec::new(); + + for condition in conditions { + let arr = condition + .as_array() + .ok_or_else(|| Error::from_reason("Each condition must be an array"))?; + if arr.len() < 3 { + return Err(Error::from_reason( + "Each condition must have 3 elements: [field, operator, value]", + )); + } + + let field = arr[0] + .as_str() + .ok_or_else(|| Error::from_reason("Field must be a string"))? + .to_string(); + + let op_str = arr[1] + .as_str() + .ok_or_else(|| Error::from_reason("Operator must be a string"))?; + let operator = parse_operator(op_str)?; + + let value = js_value_to_keytype(&arr[2])?; + + inner.push((field, operator, value)); + } + + Ok(NodeAttrQuery { inner }) + } +} + +impl NodeAttrQuery { + pub fn to_core_query(&self) -> Result { + Ok(self.inner.clone()) + } +} diff --git a/src/rust/nodejs/src/reader.rs b/src/rust/nodejs/src/reader.rs new file mode 100644 index 0000000..6940228 --- /dev/null +++ b/src/rust/nodejs/src/reader.rs @@ -0,0 +1,145 @@ +use crate::error::to_napi_error; +use crate::iter::FeatureIter; +use crate::query::{NodeAttrQuery, NodeSpatialQuery}; + +use fcb_core::deserializer::to_cj_metadata; +use fcb_core::http_reader::HttpFcbReader; +use napi::bindgen_prelude::*; +use napi_derive::napi; + +/// FlatCityBuf HTTP reader for remote FCB files. +/// +/// Opens a remote FCB file via HTTP range requests and provides +/// methods to query features by spatial bounds or attribute filters. +/// +/// Note: Each query method re-opens the HTTP connection because the +/// underlying reader is consumed during selection. This matches the +/// Python async binding pattern. +#[napi] +pub struct FcbReader { + url: String, + /// Cached CityJSON metadata from the initial open + cityjson_cache: serde_json::Value, + /// Cached feature count from header + feature_count: u64, +} + +#[napi] +impl FcbReader { + /// Open a remote FCB file by URL. + /// + /// Fetches the header and spatial index metadata via HTTP range requests. + #[napi(factory)] + pub async fn open(url: String) -> Result { + let reader = HttpFcbReader::open(&url).await.map_err(to_napi_error)?; + let header = reader.header(); + let cj = to_cj_metadata(&header).map_err(to_napi_error)?; + let cityjson_cache = + serde_json::to_value(&cj).map_err(|e| Error::from_reason(e.to_string()))?; + let feature_count = header.features_count(); + + Ok(FcbReader { + url, + cityjson_cache, + feature_count, + }) + } + + /// Get CityJSON metadata (transform, CRS, metadata object). + /// + /// Returns the CityJSON-compatible metadata extracted from the FCB header. + #[napi] + pub fn cityjson(&self) -> serde_json::Value { + self.cityjson_cache.clone() + } + + /// Get the feature count from the header. + #[napi(getter)] + pub fn features_count(&self) -> u32 { + self.feature_count as u32 + } + + /// Select all features. Returns an async iterator. + #[napi] + pub async fn select_all(&self) -> Result { + let reader = HttpFcbReader::open(&self.url) + .await + .map_err(to_napi_error)?; + let iter = reader.select_all().await.map_err(to_napi_error)?; + Ok(FeatureIter::new(iter)) + } + + /// Select features by spatial query (bbox, point intersects, or point nearest). + #[napi] + pub async fn select_spatial(&self, query: &NodeSpatialQuery) -> Result { + let inner_query = query.to_core_query()?; + let reader = HttpFcbReader::open(&self.url) + .await + .map_err(to_napi_error)?; + let iter = reader + .select_query(inner_query) + .await + .map_err(to_napi_error)?; + Ok(FeatureIter::new(iter)) + } + + /// Select features by spatial query with pagination. + #[napi] + pub async fn select_spatial_paged( + &self, + query: &NodeSpatialQuery, + limit: Option, + offset: Option, + ) -> Result { + let inner_query = query.to_core_query()?; + let reader = HttpFcbReader::open(&self.url) + .await + .map_err(to_napi_error)?; + let iter = reader + .select_query_paged( + inner_query, + limit.map(|l| l as usize), + offset.map(|o| o as usize), + ) + .await + .map_err(to_napi_error)?; + Ok(FeatureIter::new(iter)) + } + + /// Select features by attribute query. + #[napi] + pub async fn select_attr_query(&self, query: &NodeAttrQuery) -> Result { + let core_query = query.to_core_query()?; + let reader = HttpFcbReader::open(&self.url) + .await + .map_err(to_napi_error)?; + let iter = reader + .select_attr_query(&core_query) + .await + .map_err(to_napi_error)?; + Ok(FeatureIter::new(iter)) + } + + /// Select features by attribute query with pagination. + #[napi] + pub async fn select_attr_query_paged( + &self, + query: &NodeAttrQuery, + limit: Option, + offset: Option, + ) -> Result { + let core_query = query.to_core_query()?; + let reader = HttpFcbReader::open(&self.url) + .await + .map_err(to_napi_error)?; + let iter = reader + .select_attr_query_paged( + &core_query, + limit.map(|l| l as usize), + offset.map(|o| o as usize), + ) + .await + .map_err(to_napi_error)?; + Ok(FeatureIter::new(iter)) + } +} diff --git a/src/rust/nodejs/src/types.rs b/src/rust/nodejs/src/types.rs new file mode 100644 index 0000000..4baba21 --- /dev/null +++ b/src/rust/nodejs/src/types.rs @@ -0,0 +1,49 @@ +use chrono::{DateTime, Utc}; +use fcb_core::static_btree::{FixedStringKey, Float, KeyType, Operator}; +use napi::bindgen_prelude::*; + +/// Parse operator string into fcb_core Operator +pub fn parse_operator(op: &str) -> Result { + match op { + "Eq" => Ok(Operator::Eq), + "Gt" => Ok(Operator::Gt), + "Ge" => Ok(Operator::Ge), + "Lt" => Ok(Operator::Lt), + "Le" => Ok(Operator::Le), + "Ne" => Ok(Operator::Ne), + _ => Err(Error::from_reason(format!("Invalid operator: {op}"))), + } +} + +/// Parse a JS value into an fcb_core KeyType for attribute queries. +/// +/// JS types map as follows: +/// - boolean → KeyType::Bool +/// - string (ISO 8601 date) → KeyType::DateTime (if parseable) +/// - string → KeyType::StringKey50 or StringKey100 +/// - number → KeyType::Float64 +pub fn js_value_to_keytype(value: &serde_json::Value) -> Result { + match value { + serde_json::Value::Bool(b) => Ok(KeyType::Bool(*b)), + serde_json::Value::Number(n) => { + let f = n + .as_f64() + .ok_or_else(|| Error::from_reason("Number must be a finite f64"))?; + Ok(KeyType::Float64(Float(f))) + } + serde_json::Value::String(s) => { + // Try to parse as DateTime first + if let Ok(dt) = s.parse::>() { + return Ok(KeyType::DateTime(dt)); + } + if s.len() > 50 { + Ok(KeyType::StringKey100(FixedStringKey::<100>::from_str(s))) + } else { + Ok(KeyType::StringKey50(FixedStringKey::<50>::from_str(s))) + } + } + _ => Err(Error::from_reason(format!( + "Unsupported value type in query: {value}" + ))), + } +} diff --git a/src/ts/package.json b/src/ts/package.json index 30cb8fc..49b59ad 100644 --- a/src/ts/package.json +++ b/src/ts/package.json @@ -1,8 +1,8 @@ { "name": "@cityjson/flatcitybuf", "type": "module", - "version": "0.2.0", - "description": "FlatCityBuf is a library for reading and writing CityJSON with FlatBuffers.", + "version": "0.3.0", + "description": "FlatCityBuf is a library for reading and writing CityJSON with FlatBuffers. Supports both browser (WASM) and Node.js (native).", "author": { "name": "Hidemichi Baba", "email": "baba.papa1120.ba@gmail.com" @@ -20,17 +20,40 @@ "cityjson", "flatbuffers", "wasm", + "nodejs", "geospatial", "3d-city-models" ], + "exports": { + ".": { + "browser": { + "import": "./fcb_wasm.js", + "types": "./fcb_wasm.d.ts" + }, + "node": { + "import": "./node/index.js", + "types": "./node/index.d.ts" + }, + "default": { + "import": "./node/index.js", + "types": "./node/index.d.ts" + } + } + }, + "main": "fcb_wasm.js", + "types": "fcb_wasm.d.ts", "files": [ "fcb_wasm_bg.wasm", "fcb_wasm.js", - "fcb_wasm.d.ts" + "fcb_wasm.d.ts", + "node/" ], - "main": "fcb_wasm.js", - "types": "fcb_wasm.d.ts", "sideEffects": [ "./snippets/*" - ] + ], + "scripts": { + "build:node": "cd ../rust/nodejs && napi build --platform --release --js ../ts/node/index.js --dts ../ts/node/index.d.ts", + "build:wasm": "cd ../rust/wasm && wasm-pack build --release --target web --out-dir ../../ts", + "build": "npm run build:wasm && npm run build:node" + } }