diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2629699 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + # push only on main; PR branches are covered by pull_request. Scoping push this + # way avoids the double run (a push to a branch with an open PR otherwise fires + # both events). + push: + branches: [main] + pull_request: + +# charttable builds with Zig 0.16 into libcharttable.a, libcharttable.so and the +# C header. Every consumer takes one of those three, so CI builds all of them. +# +# Three jobs, because no single runner compiles the whole library: +# * build-test — the gate. Vulkan backend, system codecs, the full test suite. +# * macos — the only runner that compiles the Metal backend and the ObjC +# shim, and the only one that builds the demo app. +# * cross — every target a release ships, so a tag does not discover a +# broken cross build after the fact. +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # The test binary is a consumer of the renderer, so it links the Vulkan + # loader. The codecs come from the system here, which is the default path + # off Windows and the one an embedder on Linux takes. + - name: Install the Vulkan loader and the codecs + run: | + sudo apt-get update + sudo apt-get install -y libvulkan-dev libwebp-dev libpng-dev + + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + - name: Format check + run: zig fmt --check src/ build/ build.zig + + # Debug keeps the tests' safety checks. The cross matrix covers ReleaseFast. + # The GPU tests open a device and skip themselves when the runner has none, + # so the suite passes headless. + - name: Build and test + run: zig build install test + + - name: Build the shared library + run: zig build shared + + macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + + # The default codec path on macOS: Homebrew's static archives, which is + # what a developer on this platform links. + - name: Install the codecs + run: brew install webp libpng + + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + # Compiles gpu_metal.zig and metal_shim.m, which no other runner reaches. + # Build only: the Metal tests need a real device, and this runner is not + # promised one. + - name: Build the libraries + run: zig build lib shared -Doptimize=ReleaseFast + + - name: Build the chart demo + run: zig build example + + cross: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { target: x86_64-linux-gnu } + - { target: aarch64-linux-gnu } + - { target: x86_64-windows-gnu } + - { target: aarch64-windows-gnu } + name: ${{ matrix.target }} + steps: + - uses: actions/checkout@v7 + + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + # -Dcodec-source, as the release builds use: the system headers of this + # runner describe this runner, so a cross build compiles its own libwebp + # and libpng instead. Builds both linkages, because the shared one is a + # real link and fails where the archive still builds. + - name: Cross-compile ${{ matrix.target }} + run: | + zig build lib shared -Doptimize=ReleaseFast -Dcodec-source \ + -Dtarget=${{ matrix.target }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c25ed3d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,126 @@ +name: Release + +# Push a `vX.Y.Z` tag to cut a release. That is the whole procedure: the tag is +# the version, so no file needs a bump first. Every supported target is built, +# the archives are attached to a GitHub release, and the Homebrew tap is +# updated. A tag carrying a suffix (`v0.2.0-rc1`) publishes as a prerelease and +# leaves the tap alone — use one to prove the matrix before the real tag. +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + build: + name: ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # macOS builds run natively per arch: the Metal backend compiles the + # ObjC shim against the platform SDK, which no other runner carries. + - { target: aarch64-macos, os: macos-latest } + - { target: x86_64-macos, os: macos-15-intel } + - { target: x86_64-linux-gnu, os: ubuntu-latest, deb: amd64 } + - { target: aarch64-linux-gnu, os: ubuntu-latest, deb: arm64 } + - { target: x86_64-windows-gnu, os: ubuntu-latest } + - { target: aarch64-windows-gnu, os: ubuntu-latest } + steps: + - uses: actions/checkout@v7 + + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + # -Dversion carries the tag into the library: charttable_version() reports + # it and the shared library stamps it into its soname. The build refuses a + # tag that is not a semantic version. + # + # Two prefixes: on Windows the static archive and the DLL's import library + # are both charttable.lib, so one prefix loses the archive. + # + # -Dcodec-source everywhere, so every released library carries its own + # libwebp and libpng rather than hunting for them on the target machine. + - name: Build + run: | + v="${GITHUB_REF_NAME#v}" + for step in lib shared; do + case $step in lib) p=out/static ;; shared) p=out/shared ;; esac + zig build "$step" -p "$p" -Dversion="$v" \ + -Dtarget=${{ matrix.target }} -Doptimize=ReleaseFast -Dcodec-source + done + + - name: Package + run: scripts/package-release.sh "${GITHUB_REF_NAME#v}" "${{ matrix.target }}" "${{ matrix.deb }}" + + - uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.target }} + path: dist/* + + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v8 + with: + path: dist + merge-multiple: true + + - name: Checksums + run: cd dist && shasum -a 256 charttable-* libcharttable-* > SHA256SUMS + + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + # A release may already exist: cutting one from the web UI is what + # created the tag that started this run, and a re-run after a failed + # leg lands here twice. Attach to it instead of failing on the 422, + # and leave hand-written notes alone. + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" dist/* --clobber + else + gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag \ + ${{ contains(github.ref_name, '-') && '--prerelease' || '' }} \ + dist/* + fi + + homebrew: + needs: release + if: ${{ !contains(github.ref_name, '-') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + path: dist + merge-multiple: true + + # A token with write access to the tap repo. Without it the release still + # stands; only the tap goes stale. + - name: Update the Homebrew tap + env: + TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + if [ -z "$TAP_TOKEN" ]; then + echo "::warning::HOMEBREW_TAP_TOKEN is not set — skipping the tap update" + exit 0 + fi + v="${GITHUB_REF_NAME#v}" + git clone --depth 1 \ + "https://x-access-token:$TAP_TOKEN@github.com/${{ github.repository_owner }}/homebrew-tap" tap + mkdir -p tap/Formula + scripts/brew-formula.sh "$v" dist > tap/Formula/charttable.rb + cd tap + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/charttable.rb + git diff --cached --quiet || git commit -m "charttable $v" + git push diff --git a/.gitignore b/.gitignore index d527deb..901e373 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ specs/ # Example app build output examples/macos/chartview + +# Where scripts/package-release.sh builds and stages a release +out/ +dist/ diff --git a/README.md b/README.md index 7b98f34..1da973b 100644 --- a/README.md +++ b/README.md @@ -2,36 +2,85 @@

🗺️ A native map renderer for the MapLibre style spec.
- charttable takes a MapLibre style and vector tiles and draws them straight - to the GPU — Metal, Vulkan, Direct3D 12 or SDL — holding 60 fps through - pan, pinch-zoom and rotation. One Zig library with a C ABI. + charttable draws a MapLibre style and vector tiles on the GPU. It holds 60 fps through pan, pinch-zoom and rotation. +

+ +

+ + CI +

--- -> [!NOTE] -> Extracted from the [Lookout Marine](https://github.com/beetlebugorg/lookout-core) -> rendering engine and generalized: where Lookout renders one hard-wired -> nautical portrayal, charttable renders whatever the style says. It is a -> **clean-room** implementation of the published -> [MapLibre Style Specification](https://maplibre.org/maplibre-style-spec/) — -> see THIRD-PARTY-NOTICES.md for provenance. - -## Why it is different - -- **Tessellate once, transform per frame.** Tiles lay out into resident GPU - buckets; a pan, zoom or rotation is a matrix change, never a rebuild. An - idle map uses no CPU time. -- **Restyling is a buffer refill, not a re-layout.** Geometry and evaluated - paint live in separate vertex streams: a palette flip or a - `setPaintProperty` re-evaluates paint and re-uploads one stream. -- **Filters evaluate at fractional zoom.** Zoom gates bake into the resident - scene as per-vertex visibility windows, so features appear at their exact - zoom, not the next integer step. -- **Native everywhere the engine goes.** The same scene contract drives - Metal, Vulkan, D3D12 and SDL backends. +charttable is one Zig library behind a C ABI. It draws through Metal, Vulkan or Direct3D 12, so the same library serves macOS, iOS, Linux, Windows and Android. It does no networking. Tiles come from a local pmtiles archive, or from your code through a resource callback. + +## Install + +``` +brew install beetlebugorg/tap/charttable +``` + +Every tag also publishes an archive per target on [Releases](https://github.com/beetlebugorg/charttable/releases). Each one holds the C header, a static library and a shared library. + +| Target | Static | Shared | +| --- | --- | --- | +| macOS, arm64 and x86_64 | `libcharttable.a` | `libcharttable.dylib` | +| Linux, arm64 and x86_64 | `libcharttable.a` | `libcharttable.so` | +| Windows, arm64 and x86_64 | `charttable.lib` | `charttable.dll` | + +Linux also gets a Debian package. The released libraries carry their own libwebp and libpng, so nothing else has to be installed first. + +## Use + +```c +#include + +charttable *ct = charttable_open(NULL); +charttable_set_style_json(ct, style_json, strlen(style_json)); +charttable_add_source_pmtiles(ct, "basemap", "/maps/planet.pmtiles"); +charttable_attach_surface(ct, CHARTTABLE_NATIVE_METAL_LAYER, layer, w, h); + +charttable_view v = { .lon = -76.48, .lat = 38.98, .zoom = 12 }; +charttable_set_view(ct, &v); + +/* once per frame */ +charttable_tick(ct, dt_ms); +if (charttable_needs_redraw(ct)) charttable_render(ct); +``` + +The API is one opaque handle and about forty functions. A mutex inside the handle serializes them, so every call is safe from any thread. The three functions that touch the surface must be called from the thread that owns it, because the platform requires that. + +## What it renders + +The target is the whole [MapLibre Style Specification](https://maplibre.org/maplibre-style-spec/). What MapLibre draws from a style, charttable should draw from the same style. There is no charttable dialect and no porting step. The official conformance fixtures are vendored and run as the oracle, so the coverage is measured. 575 of the 577 expression fixtures pass today. + +charttable now draws `background`, `fill`, `line`, `symbol`, `raster`, `hillshade` and `color-relief` layers, from `vector`, `raster` and `raster-dem` sources. `circle`, `geojson` and `fill-extrusion` are next. + +A layer or property charttable does not support yet is skipped, and the reason goes into `charttable_style_diagnostics()`. Read that after you load a style. + +## Why it is fast + +- **A tile is turned into triangles once.** After that, a pan, zoom or rotation draws the same geometry with a new matrix. A map sitting still uses no CPU. +- **Restyling touches the paint stream only.** Geometry and evaluated paint sit in separate vertex streams. Changing a color, an opacity or a whole palette re-uploads the paint and leaves the geometry alone, so a day-to-night switch lands in one frame. +- **Features appear at the zoom the style names.** Each vertex carries the zoom range it is visible in, so a feature fades in at 12.4 if the style says 12.4, not at 13. +- **One scene, three backends.** Metal, Vulkan and D3D12 draw from the same prepared scene, so a fix in the renderer reaches all three. + +## Build + +Zig 0.16.0. + +``` +zig build test # run the suite +zig build lib # libcharttable.a and the header, into zig-out +zig build shared # the shared library, beside it +``` + +Add `-Dcodec-source` to compile libwebp and libpng in rather than link the platform copies. A cross build needs it. `-Dgpu=vk` picks the Vulkan backend on Windows, where D3D12 is the default. `-Dversion` sets what `charttable_version()` reports and what the shared library stamps into its soname. A release passes the tag, and a source build reports `0.0.0-dev`. ## Status -Early extraction. See DESIGN.md for the architecture, the conformance tiers, -and the milestone plan. Building: `zig build test` (Zig 0.16.0). +charttable is early. The engine draws, and the spec coverage above is the current work. See DESIGN.md for the architecture, the conformance tiers and the milestone plan. + +> [!NOTE] +> Extracted from the [Lookout Marine](https://github.com/beetlebugorg/lookout-core) rendering engine and generalized. Where Lookout renders one hard-wired nautical portrayal, charttable renders whatever the style says. It is a **clean-room** implementation of the published spec. See THIRD-PARTY-NOTICES.md for provenance. diff --git a/build.zig b/build.zig index fbec2a4..3973197 100644 --- a/build.zig +++ b/build.zig @@ -1,5 +1,8 @@ const std = @import("std"); const codecs = @import("build/codecs.zig"); +/// Read for `.version`, which is the dev sentinel a source build reports. A +/// release passes the tag as `-Dversion` instead. +const zon = @import("build.zig.zon"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); @@ -30,84 +33,17 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); - if (android) { - // bionic's nullability-on-array declarations ("const struct timeval - // _Nonnull [2]") break translate-c, and the @cImports here (png.h, - // vulkan.h) reach sys/ headers through the NDK sysroot. The - // annotations are hints only; defining them empty drops them for our - // parse and the C compiles alike — the same neutralisation - // lookout-marine applies to its own module. - mod.addCMacro("_Nonnull", ""); - mod.addCMacro("_Nullable", ""); - mod.addCMacro("_Null_unspecified", ""); - } - - // The Metal backend (src/gpu/gpu_metal.zig + metal_shim.m). The shader - // source rides an anonymous import (`@embedFile("metal_msl")`) and is - // compiled by the shim at runtime — no offline shader toolchain. The - // import itself is target-independent; only the ObjC shim and the - // frameworks are Apple-gated (non-mac targets select gpu_none.zig and - // never analyze the Metal backend). - mod.addAnonymousImport("metal_msl", .{ .root_source_file = b.path("shaders/metal.metal") }); - - // The Vulkan backend (src/gpu/gpu_vk.zig). Everything off Apple draws with - // it. Unlike Metal there is no runtime shader compiler, so the programs - // ride in precompiled as SPIR-V; shaders/vk/README.md holds the command - // that regenerates them. The headers are vendored with no VK_USE_PLATFORM_* - // (those drag in the X11, Wayland and Windows SDKs) and the loader is - // linked by the consumer, which is what lets one build serve every window - // system. - // The D3D12 backend (src/gpu/gpu_d3d12.zig). The HLSL rides in as source - // and is compiled by d3dcompiler_47.dll at open, so there is no offline - // shader toolchain and no import library: d3d12.dll, dxgi.dll and the - // compiler are all loaded by name at runtime (src/gpu/c_d3d12.zig). - if (use_d3d12) { - const hlsl = [_][2][]const u8{ - .{ "d3d12_fill_vert", "shaders/d3d12/fill.vert.hlsl" }, - .{ "d3d12_fill_frag", "shaders/d3d12/fill.frag.hlsl" }, - .{ "d3d12_pattern_vert", "shaders/d3d12/pattern.vert.hlsl" }, - .{ "d3d12_pattern_frag", "shaders/d3d12/pattern.frag.hlsl" }, - .{ "d3d12_sprite_vert", "shaders/d3d12/sprite.vert.hlsl" }, - .{ "d3d12_sprite_frag", "shaders/d3d12/sprite.frag.hlsl" }, - .{ "d3d12_sdf_frag", "shaders/d3d12/sdf.frag.hlsl" }, - .{ "d3d12_overlay_vert", "shaders/d3d12/overlay.vert.hlsl" }, - .{ "d3d12_overlay_frag", "shaders/d3d12/overlay.frag.hlsl" }, - }; - for (hlsl) |e| mod.addAnonymousImport(e[0], .{ .root_source_file = b.path(e[1]) }); - } - - if (use_vk) { - const spv = [_][2][]const u8{ - .{ "fill_vert_spv", "shaders/vk/fill.vert.spv" }, - .{ "fill_frag_spv", "shaders/vk/fill.frag.spv" }, - .{ "sprite_vert_spv", "shaders/vk/sprite.vert.spv" }, - .{ "sprite_frag_spv", "shaders/vk/sprite.frag.spv" }, - .{ "sdf_frag_spv", "shaders/vk/sdf.frag.spv" }, - .{ "pattern_vert_spv", "shaders/vk/pattern.vert.spv" }, - .{ "pattern_frag_spv", "shaders/vk/pattern.frag.spv" }, - .{ "overlay_vert_spv", "shaders/vk/overlay.vert.spv" }, - .{ "overlay_frag_spv", "shaders/vk/overlay.frag.spv" }, - }; - for (spv) |e| mod.addAnonymousImport(e[0], .{ .root_source_file = b.path(e[1]) }); - mod.addIncludePath(b.path("vendor/vulkan/include")); - } - - if (apple) { - mod.addIncludePath(b.path("src/gpu")); // metal_shim.h for the @cImport - // Manual retain/release on purpose — objects live in C structs - // (same pattern as lookout-marine's build.zig). - mod.addCSourceFile(.{ - .file = b.path("src/gpu/metal_shim.m"), - .flags = &.{ "-O2", "-fno-objc-arc", "-fno-sanitize=undefined" }, - }); - mod.linkFramework("Metal", .{}); - mod.linkFramework("QuartzCore", .{}); - mod.linkFramework("Foundation", .{}); - } + // The same module again, for `zig build test`. A test binary IS the + // consumer of the renderer, so it names the Vulkan loader itself; the + // library must not. Keeping the two apart is what leaves that one link + // input off every archive, shared library and cross build. + const test_mod = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); - // Where the vendored spec conformance fixtures live (absolute, so the - // test binary finds them regardless of its own cwd). The harness skips - // itself when the directory is absent. // libwebp, when the host has it. Optional on purpose: charttable's own // PNG reader covers what tile servers usually send, and a hard // dependency would land on every embedder. Tile servers that serve WebP @@ -127,47 +63,141 @@ pub fn build(b: *std.Build) void { const d = b.option([]const u8, "codec-dir", "Directory of cross-built codec archives (include/ + lib/)") orelse break :blk null; break :blk if (d.len == 0) null else d; }; - // Under a sysroot the SDK's own headers are not on the search path for - // this module's C sources, and a framework header that includes a plain - // one (Security.h -> libDER/DERItem.h) stops resolving. The host build - // passes a sysroot on every Xcode cross build. - if (b.sysroot) |sr| { - mod.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ sr, "usr/include" }) }); - } - if (use_webp or use_libpng) { - // Windows and Android carry neither Homebrew nor a system copy of - // these, and both options default to on, so the sources are fetched - // by the package manager and compiled in (build/codecs.zig) — zlib - // included, so nothing here asks the linker to search for -lz. - // Naming a codec-dir still wins, for a build with its own archives. - if ((windows or android) and codec_dir == null) { - _ = codecs.addFromSource(b, mod, target, use_webp, use_libpng); - } else { - if (codec_dir) |dir| { - mod.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ dir, "include" }) }); - } else if (target.result.os.tag == .macos) { - mod.addIncludePath(.{ .cwd_relative = "/opt/homebrew/include" }); - } - if (use_webp) linkCodec(b, mod, target, codec_dir, "webp"); - if (use_libpng) { - linkCodec(b, mod, target, codec_dir, "png16"); - // libpng's objects arrive with their zlib symbols undefined, so - // zlib has to resolve here as well as in whatever links this. - linkZlib(b, mod); - } - } - } + // Compile the codecs from source instead of taking them from the platform. + // Windows and Android have neither Homebrew nor a system copy, so they turn + // it on by themselves. A cross build asks for it too: the system headers of + // the host describe the host, and a release wants an archive that carries + // its own decoders rather than one that hunts for them on the target. + const codec_source = b.option(bool, "codec-source", "Compile libwebp and libpng from source") orelse (windows or android); + // The version the library reports and the shared library stamps into its + // soname. A release passes the tag, so nobody hand-edits a version to match + // one. build.zig.zon carries the default, for every other build. + const version = b.option([]const u8, "version", "Version the library reports (default: build.zig.zon)") orelse zon.version; + const semver = std.SemanticVersion.parse(version) catch @panic("-Dversion is not a semantic version"); const ct_opts = b.addOptions(); + // NUL-terminated, so charttable_version() hands the pointer straight to C. + ct_opts.addOption([:0]const u8, "version", b.allocator.dupeZ(u8, version) catch @panic("OOM")); ct_opts.addOption(bool, "webp", use_webp); ct_opts.addOption(bool, "libpng", use_libpng); // Which renderer src/gpu/gpu.zig selects on Windows. ct_opts.addOption(bool, "gpu_d3d12", use_d3d12); + // Where the vendored spec conformance fixtures live (absolute, so the + // test binary finds them regardless of its own cwd). The harness skips + // itself when the directory is absent. ct_opts.addOption([]const u8, "spec_fixture_dir", b.pathFromRoot("test/spec/expression")); ct_opts.addOption([]const u8, "report_path", b.pathFromRoot("test/spec/conformance-failures.txt")); ct_opts.addOption([]const u8, "assets_dir", b.pathFromRoot("test/assets")); ct_opts.addOption([]const u8, "out_dir", b.pathFromRoot("zig-out")); - mod.addOptions("ct_build", ct_opts); + + // Both modules take the same configuration. Only the test module names the + // Vulkan loader (below), so no artifact a consumer takes away carries it. + for ([_]*std.Build.Module{ mod, test_mod }) |m| { + if (android) { + // bionic's nullability-on-array declarations ("const struct timeval + // _Nonnull [2]") break translate-c, and the @cImports here (png.h, + // vulkan.h) reach sys/ headers through the NDK sysroot. The + // annotations are hints only; defining them empty drops them for our + // parse and the C compiles alike — the same neutralisation + // lookout-marine applies to its own module. + m.addCMacro("_Nonnull", ""); + m.addCMacro("_Nullable", ""); + m.addCMacro("_Null_unspecified", ""); + } + + // The Metal backend (src/gpu/gpu_metal.zig + metal_shim.m). The shader + // source rides an anonymous import (`@embedFile("metal_msl")`) and is + // compiled by the shim at runtime — no offline shader toolchain. The + // import itself is target-independent; only the ObjC shim and the + // frameworks are Apple-gated (non-mac targets select gpu_none.zig and + // never analyze the Metal backend). + m.addAnonymousImport("metal_msl", .{ .root_source_file = b.path("shaders/metal.metal") }); + + // The Vulkan backend (src/gpu/gpu_vk.zig). Everything off Apple draws with + // it. Unlike Metal there is no runtime shader compiler, so the programs + // ride in precompiled as SPIR-V; shaders/vk/README.md holds the command + // that regenerates them. The headers are vendored with no VK_USE_PLATFORM_* + // (those drag in the X11, Wayland and Windows SDKs) and the loader is + // linked by the consumer, which is what lets one build serve every window + // system. + // The D3D12 backend (src/gpu/gpu_d3d12.zig). The HLSL rides in as source + // and is compiled by d3dcompiler_47.dll at open, so there is no offline + // shader toolchain and no import library: d3d12.dll, dxgi.dll and the + // compiler are all loaded by name at runtime (src/gpu/c_d3d12.zig). + if (use_d3d12) { + const hlsl = [_][2][]const u8{ + .{ "d3d12_fill_vert", "shaders/d3d12/fill.vert.hlsl" }, + .{ "d3d12_fill_frag", "shaders/d3d12/fill.frag.hlsl" }, + .{ "d3d12_pattern_vert", "shaders/d3d12/pattern.vert.hlsl" }, + .{ "d3d12_pattern_frag", "shaders/d3d12/pattern.frag.hlsl" }, + .{ "d3d12_sprite_vert", "shaders/d3d12/sprite.vert.hlsl" }, + .{ "d3d12_sprite_frag", "shaders/d3d12/sprite.frag.hlsl" }, + .{ "d3d12_sdf_frag", "shaders/d3d12/sdf.frag.hlsl" }, + .{ "d3d12_overlay_vert", "shaders/d3d12/overlay.vert.hlsl" }, + .{ "d3d12_overlay_frag", "shaders/d3d12/overlay.frag.hlsl" }, + }; + for (hlsl) |e| m.addAnonymousImport(e[0], .{ .root_source_file = b.path(e[1]) }); + } + + if (use_vk) { + const spv = [_][2][]const u8{ + .{ "fill_vert_spv", "shaders/vk/fill.vert.spv" }, + .{ "fill_frag_spv", "shaders/vk/fill.frag.spv" }, + .{ "sprite_vert_spv", "shaders/vk/sprite.vert.spv" }, + .{ "sprite_frag_spv", "shaders/vk/sprite.frag.spv" }, + .{ "sdf_frag_spv", "shaders/vk/sdf.frag.spv" }, + .{ "pattern_vert_spv", "shaders/vk/pattern.vert.spv" }, + .{ "pattern_frag_spv", "shaders/vk/pattern.frag.spv" }, + .{ "overlay_vert_spv", "shaders/vk/overlay.vert.spv" }, + .{ "overlay_frag_spv", "shaders/vk/overlay.frag.spv" }, + }; + for (spv) |e| m.addAnonymousImport(e[0], .{ .root_source_file = b.path(e[1]) }); + m.addIncludePath(b.path("vendor/vulkan/include")); + } + + if (apple) { + m.addIncludePath(b.path("src/gpu")); // metal_shim.h for the @cImport + // Manual retain/release on purpose — objects live in C structs + // (same pattern as lookout-marine's build.zig). + m.addCSourceFile(.{ + .file = b.path("src/gpu/metal_shim.m"), + .flags = &.{ "-O2", "-fno-objc-arc", "-fno-sanitize=undefined" }, + }); + m.linkFramework("Metal", .{}); + m.linkFramework("QuartzCore", .{}); + m.linkFramework("Foundation", .{}); + } + + // Under a sysroot the SDK's own headers are not on the search path for + // this module's C sources, and a framework header that includes a plain + // one (Security.h -> libDER/DERItem.h) stops resolving. The host build + // passes a sysroot on every Xcode cross build. + if (b.sysroot) |sr| { + m.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ sr, "usr/include" }) }); + } + if (use_webp or use_libpng) { + // The sources are fetched by the package manager (build.zig.zon) and + // built here — zlib included, so nothing asks the linker to search for + // -lz. Naming a codec-dir still wins, for a build with its own archives. + if (codec_source and codec_dir == null) { + _ = codecs.addFromSource(b, m, target, use_webp, use_libpng); + } else { + if (codec_dir) |dir| { + m.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ dir, "include" }) }); + } else if (target.result.os.tag == .macos) { + m.addIncludePath(.{ .cwd_relative = "/opt/homebrew/include" }); + } + if (use_webp) linkCodec(b, m, target, codec_dir, "webp"); + if (use_libpng) { + linkCodec(b, m, target, codec_dir, "png16"); + // libpng's objects arrive with their zlib symbols undefined, so + // zlib has to resolve here as well as in whatever links this. + linkZlib(b, m); + } + } + } + m.addOptions("ct_build", ct_opts); + } // The C ABI as a static library: `zig build lib` drops libcharttable.a // and include/charttable.h into zig-out for a host to link (src/capi.zig @@ -177,15 +207,38 @@ pub fn build(b: *std.Build) void { .root_module = mod, .linkage = .static, }); - const lib_step = b.step("lib", "Build the static library + C header"); - lib_step.dependOn(&b.addInstallArtifact(lib, .{}).step); - lib_step.dependOn(&b.addInstallFileWithDir( + const header = b.addInstallFileWithDir( b.path("include/charttable.h"), .header, "charttable.h", - ).step); + ); + const lib_step = b.step("lib", "Build the static library + C header"); + lib_step.dependOn(&b.addInstallArtifact(lib, .{}).step); + lib_step.dependOn(&header.step); b.getInstallStep().dependOn(lib_step); + // The same C ABI as a shared library: `zig build shared` drops + // libcharttable.so (.dylib, or charttable.dll plus its import library) next + // to the archive. A host that loads the renderer at run time takes this one; + // a host that links it into its own binary takes the archive. + // + // Its own step, not part of the default build: a shared library is a final + // link, so it fails where the archive still builds — a target with no + // dynamic linker, for one. + // + // A cross build leaves the Vulkan loader undefined, as the archive does: the + // library draws through whichever loader the process already holds, and that + // is what lets one build serve every window system. + const shared = b.addLibrary(.{ + .name = "charttable", + .root_module = mod, + .linkage = .dynamic, + .version = semver, + }); + const shared_step = b.step("shared", "Build the shared library + C header"); + shared_step.dependOn(&b.addInstallArtifact(shared, .{}).step); + shared_step.dependOn(&header.step); + // `zig build example -Dtile57=` — the macOS demo. // // Built HERE rather than by hand-linking libcharttable.a: ld rejects the @@ -211,10 +264,15 @@ pub fn build(b: *std.Build) void { }); exe_mod.addIncludePath(b.path("include")); exe_mod.linkLibrary(lib); - if (use_webp) linkCodec(b, exe_mod, target, codec_dir, "webp"); - if (use_libpng) { - linkCodec(b, exe_mod, target, codec_dir, "png16"); - linkZlib(b, exe_mod); + // The library it links already carries the codecs when they come from + // source. Otherwise the exe names the platform archives, as the module + // does: the archive holds the calls, not the decoders. + if (!codec_source or codec_dir != null) { + if (use_webp) linkCodec(b, exe_mod, target, codec_dir, "webp"); + if (use_libpng) { + linkCodec(b, exe_mod, target, codec_dir, "png16"); + linkZlib(b, exe_mod); + } } for ([_][]const u8{ "Cocoa", "Metal", "QuartzCore", "CoreGraphics", "ImageIO", "UniformTypeIdentifiers" }) |fw| { exe_mod.linkFramework(fw, .{}); @@ -225,15 +283,13 @@ pub fn build(b: *std.Build) void { // `zig build test` — the gate. Every source module is referenced from // src/root.zig so its tests ride this one build. - const tests = b.addTest(.{ .root_module = mod }); + const tests = b.addTest(.{ .root_module = test_mod }); // The library leaves the Vulkan loader to whoever links it (the shells do, - // through meson/gradle/MSBuild), but a test binary IS the consumer, so it - // has to name the loader itself. tests.root_module IS `mod`, so this flag - // rides on every consumer too — harmless where the shell links the loader - // anyway, but an android cross-build has no -lvulkan to find, and its - // tests never run; the gradle/CMake link names the loader there. The - // D3D12 backend links nothing. - if (use_vk and !android) tests.root_module.linkSystemLibrary("vulkan", .{}); + // through meson/gradle/MSBuild), and the test binary is that consumer, so + // it names the loader here. An android cross-build is the exception: it has + // no -lvulkan to find, and its tests never run. The D3D12 backend links + // nothing. + if (use_vk and !android) test_mod.linkSystemLibrary("vulkan", .{}); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&b.addRunArtifact(tests).step); } diff --git a/build.zig.zon b/build.zig.zon index 43e3671..27f457f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,9 @@ .{ .name = .charttable, - .version = "0.1.0", + // Not a release number, and it never changes. A release takes its version + // from the tag (-Dversion), so there is nothing here to keep in step. A + // build from a source checkout reports this instead, which is what it is. + .version = "0.0.0-dev", .minimum_zig_version = "0.16.0", .fingerprint = 0xf9efa2121e26fedc, // The image codecs, for a target that cannot take them from the platform diff --git a/build/codecs.zig b/build/codecs.zig index 81f152e..14d1299 100644 --- a/build/codecs.zig +++ b/build/codecs.zig @@ -84,9 +84,9 @@ const webp_dsp_neon = [_][]const u8{ }; const zlib_src = [_][]const u8{ - "adler32.c", "compress.c", "crc32.c", "deflate.c", "gzclose.c", - "gzlib.c", "gzread.c", "gzwrite.c", "infback.c", "inffast.c", - "inflate.c", "inftrees.c", "trees.c", "uncompr.c", "zutil.c", + "adler32.c", "compress.c", "crc32.c", "deflate.c", "gzclose.c", + "gzlib.c", "gzread.c", "gzwrite.c", "infback.c", "inffast.c", + "inflate.c", "inftrees.c", "trees.c", "uncompr.c", "zutil.c", }; const png_src = [_][]const u8{ diff --git a/include/charttable.h b/include/charttable.h index 60787f5..6df2a4d 100644 --- a/include/charttable.h +++ b/include/charttable.h @@ -109,6 +109,10 @@ void charttable_set_zoom_range(charttable *, double min_zoom, double max_zoom); * vertex/uniform layout, which shades wrong rather than failing. */ uint32_t charttable_abi_layout(void); +/* The version this library was built as ("0.4.1"), NUL-terminated. The string + * is static: do not free it, and it needs no handle. */ +const char *charttable_version(void); + /* ---- style and sources -------------------------------------------------- */ int charttable_set_style_json(charttable *, const char *json, size_t len); diff --git a/scripts/brew-formula.sh b/scripts/brew-formula.sh new file mode 100755 index 0000000..9df6f61 --- /dev/null +++ b/scripts/brew-formula.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Print the Homebrew formula for a released version, reading each archive's +# sha256 out of (the tarballs that release.yml just published). +# The tap job pipes this into Formula/charttable.rb. +# +# Usage: brew-formula.sh +set -euo pipefail + +version="$1" +dist="$2" +# The repo the release lives in, so a fork's test release yields a formula +# pointing at the fork's own downloads. +repo="${GITHUB_REPOSITORY:-beetlebugorg/charttable}" +base="https://github.com/$repo/releases/download/v$version" + +sha() { shasum -a 256 "$dist/charttable-$version-$1.tar.gz" | cut -d' ' -f1; } + +# Resolved before the heredoc: a command substitution that fails inside one is +# not caught by `set -e`, and a missing archive would emit an empty sha256. +mac_arm="$(sha aarch64-macos)" +mac_intel="$(sha x86_64-macos)" +linux_arm="$(sha aarch64-linux-gnu)" +linux_intel="$(sha x86_64-linux-gnu)" + +cat < + int main(void) { return charttable_abi_layout() == 0; } + C + + # charttable_abi_layout reports the struct-layout guard. It touches no GPU, + # so it runs anywhere. + if OS.mac? + system ENV.cc, "abi.c", "-I#{include}", "-L#{lib}", "-lcharttable", "-o", "abi" + system "./abi" + else + # The library leaves the Vulkan loader to the program that links it, and + # a test machine has none. Compile against the header instead. + system ENV.cc, "-I#{include}", "-c", "abi.c", "-o", "abi.o" + end + end +end +EOF diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100755 index 0000000..e39ccab --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Stage the built trees into the release archive for one target, and — when a +# Debian architecture is given — the matching .deb. Run it after both builds: +# +# zig build lib -p out/static -Dtarget= -Doptimize=ReleaseFast -Dcodec-source +# zig build shared -p out/shared -Dtarget= -Doptimize=ReleaseFast -Dcodec-source +# +# Everything lands in dist/. Invoked per target by .github/workflows/release.yml. +# +# Two prefixes, not one: on Windows the static archive and the DLL's import +# library are both named charttable.lib, so a single prefix loses the first to +# the second. The archive keeps that name here and the import library becomes +# charttable.dll.lib. +# +# Usage: package-release.sh [deb-arch] +set -euo pipefail + +version="$1" +target="$2" +deb_arch="${3:-}" + +root="$(cd "$(dirname "$0")/.." && pwd)" +out="$root/dist" +name="charttable-$version-$target" +stage="$out/$name" + +rm -rf "$stage" +mkdir -p "$stage/lib" "$stage/include" + +cp -a "$root"/out/static/lib/. "$stage/lib/" +case "$target" in + *windows*) + mkdir -p "$stage/bin" + cp "$root/out/shared/bin/charttable.dll" "$stage/bin/" + cp "$root/out/shared/lib/charttable.lib" "$stage/lib/charttable.dll.lib" + ;; + *macos*) + # -a keeps the soname symlinks (libcharttable.dylib -> libcharttable.0.dylib + # -> libcharttable.0.1.0.dylib) as links instead of three copies. + cp -a "$root"/out/shared/lib/libcharttable*.dylib "$stage/lib/" + ;; + *) + cp -a "$root"/out/shared/lib/libcharttable.so* "$stage/lib/" + ;; +esac + +cp "$root/include/charttable.h" "$stage/include/" +cp "$root/LICENSE" "$root/THIRD-PARTY-NOTICES.md" "$root/README.md" "$stage/" + +cd "$out" +case "$target" in + *windows*) rm -f "$name.zip" && zip -qry "$name.zip" "$name" ;; + *) tar czf "$name.tar.gz" "$name" ;; +esac + +# The same payload under /usr, for `apt install ./libcharttable-dev_*.deb`. +# There is no apt repository — the .deb is a release asset. One package, not the +# usual runtime/dev pair: a release asset is installed to build against, and +# splitting it would ship two files that are only ever taken together. +if [ -n "$deb_arch" ]; then + # dpkg reads `0.2.0-rc1` as upstream 0.2.0 with revision rc1, which sorts + # AFTER plain 0.2.0 and makes the final release look like a downgrade. `~` + # is the separator that sorts before, so 0.2.0~rc1 upgrades to 0.2.0. + deb_version="$(printf '%s' "$version" | sed 's/-/~/')" + pkg="$out/deb" + rm -rf "$pkg" + mkdir -p "$pkg/DEBIAN" "$pkg/usr/lib" "$pkg/usr/include" \ + "$pkg/usr/share/doc/libcharttable-dev" + cp -a "$stage/lib/." "$pkg/usr/lib/" + chmod 644 "$pkg/usr/lib/libcharttable.a" + install -m644 "$stage/include/charttable.h" "$pkg/usr/include/" + install -m644 "$root/LICENSE" "$pkg/usr/share/doc/libcharttable-dev/copyright" + cat > "$pkg/DEBIAN/control" < +Section: libdevel +Priority: optional +Homepage: https://github.com/beetlebugorg/charttable +Description: Native map renderer for the MapLibre style spec + charttable takes a MapLibre style and vector tiles and draws them straight to + the GPU, holding 60 fps through pan, pinch-zoom and rotation. Tiles lay out + into resident GPU buckets once, so a pan, zoom or rotation is a matrix change + rather than a rebuild. + . + Ships libcharttable.a, libcharttable.so and the C header. The Vulkan loader + stays undefined in both libraries: the program that links charttable names it. +EOF + dpkg-deb --build --root-owner-group "$pkg" "$out/libcharttable-dev_${deb_version}_${deb_arch}.deb" + rm -rf "$pkg" +fi + +rm -rf "$stage" +ls -l "$out" diff --git a/src/capi.zig b/src/capi.zig index fceba5d..767a26d 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -369,6 +369,13 @@ export fn charttable_abi_layout() callconv(.c) u32 { return @import("scene/types.zig").abiLayout(); } +/// The version this library was built as ("0.4.1"). A release build takes it +/// from the tag. The string is static: it needs no free and outlives the +/// handle, so there is no handle to pass. +export fn charttable_version() callconv(.c) [*:0]const u8 { + return @import("ct_build").version; +} + // ---- style + sources ------------------------------------------------------- export fn charttable_set_style_json(h: ?*anyopaque, json: [*]const u8, len: usize) callconv(.c) c_int { @@ -1116,6 +1123,13 @@ test "capi: a handle opens, takes a style, moves the camera, and closes" { try testing.expectEqual(@import("scene/types.zig").abiLayout(), charttable_abi_layout()); } +test "capi: the version comes from the build" { + const built = @import("ct_build").version; + try testing.expectEqualStrings(built, std.mem.span(charttable_version())); + // A tag drives this, so it has to parse as one. + _ = try std.SemanticVersion.parse(built); +} + test "capi: every entry point is null-safe" { try testing.expectEqual(ERR_HANDLE, charttable_set_style_json(null, smoke_style.ptr, smoke_style.len)); try testing.expectEqual(ERR_HANDLE, charttable_tick(null, 16)); diff --git a/src/map_object.zig b/src/map_object.zig index f08384b..e983910 100644 --- a/src/map_object.zig +++ b/src/map_object.zig @@ -648,10 +648,10 @@ pub const Map = struct { std.debug.print( "ct-trace: {s} dirty={} partial={} building={} covHolds={} bz={d:.2} cov={d:.2} cam={d:.2} tgt={d:.2} anim={} gest={} wanted={d} have={d} pend={d}\n", .{ - act, self.dirty, self.partial, + act, self.dirty, self.partial, self.building, self.coverageHolds(), self.buildZoom(), - self.cov_zoom, self.cam.zoom, self.cam.target_zoom, - self.cam.animating(), self.gesturing(), self.wanted.items.len, + self.cov_zoom, self.cam.zoom, self.cam.target_zoom, + self.cam.animating(), self.gesturing(), self.wanted.items.len, have, self.pendingWanted(), }, ); @@ -2942,9 +2942,9 @@ test "Map: the cost of a zoom sweep" { "zoom OUT z16->z14, 8 wheel notches of one quantum: {d} ms, {d} rebuilds, {d} tiles tessellated\n" ++ " buckets reused overall: {d}\n", .{ - eased_ms, eased_rebuilds, eased_built, - frames, step_ms, m.rebuilds - r1, - m.tiles_built - b1, out_ms, m.rebuilds - r2, + eased_ms, eased_rebuilds, eased_built, + frames, step_ms, m.rebuilds - r1, + m.tiles_built - b1, out_ms, m.rebuilds - r2, m.tiles_built - b2, m.tiles_reused - reused0, }, );