diff --git a/.gitignore b/.gitignore index b28354d3..d7478388 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ __pycache__/ # render outputs dropped at the repo root during dev /a.pdf +/zig-out-*/ +# Fetched zig packages, materialized per-project (the S-101 catalogue lands +# here when the submodule is not initialized). +zig-pkg/ diff --git a/README.md b/README.md index 97a91cae..4797e3a6 100644 --- a/README.md +++ b/README.md @@ -16,46 +16,8 @@ --- > [!WARNING] -> **Not for navigation.** This project is coded almost entirely with AI (Claude) and -> human-reviewed. It is an experiment in using AI to implement a large, complex specification -> from scratch — not a certified or tested navigation product. Do not rely on it for -> real-world navigation. See [Known limitations](docs/docs/limitations.md). - ---- - -## Goals - -**tile57** is an experiment in building a real, spec-faithful nautical chart engine almost -entirely with AI assistance. A few specific goals shape its design: - -- **AI-written, human-reviewed.** Every significant piece of this codebase was generated by - Claude and reviewed by a human. The project tests how far AI can carry the heavy lifting of - spec interpretation, test coverage, and implementation correctness on a non-trivial domain. - -- **Spec adherence first.** The goal is to implement native S-101 and S-57 decoding, S-101 - portrayal, and S-52 display as faithfully as possible, using the actual IHO spec documents - and the official Portrayal Catalogue — not approximations or shortcuts. - -- **Cross-platform via Zig.** Zig's build system and cross-compilation support let the same - core compile to native (Linux, macOS, Windows) and **WASM** without code changes. Go and Zig - were chosen specifically because both have excellent build systems and first-class WASM targets, - making the engine usable in desktop apps, servers, and browsers from one codebase. - -- **Coupled tile + style.** The engine emits vector tiles (MLT or MVT) *and* a matching - MapLibre GL style together. The same style works for MapLibre Native and MapLibre GL JS, so - native and web renderers share one chart look without separate style maintenance. - -- **Language-agnostic embedding.** A thin C ABI (`libtile57.a`) bridges the Zig core to any - language with C FFI. Go bindings ship in the repo; others are straightforward additions. - -- **An engine to build on.** The goal is an S-57/S-100 chart engine you can use to build - a marine app without first becoming an IHO spec expert. Open a chart, get tiles, PNGs, - or PDFs; the S-52 rules, portrayal catalogue, and mariner settings are the engine's - problem. It aims to support: - - an **anchor alarm** that draws your swing circle over a real chart, - - a **Windy plugin** overlaying forecast weather on ENC charts, - - a **native cross-platform Qt6 C++ chartplotter**, - - a paper-style **passage-plan PDF printer**, a race-committee display, a tides kiosk… +> **Not for navigation.** This is not a certified or tested navigation product. Do not rely +> on it for real-world navigation. See [Known limitations](docs/docs/limitations.md). --- @@ -195,6 +157,12 @@ Docs source lives in [`docs/`](docs/): [intro](docs/docs/intro.md), the [architecture](docs/docs/architecture.md), and the [tile schema](docs/docs/tile-schema.md). +## AI-First Development + +This project is built with AI assistance. We encourage contributors to use AI tools for +development and to contribute by providing clear requirements and/or a prototype of what +they'd like rather than code. See the [contributing guide](docs/docs/contributing.md). + ## License tile57's own code is [MIT](LICENSE) © Jeremy Collins. It embeds the IHO S-101 diff --git a/build.zig b/build.zig index 3553380d..80d4d281 100644 --- a/build.zig +++ b/build.zig @@ -3,8 +3,33 @@ const std = @import("std"); // The vendored S-101 PortrayalCatalog, relative to the engine/ build root. Its // Rules (Lua) + Symbols/LineStyles/AreaFills/ColorProfiles (assets) are embedded // into the binary so tile57 portrays + styles charts with no on-disk catalogue. +// +// Two sources, same upstream commit: a dev checkout has it as the git submodule +// below; a *fetched* tile57 package does not (zig's fetcher skips git +// submodules, and the package excludes it from `paths`), so build() falls back +// to the `s101_portrayal` lazy dependency in build.zig.zon. See resolveCatalog. const PORTRAYAL_CATALOG = "vendor/S-101_Portrayal-Catalogue/PortrayalCatalog"; +// Where the PortrayalCatalog actually is for THIS build: `.b` is the builder +// whose root the relative `.root` resolves under (the tile57 build itself for +// the submodule, the s101_portrayal dependency's builder for the fetched +// fallback) — embedDir walks and @embedFile's through it. Null means the lazy +// dependency fetch was just scheduled and build() must return so zig can re-run +// it with the package on disk. +const Catalog = struct { b: *std.Build, root: []const u8 }; +fn resolveCatalog(b: *std.Build) ?Catalog { + // Probe a directory only an *initialized* submodule has (a plain clone + // leaves vendor/S-101_Portrayal-Catalogue as an empty directory). + const probe = b.pathFromRoot(PORTRAYAL_CATALOG ++ "/Rules"); + if (std.Io.Dir.openDirAbsolute(b.graph.io, probe, .{})) |dir| { + var d = dir; + d.close(b.graph.io); + return .{ .b = b, .root = PORTRAYAL_CATALOG }; + } else |_| {} + const dep = b.lazyDependency("s101_portrayal", .{}) orelse return null; + return .{ .b = dep.builder, .root = "PortrayalCatalog" }; +} + // libtess2 (vendored, SGI Free Software License B — vendor/libtess2/LICENSE.txt). // The polygon tessellator behind the GPU surface: contours in, triangles out, // with the winding rules S-52 needs (even-odd for glyph/symbol outlines with @@ -15,14 +40,24 @@ const tess_sources = [_][]const u8{ "bucketalloc.c", "dict.c", "geom.c", "mesh.c", "priorityq.c", "sweep.c", "tess.c", }; +// Cross-compiling to a non-macOS Apple target (`-Dtarget=aarch64-ios[-simulator]`) +// needs that SDK's libc headers — Zig only bundles Apple headers for macOS. Pass +// `--sysroot "$(xcrun --sdk iphoneos --show-sdk-path)"` and every C-compiling +// module picks the headers up here (a no-op when no sysroot is given). +fn addSysrootIncludes(b: *std.Build, mod: *std.Build.Module) void { + const sysroot = b.sysroot orelse return; + mod.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "usr/include" }) }); +} + fn addTess(b: *std.Build, mod: *std.Build.Module) void { mod.link_libc = true; // libtess2 uses assert.h/stdio.h/stdlib.h + addSysrootIncludes(b, mod); mod.addIncludePath(b.path("vendor/libtess2/Include")); mod.addIncludePath(b.path("vendor/libtess2/Source")); mod.addCSourceFiles(.{ .root = b.path("vendor/libtess2/Source"), .files = &tess_sources, - .flags = &.{ "-std=gnu99", "-O2" }, + .flags = &.{ "-std=gnu99", "-O2", "-fno-sanitize=undefined" }, }); } @@ -52,18 +87,22 @@ fn addCatalogueJson(b: *std.Build, mod: *std.Build.Module) void { // `posix`: define LUA_USE_POSIX (Unix). On Windows it must stay OFF — forcing it // pulls in /dlopen; without it luaconf.h auto-selects LUA_USE_WINDOWS // from _WIN32. lua_shim.c is already portable (only getenv + ANSI stdio). -fn addLua(b: *std.Build, mod: *std.Build.Module, posix: bool) void { +fn addLua(b: *std.Build, mod: *std.Build.Module, posix: bool, ios: bool) void { + addSysrootIncludes(b, mod); mod.addIncludePath(b.path("vendor/lua/src")); - const shim_flags: []const []const u8 = if (posix) &.{"-DLUA_USE_POSIX"} else &.{}; + const shim_flags: []const []const u8 = if (posix) &.{ "-DLUA_USE_POSIX", "-fno-sanitize=undefined" } else &.{"-fno-sanitize=undefined"}; mod.addCSourceFile(.{ .file = b.path("src/portray/lua_shim.c"), .flags = shim_flags }); - const lua_flags: []const []const u8 = if (posix) - &.{ "-std=gnu99", "-DLUA_USE_POSIX", "-O2" } - else - &.{ "-std=gnu99", "-O2" }; + var lua_flags = std.ArrayList([]const u8).empty; + lua_flags.appendSlice(b.allocator, &.{ "-std=gnu99", "-O2", "-fno-sanitize=undefined" }) catch @panic("OOM"); + if (posix) lua_flags.append(b.allocator, "-DLUA_USE_POSIX") catch @panic("OOM"); + // iOS forbids system(3) (marked unavailable in the SDK). Stub loslib's + // l_system hook to "no shell": os.execute() reports no shell available, + // os.execute(cmd) fails — nothing in the portrayal path shells out anyway. + if (ios) lua_flags.append(b.allocator, "-Dl_system(cmd)=((cmd)==0?0:-1)") catch @panic("OOM"); mod.addCSourceFiles(.{ .root = b.path("vendor/lua/src"), .files = &lua_sources, - .flags = lua_flags, + .flags = lua_flags.items, }); } @@ -71,9 +110,10 @@ fn addLua(b: *std.Build, mod: *std.Build.Module, posix: bool) void { // behind svgraster.c to a module. Used by the `sprite` module (sprite/pattern // atlas generation in the bake tool). Single-header C libs; need libc. fn addSvgRaster(b: *std.Build, mod: *std.Build.Module) void { + addSysrootIncludes(b, mod); mod.addIncludePath(b.path("vendor/nanosvg")); mod.addIncludePath(b.path("vendor/stb")); - mod.addCSourceFile(.{ .file = b.path("src/sprite/svgraster.c"), .flags = &.{ "-std=gnu99", "-O2" } }); + mod.addCSourceFile(.{ .file = b.path("src/sprite/svgraster.c"), .flags = &.{ "-std=gnu99", "-O2", "-fno-sanitize=undefined" } }); } // Re-import the pure packages into a consumer module (engine, libtile57.a, the @@ -162,6 +202,11 @@ fn addPkgTest( } pub fn build(b: *std.Build) void { + // The S-101 PortrayalCatalog source (submodule, or the lazy dependency for + // a fetched package). On the first pass of a fetch this is null — return so + // zig downloads the package and re-runs build(). + const catalog = resolveCatalog(b) orelse return; + const target = b.standardTargetOptions(.{}); // Default to ReleaseFast: the tile57 CLI is a compute-heavy baking tool, and a // Debug build bakes ~2.6x slower (no inlining/hoisting/vectorisation). A plain @@ -293,11 +338,11 @@ pub fn build(b: *std.Build) void { .{ .name = "s101", .module = s101_mod }, }, }); - addLua(b, portray_mod, lua_posix); + addLua(b, portray_mod, lua_posix, target.result.os.tag == .ios); // Embed the S-101 Lua rules (216 framework + feature-class files) so the Lua // `require` searcher in lua_shim.c can load them from memory — tile57 portrays // S-57 cells with no on-disk catalogue. An explicit rules dir still overrides. - portray_mod.addImport("rules_registry", embedDir(b, "rules_registry", PORTRAYAL_CATALOG ++ "/Rules", ".lua")); + portray_mod.addImport("rules_registry", embedDir(catalog.b, "rules_registry", catalog.b.pathJoin(&.{ catalog.root, "Rules" }), ".lua")); // MapLibre style generation (src/style/): color tables, line styles, the // style.json layer set (maplibre.zig), and the S-52 mariner settings model + @@ -352,7 +397,7 @@ pub fn build(b: *std.Build) void { // directly (tile57_colortables_default / tile57_style_template) AND it rides on // catalog_embed below. A second embedDir for the same dir would create a second // same-named module and collide in the libtile57.a build (where both are present). - const colorprofile_registry = embedDir(b, "colorprofile_registry", PORTRAYAL_CATALOG ++ "/ColorProfiles", ".xml"); + const colorprofile_registry = embedDir(catalog.b, "colorprofile_registry", catalog.b.pathJoin(&.{ catalog.root, "ColorProfiles" }), ".xml"); // The S-101 portrayal *assets* embedded into the binary: symbol SVGs, the palette // CSS, line-style + area-fill XML, and the colour profile. The bundle pipeline @@ -360,10 +405,10 @@ pub fn build(b: *std.Build) void { // catalogue; a --catalog / positional dir still overrides (read from disk). Shared // by the CLI baker AND libtile57.a (so the C ABI bake_bundle needs no catalogue). const catalog_embed = b.createModule(.{ .root_source_file = b.path("tools/catalog_embed.zig") }); - catalog_embed.addImport("symbols_registry", embedDir(b, "symbols_registry", PORTRAYAL_CATALOG ++ "/Symbols", ".svg")); - catalog_embed.addImport("css_registry", embedDir(b, "css_registry", PORTRAYAL_CATALOG ++ "/Symbols", ".css")); - catalog_embed.addImport("linestyles_registry", embedDir(b, "linestyles_registry", PORTRAYAL_CATALOG ++ "/LineStyles", ".xml")); - catalog_embed.addImport("areafills_registry", embedDir(b, "areafills_registry", PORTRAYAL_CATALOG ++ "/AreaFills", ".xml")); + catalog_embed.addImport("symbols_registry", embedDir(catalog.b, "symbols_registry", catalog.b.pathJoin(&.{ catalog.root, "Symbols" }), ".svg")); + catalog_embed.addImport("css_registry", embedDir(catalog.b, "css_registry", catalog.b.pathJoin(&.{ catalog.root, "Symbols" }), ".css")); + catalog_embed.addImport("linestyles_registry", embedDir(catalog.b, "linestyles_registry", catalog.b.pathJoin(&.{ catalog.root, "LineStyles" }), ".xml")); + catalog_embed.addImport("areafills_registry", embedDir(catalog.b, "areafills_registry", catalog.b.pathJoin(&.{ catalog.root, "AreaFills" }), ".xml")); catalog_embed.addImport("colorprofile_registry", colorprofile_registry); // The chart-bundle module: S-101 portrayal asset emission + the per-cell composite @@ -417,6 +462,10 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/lib_root.zig"), .target = target, .optimize = optimize, + // iOS: std.debug's stack-trace machinery references + // _dyld_get_image_header_containing_address, which iOS' libdyld doesn't + // export — strip so the panic path never pulls it in. + .strip = target.result.os.tag == .ios, .pic = true, // links into a PIE C++ host .link_libc = true, // Lua needs the C runtime }); @@ -436,7 +485,24 @@ pub fn build(b: *std.Build) void { // (tile57_colortables_default / tile57_style_template). lib_mod.addImport("colorprofile_registry", colorprofile_registry); lib_mod.addImport("catalog", catalog_embed); // chart.renderView symbol/pattern store + // The engine's own git commit, embedded so the RUNTIME can state which + // engine a process actually linked (tile57_warmup logs it once): build + // provenance that survives any amount of checkout / link confusion. + { + const buildinfo = b.addOptions(); + var code: u8 = 0; + const raw = b.runAllowFail(&.{ "git", "describe", "--always", "--dirty" }, &code, .ignore) catch "unknown"; + buildinfo.addOption([]const u8, "commit", std.mem.trim(u8, raw, " \n\r\t")); + lib_mod.addImport("buildinfo", buildinfo.createModule()); + } const lib = b.addLibrary(.{ .name = "tile57", .linkage = .static, .root_module = lib_mod }); + // The archive for zig-package consumers (lookout-core links it into its own + // build): a named lazy path, NOT dep.artifact() — the default install step + // installs the `tile57` CLI under the same name, and on macOS the lib + // reaches the install step only as the repacked file below. The raw archive + // is fine for a zig consumer; ld64/libtool consumers must still repack + // (loose-object extract) exactly like scripts/macho-align.sh does. + b.addNamedLazyPath("libtile57_a", lib.getEmittedBin()); // Bundle compiler-rt INTO the static archive. A non-Zig linker (the CGO host's gcc/clang, // `go test`) has no access to Zig's compiler-rt, so builtins the code references — e.g. // `roundq` (f128 @round, pulled in by std.json's number→int coercion in coverage decode) — @@ -652,8 +718,10 @@ pub fn build(b: *std.Build) void { .{ .name = "s57", .module = s57_mod }, }; const compose_step = b.step("compose-test", "Run the runtime compositor + clip-core tests"); - _ = addPkgTest(b, compose_step, "src/compose/compose.zig", target, optimize, &compose_deps); - _ = addPkgTest(b, test_step, "src/compose/compose.zig", target, optimize, &compose_deps); + // compose.zig reads two debug-valve env vars via std.c.getenv, so the test + // binaries need libc (the shipped lib already links it; addPkgTest omits it). + addPkgTest(b, compose_step, "src/compose/compose.zig", target, optimize, &compose_deps).link_libc = true; + addPkgTest(b, test_step, "src/compose/compose.zig", target, optimize, &compose_deps).link_libc = true; // The chart-bundle module hosts the per-cell composite (composeTile / ComposeSource). Its full // dep set (engine + assets/sprite/catalog) needs libc, so create the test module directly diff --git a/build.zig.zon b/build.zig.zon index 317aafa8..49b10076 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,81 +1,42 @@ .{ - // This is the default name used by packages depending on this one. For - // example, when a user runs `zig fetch --save `, this field is used - // as the key in the `dependencies` table. Although the user can choose a - // different name, most users will stick with this provided value. - // - // It is redundant to include "zig" in this name because it is already - // within the Zig package namespace. .name = .tile57, - // This is a [Semantic Version](https://semver.org/). - // In a future version of Zig it will be used for package deduplication. .version = "0.3.0", // Together with name, this represents a globally unique package - // identifier. This field is generated by the Zig toolchain when the - // package is first created, and then *never changes*. This allows - // unambiguous detection of one package being an updated version of - // another. - // - // When forking a Zig project, this id should be regenerated (delete the - // field and run `zig build`) if the upstream project is still maintained. - // Otherwise, the fork is *hostile*, attempting to take control over the - // original project's identity. Thus it is recommended to leave the comment - // on the following line intact, so that it shows up in code reviews that - // modify the field. + // identifier. Generated by the Zig toolchain when the package was first + // created, and then *never changes*. .fingerprint = 0x9033dd8108de0b48, // Changing this has security and trust implications. - // Tracks the earliest Zig version that the package considers to be a - // supported use case. .minimum_zig_version = "0.16.0", - // This field is optional. - // Each dependency must either provide a `url` and `hash`, or a `path`. - // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. - // Once all dependencies are fetched, `zig build` no longer requires - // internet connectivity. .dependencies = .{ - // See `zig fetch --save ` for a command-line interface for adding dependencies. - //.example = .{ - // // When updating this field to a new URL, be sure to delete the corresponding - // // `hash`, otherwise you are communicating that you expect to find the old hash at - // // the new URL. If the contents of a URL change this will result in a hash mismatch - // // which will prevent zig from using it. - // .url = "https://example.com/foo.tar.gz", - // - // // This is computed from the file contents of the directory of files that is - // // obtained after fetching `url` and applying the inclusion rules given by - // // `paths`. - // // - // // This field is the source of truth; packages do not come from a `url`; they - // // come from a `hash`. `url` is just one of many possible mirrors for how to - // // obtain a package matching this `hash`. - // // - // // Uses the [multihash](https://multiformats.io/multihash/) format. - // .hash = "...", - // - // // When this is provided, the package is found in a directory relative to the - // // build root. In this case the package's hash is irrelevant and therefore not - // // computed. This field and `url` are mutually exclusive. - // .path = "foo", - // - // // When this is set to `true`, a package is declared to be lazily - // // fetched. This makes the dependency only get fetched if it is - // // actually used. - // .lazy = false, - //}, + // The IHO S-101 Portrayal Catalogue, pinned to the SAME commit as the + // vendor/S-101_Portrayal-Catalogue git submodule. Lazy: only fetched + // when the submodule isn't initialized (i.e. when tile57 itself was + // fetched as a zig package — zig's fetcher doesn't do git submodules). + // Bumping the submodule means bumping this url + hash too. + .s101_portrayal = .{ + .url = "https://github.com/iho-ohi/S-101_Portrayal-Catalogue/archive/62f7773a5641fb22ad88e5508a58d668ad2a7b98.tar.gz", + .hash = "N-V-__8AAJSTIgAc0-UiqHpiCVuhG76L46b8rGvny0aPFvtf", + .lazy = true, + }, }, - // Specifies the set of files and directories that are included in this package. - // Only files and directories listed here are included in the `hash` that - // is computed for this package. Only files listed here will remain on disk - // when using the zig package manager. As a rule of thumb, one should list - // files required for compilation plus any license(s). - // Paths are relative to the build root. Use the empty string (`""`) to refer to - // the build root itself. - // A directory listed here means that all files within, recursively, are included. + // What ships when tile57 is consumed as a zig package (e.g. lookout-core + // fetches it to build libtile57.a from source). vendor/ is listed subdir by + // subdir so the git submodules (the portrayal catalogue + S-101 docs) stay + // out of the package — the catalogue arrives via the dependency above. .paths = .{ "build.zig", "build.zig.zon", "src", - // For example... - //"LICENSE", - //"README.md", + "include", + "tools", + "scripts", + "bindings", + "vendor/fonts", + "vendor/libtess2", + "vendor/lua", + "vendor/nanosvg", + "vendor/s101", + "vendor/stb", + "LICENSE", + "THIRD_PARTY_LICENSES.md", }, } diff --git a/docs/docs/api/assets.md b/docs/docs/api/assets.md new file mode 100644 index 00000000..07e0f748 --- /dev/null +++ b/docs/docs/api/assets.md @@ -0,0 +1,41 @@ +--- +title: Portrayal assets +slug: /c-api/assets +--- + +# Generate portrayal assets + +`tile57_bake_assets` produces all portrayal assets in memory — colour tables, +line styles, and the sprite / area-fill pattern atlases — from the library's +embedded catalogue (`catalog_dir` NULL/"") or an on-disk `PortrayalCatalog`. +Every non-NULL buffer is owned by the library; release the whole struct with +`tile57_assets_free`. + +```c +typedef struct { + uint8_t *colortables; size_t colortables_len; + uint8_t *linestyles; size_t linestyles_len; + uint8_t *sprite_json; size_t sprite_json_len; uint8_t *sprite_png; size_t sprite_png_len; + uint8_t *pattern_json; size_t pattern_json_len; uint8_t *pattern_png; size_t pattern_png_len; +} tile57_assets; + +tile57_status tile57_bake_assets(const char *catalog_dir, tile57_assets *out, + tile57_error *err); +void tile57_assets_free(tile57_assets *out); +``` + +`tile57_bake_sprite_mln` is a focused variant that fills only the `sprite_json` / +`sprite_png` fields with a MapLibre **sprite-mln** atlas: every S-101 symbol packed +into one PNG, each atlas cell centered on its symbol's pivot, plus a JSON index of +`{name: {x, y, width, height, pixelRatio}}`. A GPU host loads this atlas once and +draws point symbols and area patterns as textured quads by name — the atlas the +[host-surface `draw_sprite`/`draw_pattern` callbacks](./render.md#host-surface-vector-callbacks) +hand back. `tile57_bake_glyph_sdf` is its text counterpart: an RGBA +signed-distance-field atlas of the label font, for a host that draws text as SDF +quads. Free either with `tile57_assets_free` as above. + +```c +tile57_status tile57_bake_sprite_mln(const char *catalog_dir, tile57_assets *out, + tile57_error *err); +tile57_status tile57_bake_glyph_sdf(tile57_assets *out, tile57_error *err); +``` diff --git a/docs/docs/api/bake.md b/docs/docs/api/bake.md new file mode 100644 index 00000000..67dd3106 --- /dev/null +++ b/docs/docs/api/bake.md @@ -0,0 +1,88 @@ +--- +title: Bake +slug: /c-api/bake +--- + +# Bake: ENC charts → per-chart archives + +Tile production is a two-step composite model. First bake each chart to its +own PMTiles at its compilation scale; the archive embeds the chart's M_COVR +coverage, compilation scale, and identity in its metadata. Then open a +**compositor** over the archives and serve any `(z, x, y)` tile on demand — +the compositor stitches the overlapping charts through an ownership partition, +handling cross-band zoom (see [Compose](./compose.md)). + +```c +/* Bake ONE chart (+ its .001.. updates, read from disk) to PMTiles bytes over its + * native band zoom range. Returned in *out/*out_len (free with tile57_free); + * NULL/0 when the chart produced no tiles. */ +tile57_status tile57_bake_chart_bytes(const char *path, uint8_t **out, size_t *out_len, + tile57_error *err); + +/* Bake `n` charts IN PARALLEL across up to `workers` threads (a MEMORY bound — + * pass a small count). out_bytes[i]/out_lens[i] receive chart i's archive or + * NULL/0; *out_baked (NULL to ignore) counts the charts that produced bytes. */ +tile57_status tile57_bake_charts(const char *const *paths, size_t n, uint32_t workers, + uint8_t **out_bytes, size_t *out_lens, + size_t *out_baked, tile57_error *err); + +/* Walk in_dir for *.000 charts and bake each IN PARALLEL to the SAME relative + * path under out_dir with a .pmtiles extension (+ an .sha sidecar). + * INCREMENTAL: a chart whose archive is already at least as new as its whole + * input (.000 + update chain) is skipped, so a re-run over an unchanged tree + * bakes nothing — *out_baked counts THIS run, and 0 over a warm cache is + * success. progress (or NULL) fires per chart, possibly from worker threads; + * returning false CANCELS the bake (at chart granularity — the charts in flight + * finish). A cancelled bake is TILE57_OK with *out_baked = what it completed. */ +typedef bool (*tile57_bake_progress)(void *ctx, uint32_t done, uint32_t total); +tile57_status tile57_bake_tree(const char *in_dir, const char *out_dir, uint32_t workers, + tile57_bake_progress progress, void *progress_ctx, + uint32_t *out_baked, tile57_error *err); + +/* Read a PMTiles archive's metadata JSON blob (decompressed); NULL/0 when the + * archive carries none. A per-chart bake embeds the chart's coverage + cscl + + * date/name under a "coverage" key. */ +tile57_status tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, + uint8_t **out, size_t *out_len, + tile57_error *err); +``` + +Every baked feature carries the pick-report properties `class` (object-class +acronym), `cell` (source chart stem), and `s57` (the full S-57 attribute set as a +JSON object) — what [`tile57_chart_query`](./render.md#query-the-features-under-a-point-object-query--pick) +and a host inspector read back. + +The `tile57 bake -o out/` CLI produces this structure +directly: `out/tiles/.pmtiles` per chart plus `out/partition.tpart`. + +## Read raw S-57 source data + +The bake section also reads the source data directly — no handle, no bake — for +a host's import UI: + +```c +/* Per-chart metadata of the S-57 data at `path` (one .000, updates applied, or a + * whole ENC_ROOT) as a JSON array: [{"name","scale","edition","update", + * "issueDate","agency","bbox"}, ...] — a host's chart-database scan. */ +tile57_status tile57_enc_charts(const char *path, uint8_t **out, size_t *out_len, + tile57_error *err); + +/* Features for comma-separated object-class acronyms (e.g. "DEPARE,DRGARE") as + * a GeoJSON FeatureCollection: lon/lat geometry, properties = {"class", plus the + * full S-57 acronym->value attribute map}. NULL/0 when nothing matched. */ +tile57_status tile57_enc_features(const char *path, const char *classes, + uint8_t **out, size_t *out_len, tile57_error *err); + +/* The same over in-memory base .000 bytes (from a zip member, say). */ +tile57_status tile57_enc_features_bytes(const uint8_t *base, size_t len, + const char *classes, + uint8_t **out, size_t *out_len, tile57_error *err); + +/* Decode a CATALOG.031 exchange-set catalogue into a JSON array of its CATD + * entries — file path, longName (chart title), impl (BIN/ASC/TXT), bbox. */ +tile57_status tile57_enc_catalog(const uint8_t *catalog_031, size_t len, + uint8_t **out, size_t *out_len, tile57_error *err); +``` + +The CLI mirrors these as `tile57 cells`, `tile57 features`, and +`tile57 catalog`. diff --git a/docs/docs/api/compose.md b/docs/docs/api/compose.md new file mode 100644 index 00000000..f6c81446 --- /dev/null +++ b/docs/docs/api/compose.md @@ -0,0 +1,109 @@ +--- +title: Compose +slug: /c-api/compose +--- + +# Compose: many charts, one chart + +The compositor builds (or loads) the ownership partition over its charts' +embedded coverage, then offers the SAME output set as a single chart, composed: +any tile on demand for the cost of a classify plus one decompress or one +decode/clip, plus the composed view outputs and the composed cursor pick. It +**borrows** the charts — their mmap'd archives and decoded coverage — so the +chart set is never fully resident and the charts must outlive the compositor. +Open once, serve many, close. + +```c +/* Opaque runtime-compositor handle. */ +typedef struct tile57_compose tile57_compose; + +/* Coverage/zoom summary filled by tile57_compose_get_meta. */ +typedef struct { + uint8_t min_zoom; + uint8_t max_zoom; /* deepest zoom served (native + one overscale zoom) */ + uint32_t charts; /* coverage-carrying charts held */ + double west, south, east, north; /* union coverage bounds, degrees */ +} tile57_compose_meta; + +/* Open a compositor over `n` open charts. Charts whose archives embed no + * coverage are skipped (they can own no ground); none at all is + * TILE57_ERR_UNSUPPORTED. partition_path (NULL to skip) names a sidecar — + * written by tile57_compose_save_partition (the `tile57 bake` CLI emits + * partition.tpart) — to load and skip the build; a missing/stale one falls back + * to building. Close with tile57_compose_close BEFORE closing the charts. */ +tile57_status tile57_compose_open(tile57_chart *const *charts, size_t n, + const char *partition_path, + tile57_compose **out, tile57_error *err); + +/* Compose tile (z,x,y) on demand into RAW (decompressed) MLT — what a live tile + * server hands its HTTP layer (which gzips on the wire). NULL/0 out with OK = + * no bytes; *out_owned (NULL to ignore) then distinguishes the two empties: + * owned=false: no chart owns this ground — true empty ocean, safe to cache; + * owned=true: a chart owns this ground but produced nothing — transient while + * its per-chart bake is running, suspect once bakes are done. */ +tile57_status tile57_compose_tile(tile57_compose *c, uint8_t z, uint32_t x, uint32_t y, + uint8_t **out, size_t *out_len, bool *out_owned, + tile57_error *err); + +/* The composed view outputs and pick — the render-surface calls across the WHOLE + * composed set: every covering tile is composed on demand (stitched + * through the ownership partition) and replayed through the S-52 pixel path. + * Same parameters, limits, and ownership as the single-chart forms. */ +tile57_status tile57_compose_png(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); +tile57_status tile57_compose_pdf(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); +tile57_status tile57_compose_canvas(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, const tile57_mariner *m, + const tile57_canvas_cb *canvas, tile57_error *err); +tile57_status tile57_compose_surface(tile57_compose *c, double lon, double lat, double zoom, + double rotation_rad, + uint32_t width, uint32_t height, const tile57_mariner *m, + const tile57_surface_cb *surface, tile57_error *err); +/* The composed view-level, globally-decluttered TEXT pass (tile57_chart_labels + * across the composed set): only surviving labels, decluttered across tile AND + * chart seams, no geometry. */ +tile57_status tile57_compose_labels(tile57_compose *c, double lon, double lat, double zoom, + double rotation_rad, + uint32_t width, uint32_t height, const tile57_mariner *m, + const tile57_surface_cb *surface, tile57_error *err); +tile57_status tile57_compose_query(tile57_compose *c, double lon, double lat, double zoom, + const tile57_query_cb *cb, tile57_error *err); + +/* The composed draw-ready GPU scene — a whole chart library into one scene, seams + * stitched across cells. See Render › "Draw-ready GPU scenes" for the + * tile57_gpu_scene buffers, pixel_ratio, and how to draw the ranges; free with + * tile57_gpu_scene_free. */ +tile57_status tile57_compose_gpu_scene(tile57_compose *c, double lon, double lat, double zoom, + uint32_t width, uint32_t height, const tile57_mariner *m, + double pixel_ratio, tile57_gpu_scene *out, tile57_error *err); + +/* Fill *out with the compositor's zoom range + union coverage bounds. */ +void tile57_compose_get_meta(tile57_compose *c, tile57_compose_meta *out); + +/* Serialize the ownership partition to `path` (a sidecar a later + * tile57_compose_open loads to skip the build). */ +tile57_status tile57_compose_save_partition(tile57_compose *c, const char *path, + tile57_error *err); + +/* Release a compositor. Its charts stay open (and stay yours to close). */ +void tile57_compose_close(tile57_compose *c); +``` + +```c +/* bake -> open -> compose -> serve */ +tile57_chart *charts[2]; +tile57_chart_open("tiles/US5MD1MC.pmtiles", &charts[0], NULL); +tile57_chart_open("tiles/US5MD1MD.pmtiles", &charts[1], NULL); +tile57_compose *cmp = NULL; +tile57_compose_open(charts, 2, "partition.tpart", &cmp, NULL); +uint8_t *tile; size_t n; bool owned; +tile57_compose_tile(cmp, 13, 2359, 3139, &tile, &n, &owned, NULL); +``` + +The per-surface forms above (`_png` / `_pdf` / `_canvas` / `_surface` / `_labels`, +plus `tile57_compose_gpu_scene`) are the composed twins of the single-chart +[render surfaces](./render.md#render-surfaces) — same parameters, same callbacks, +same ownership, over the whole set instead of one archive. diff --git a/docs/docs/api/errors-lifecycle.md b/docs/docs/api/errors-lifecycle.md new file mode 100644 index 00000000..3341edf3 --- /dev/null +++ b/docs/docs/api/errors-lifecycle.md @@ -0,0 +1,84 @@ +--- +title: Errors & lifecycle +slug: /c-api/errors-lifecycle +--- + +# Errors & lifecycle + +The cross-cutting conventions every other C API page relies on: how calls report +failure, how long a handle lives and on which thread, warming the process-global +registries, freeing returned buffers, and the ABI's version guarantee. + +## Errors + +Every call that can fail returns a `tile57_status` — `TILE57_OK` (0) or a coarse +cause — and takes an optional caller-owned `tile57_error*` it fills with the +status plus a specific message on failure (a stack local is fine; nothing to +free). Results come back through out-parameters, which are always defined on +return: the result on `TILE57_OK`, `NULL`/0 otherwise. "Nothing produced" is +NOT a failure — a call that finds nothing returns `TILE57_OK` with a +`NULL`/zero out. + +```c +typedef enum { + TILE57_OK = 0, /* success */ + TILE57_ERR_BADARG, /* a NULL or out-of-range argument */ + TILE57_ERR_IO, /* a file/directory could not be opened, read, or written */ + TILE57_ERR_PARSE, /* malformed input (S-57 chart, PMTiles, partition, JSON) */ + TILE57_ERR_NOMEM, /* an allocation failed */ + TILE57_ERR_UNSUPPORTED, /* valid but unusable input */ + TILE57_ERR_RENDER, /* tile generation or rendering failed */ + TILE57_ERR_INTERNAL, /* an unexpected engine failure */ +} tile57_status; + +const char *tile57_status_str(tile57_status status); /* static strerror-style text */ + +#define TILE57_ERROR_MSG_MAX 256 +typedef struct { + tile57_status status; + char message[TILE57_ERROR_MSG_MAX]; /* NUL-terminated; "" when no detail */ +} tile57_error; +``` + +```c +tile57_chart *chart = NULL; +tile57_error err; +if (tile57_chart_open("US5MD1MC.pmtiles", &chart, &err) != TILE57_OK) { + fprintf(stderr, "open failed: %s\n", err.message); /* "path: reason" */ +} +``` + +## Lifetime + threading + +:::warning Lifetime + threading +No handle is internally synchronized — use one thread per handle. Each must +also outlive every borrower still holding it: a compositor borrows its charts +(close the compositor first, then the charts), and a path-opened chart mmaps +its file, so the file must stay in place while the chart is open. Calls that +return bytes allocate `*out`; free it with `tile57_free(ptr)`. Input +bytes are copied, so the caller may free them right after the call. +::: + +## Warmup + free + +```c +/* Populate the process-global read-only registries (feature catalogue + + * complex-linestyle table) on the calling thread. Call ONCE on your main thread + * before opening or baking charts from worker threads, so concurrent bake/render is + * race-free. Idempotent. */ +void tile57_warmup(void); + +/* Free ANY buffer the engine returned (tiles, style JSON, the scamin array, + * colortables, …) — length-prefixed, so the pointer is all it needs. */ +void tile57_free(void *ptr); +``` + +## Diagnostics header + +[`include/tile57_diag.h`](../../../include/tile57_diag.h) (`tile57_diag_*`) exposes +the embedded-Lua / S-101 framework bring-up self-tests — developer tooling, not +part of the embedding API. + +## Versioning + +Pre-1.0 (`0.3.0`). No external consumers yet, so the ABI is not frozen. diff --git a/docs/docs/api/render.md b/docs/docs/api/render.md new file mode 100644 index 00000000..4beb97e1 --- /dev/null +++ b/docs/docs/api/render.md @@ -0,0 +1,428 @@ +--- +title: Render +slug: /c-api/render +--- + +# Render: the `tile57_chart` handle + +A `tile57_chart` is ONE baked PMTiles archive, opened for metadata and +output — with no composition (the [compositor](./compose.md) offers the same +outputs across many charts). Open it from a path (mmap'd — a whole chart library can +be open without being resident) or from bytes (copied). + +```c +const char *tile57_version(void); /* "0.3.0" */ + +/* Opaque chart handle: one open baked archive. */ +typedef struct tile57_chart tile57_chart; + +tile57_status tile57_chart_open(const char *path, tile57_chart **out, tile57_error *err); +tile57_status tile57_chart_open_bytes(const uint8_t *pmtiles, size_t len, + tile57_chart **out, tile57_error *err); + +/* Vector-tile encodings an archive can store (reported in tile57_info.tile_type; + * the engine bakes MLT). */ +typedef enum { + TILE57_TILE_TYPE_MVT = 1, /* Mapbox Vector Tile */ + TILE57_TILE_TYPE_MLT = 2, /* MapLibre Tile (the bake default) */ +} tile57_tile_type; + +/* Fixed chart metadata, for a host that frames its own camera. Bounds/anchor + * validity are flagged (false -> those fields are 0). native_scale is the + * compilation scale 1:N the bake embedded (0 = unknown — derive from the zoom + * band). */ +typedef struct { + uint8_t min_zoom, max_zoom; + uint32_t bands; /* bitmask: bit r = band rank r present */ + bool has_bounds; double west, south, east, north; + bool has_anchor; double anchor_lat, anchor_lon, anchor_zoom; + uint8_t tile_type; /* tile57_tile_type */ + int32_t native_scale; +} tile57_info; +void tile57_chart_get_info(tile57_chart *chart, tile57_info *out); + +/* The distinct SCAMIN denominators present in the chart (ascending); NULL/0 when + * none. Free with tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). */ +tile57_status tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len, + tile57_error *err); + +/* The chart's M_COVR data-coverage polygons, from the coverage the bake embedded: + * ring() is called once per polygon with its exterior ring as npts interleaved + * lon,lat doubles (valid only during the call). OK with no calls when the archive + * embeds none. */ +typedef struct { + void *ctx; + void (*ring)(void *ctx, const double *lonlat, size_t npts); +} tile57_coverage_cb; +tile57_status tile57_chart_coverage(tile57_chart *chart, const tile57_coverage_cb *cb, + tile57_error *err); + +/* The chart's own stored tile at (z,x,y), decompressed (MLT or MVT per + * tile57_info.tile_type), with NO composition — the per-archive primitive for + * an embedder writing its own compositor. NULL/0 when the archive has no tile + * there. */ +tile57_status tile57_chart_tile(tile57_chart *chart, uint8_t z, uint32_t x, uint32_t y, + uint8_t **out, size_t *out_len, tile57_error *err); + +/* Release a chart and all cached tiles (not while a compositor still holds it). */ +void tile57_chart_close(tile57_chart *chart); +``` + +## Query the features under a point (object query / pick) + +The S-52 cursor pick. Given a lon/lat and the current view `zoom`, tile57 replays +the tile at that zoom and reports every feature the point falls in — an area you +are inside, or a line or point symbol within a small radius. Each hit calls you +back with the S-57 object-class acronym, the attribute JSON (acronym to value), +and the source chart name. This is what a chart application shows when you tap a +feature to see what it is. + +Passing the view zoom matters: the query reports the features actually DISPLAYED +at that zoom (it applies the same SCAMIN cull the renderer does), and the pick +tolerance tracks on-screen distance instead of ground distance — so a buoy is just +as easy to tap zoomed out as zoomed in, and a zoomed-out click doesn't return +finer-scale features that aren't drawn. + +```c +typedef struct { + void *ctx; + void (*feature)(void *ctx, const char *cls, size_t cls_len, + const char *s57, size_t s57_len, + const char *chart, size_t chart_len); +} tile57_query_cb; + +/* Calls cb->feature once per displayed feature under (lon,lat) at view `zoom`. + * Callback pointers are valid only during that call. */ +tile57_status tile57_chart_query(tile57_chart *chart, double lon, double lat, double zoom, + const tile57_query_cb *cb, tile57_error *err); +``` + +The class and chart name come through for any hit; the attribute JSON is filled in +from the `s57` pick property baked into the tiles (empty if a chart was baked +without pick attributes). + +## Render surfaces + +Every render surface draws the SAME portrayal of the SAME view — centre + fractional +zoom + pixel size — replaying the archive's baked tiles through the S-52 pixel path: +one scene across every covering tile, labels decluttered over the whole canvas, +catalogue symbols replayed as vectors. They differ only in what they hand back: a +finished raster (PNG/PDF), a stream of world-space draw calls (host surface), or +draw-ready GPU buffers. The mariner's live-swappable settings (colour scheme, +safety-contour danger and sounding swaps, category/SCAMIN/text gates, size scale) +evaluate at render time; the rest of the portrayal context was fixed at bake time. + +`width`/`height` must be 1..16384 per side; `m` NULL = canonical defaults +(`tile57_mariner_defaults`). The `tile57_mariner` settings struct is shared +with the [style builders](./style.md). + +### Finished view (PNG / PDF), one chart + +The [native S-52 rendering engine](../rendering.md) draws the view straight to +pixels or a deterministic single-page PDF. + +```c +/* PNG raster in *out/*out_len (free with tile57_free). */ +tile57_status tile57_chart_png(tile57_chart *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); + +/* Its vector twin: the SAME scene as a deterministic single-page PDF + * (1 px = 1 pt, 72 dpi; vector fills + glyph-outline text). */ +tile57_status tile57_chart_pdf(tile57_chart *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, + uint8_t **out, size_t *out_len, tile57_error *err); +``` + +The composed twins — `tile57_compose_png` / `tile57_compose_pdf`, same +parameters over a `tile57_compose` — render the same view across the WHOLE +composed set (see [Compose](./compose.md)). + +### Host surface (vector callbacks) + +Instead of a finished raster, tile57 can hand you the portrayed scene as a stream +of draw calls in world space. A GPU host tessellates that stream once, then pans +and zooms by transforming the vertices each frame, so symbols and text stay a +constant size on screen and no re-portrayal is needed while the view moves. + +You fill in a `tile57_surface_cb` vtable and pass it to `tile57_chart_surface` (or +`tile57_compose_surface` for the composed set). Area and line geometry come in +web-mercator world +coordinates (the range 0 to 1, with y pointing down). Point symbols, soundings, and +text come as a world anchor plus a small outline in reference pixels, so you can +draw them at a fixed size on screen. Every call carries the feature's SCAMIN, so you +can hide it by zoom in a shader — together with the display category it came in on, +so you can honour the S-52 rule that SCAMIN never hides a display-base feature +(`f->disp_cat == TILE57_DISP_BASE` => draw it at every zoom). + +You pass the view rotation (`rotation_rad`, 0 = north-up) and apply it to your own +transform. Each rotatable call carries a `tile57_rot_align` saying what its angle is +measured against: `TILE57_ALIGN_VIEWPORT` marks stay upright on screen (a buoy, an +ordinary label); `TILE57_ALIGN_MAP` marks are chart-relative and you add the view +rotation, so they turn with the chart — ORIENT symbols, every linestyle-embedded +symbol (traffic-lane and tidal-stream arrows, bank/dyke ticks), and depth-contour +value labels laid out along their contour. + +```c +typedef struct { double x, y; } tile57_world_point; /* web-mercator 0..1, y down */ +typedef struct { const tile57_world_point *pts; uint32_t n; + const uint32_t *ring_starts; uint32_t ring_count; } tile57_world_rings; +/* The S-52 display category the feature came in on. */ +typedef enum { TILE57_DISP_BASE=0, TILE57_DISP_STANDARD=1, TILE57_DISP_OTHER=2 } tile57_disp_cat; + +typedef struct { const char *cls; int64_t scamin; int32_t display_priority; + tile57_disp_cat disp_cat; } tile57_feature; + +/* What a rotatable call's angle is referenced to: VIEWPORT = screen (stay upright), + * MAP = chart (add the view rotation, turn with the chart). */ +typedef enum { TILE57_ALIGN_VIEWPORT = 0, TILE57_ALIGN_MAP = 1 } tile57_rot_align; + +typedef struct { + void *ctx; /* handed back to every call */ + void (*fill_area) (void *ctx, const tile57_feature *f, const tile57_world_rings *rings, + tile57_color color, int even_odd); + void (*stroke_line)(void *ctx, const tile57_feature *f, const tile57_world_rings *lines, + float width_px, float dash_on, float dash_off, tile57_color color); + /* rings arrive already rotated; align says whether to also add the view rotation. */ + void (*draw_symbol)(void *ctx, const tile57_feature *f, tile57_world_point anchor, + const tile57_local_rings *rings, tile57_color color, int even_odd, + float stroke_w, tile57_rot_align align); + /* text_group is the LABEL's S-52 text group (§14.5): 11 = important text (always + * shown — it ignores the mariner's text switches), 21/26/29 names, 23 light + * descriptions, 0 none. It rides the callback rather than tile57_feature because + * one feature can carry several labels in different groups. */ + void (*draw_text) (void *ctx, const tile57_feature *f, tile57_world_point anchor, + const tile57_local_rings *glyphs, tile57_color color, tile57_color halo, + float halo_px, tile57_rot_align align, int32_t text_group); + /* Optional. Leave NULL to get vector outlines from the two calls above; set them + * to draw point symbols and area patterns from the sprite atlas as textured quads. + * Draw the sprite at rot_deg + (align == MAP ? view_rotation : 0). */ + void (*draw_sprite) (void *ctx, const tile57_feature *f, const char *name, size_t name_len, + tile57_world_point anchor, float rot_deg, tile57_rot_align align, + float half_w_px, float half_h_px); + void (*draw_pattern)(void *ctx, const tile57_feature *f, const char *name, size_t name_len, + const tile57_world_rings *rings); + /* Optional. Text as a UTF-8 string for a host SDF glyph atlas (tile57_bake_glyph_sdf), + * instead of tessellated outlines. Rotate the run by rot_deg + (align == MAP ? + * view_rotation : 0). */ + void (*draw_text_str)(void *ctx, const tile57_feature *f, tile57_world_point anchor, + float ox_px, float oy_px, const char *text, size_t text_len, + float size_px, float rot_deg, tile57_rot_align align, + tile57_color color, tile57_color halo, int32_t text_group); +} tile57_surface_cb; + +/* Portray the view once and drive the callbacks. rotation_rad is the view rotation + * (radians clockwise; 0 = north-up), which you apply to your transform. */ +tile57_status tile57_chart_surface(tile57_chart *chart, double lon, double lat, double zoom, + double rotation_rad, + uint32_t width, uint32_t height, + const tile57_mariner *m, + const tile57_surface_cb *surface, tile57_error *err); +``` + +Set `draw_sprite` and `draw_pattern` once you have the sprite atlas loaded (see +[`tile57_bake_sprite_mln`](./assets.md)). tile57 then hands point +symbols, soundings, and area patterns by name, and you draw them as atlas quads — +smoothed by texture filtering and cheaper than tessellating outlines. If you leave +those two fields NULL, the same features arrive as vector outlines instead. + +tile57 also declutters overlapping text for you before it makes the calls (symbols +and soundings always draw, per S-52), so you don't repeat that work — and it lays +out depth-contour values along their contours, so you get the same labelled contours +as the raster and MapLibre outputs. + +Tell it your framebuffer density with `m.device_scale` (2.0 on a Retina backing +store). The engine sizes text and symbols in reference pixels and you draw them, so +it needs the density to size a label's collision box in the pixels you actually +paint. Draw at 2x while leaving `device_scale` at 1.0 and the declutter reserves +space for glyphs half the size that land on screen; the view comes out overlapping +even though the engine decluttered it correctly for the size it was told. + +#### Paint order + +The calls arrive in S-52 paint order. The engine buffers the scene and sorts it +before calling you, per S-52 Presentation Library §10.3.4.1: + +1. **`display_priority`** — the dominant key, and it "applies irrespective of whether an + object is a point, line or area". A light sector arc at priority 24 paints over + a wreck symbol at 12, even though one is a line and the other a point. +2. **geometry class** — a tiebreak used *only* where `display_priority` is equal: + areas, then area patterns, then lines, then point symbols, then soundings. +3. **emission order** — the tiebreak where both of the above are equal. + +Text is drawn last regardless of priority (§10.3.4.1, §16 rule 3). Draw the calls +in the order you receive them and the picture is right; you need no sort of your own. + +That holds only as long as you *preserve* the order. A GPU renderer usually batches +by draw type — all fills, then all sprites, then all text — to keep pipeline +switches down, and batching reorders the stream by construction: it lifts every call +of one type out of the sequence the engine placed it in. Global paint order is then +broken again, and broken in the way that looks fine on an empty stretch of water and +wrong in a harbour. + +**Do not batch by draw type and then sort each batch by `display_priority`.** That +reproduces the exact inversion this ordering exists to prevent — it makes geometry +class dominant and `display_priority` subordinate, so every sprite covers every line +whatever the priorities say. If you must batch, batch by `display_priority` *band* and +draw the bands in ascending order, switching pipelines within a band as the class +tiebreak requires. `display_priority` is exposed so you can rebuild the real order, not so +you can sort inside a per-type bucket. + +A host that batches per tile must go further: sorting within a tile still leaves +paint order broken across tiles, because tiles are drawn one after another. Walk +the priority bands *outside* the tile loop. + +Both text callbacks carry the label's `text_group`, so a host can style text by its +S-52 role rather than by its feature — draw group 11 (important text: vertical +clearances, bridge and cable legends) larger or bold, and leave ordinary names at +their normal weight. The group is per-LABEL, not per-feature: the same feature can +emit a name in group 26 and a clearance in group 11 on consecutive calls. + +#### Per-tile surface + cross-view labels + +The per-tile form `tile57_chart_tile_surface` takes no rotation: a tile is +tessellated once, north-up, and re-transformed on the GPU each frame, so a +continuously-turning course-up view never re-portrays or re-tessellates it — the +`align` flags carry everything the host needs to turn the right marks with the chart. + +Because `tile57_chart_tile_surface` declutters **within** each tile, a label that +straddles a tile seam collides or repeats across the join. When you cache geometry +per tile but want labels resolved across the whole view, add a single +`tile57_chart_labels` pass (`tile57_compose_labels` for the composed set). It walks +the view's covering tiles into **one** collision pool and emits **only** the +surviving text — through the same `draw_text_str` / `draw_text` callbacks, at the +same world anchors as `tile57_chart_surface` — and draws no fills, lines, symbols, or +soundings. So the host draws geometry + symbols from its per-tile cache and calls +this once per frame (or per view change) to overlay the globally-decluttered text +last (text is drawn on top). + +It is cheap enough to call on every view change. Each covering tile is portrayed +once and its label *candidates* — what a label says, how it is shaped, where it is +anchored — memoize on the chart or compositor. Neither zoom nor rotation is part of +that memo: the collision box, the depth-contour legibility gate and the upright flip +on a tangent-rotated run all derive per call, so a pan, zoom or rotation over tiles +already seen does no portrayal work and settles in well under a millisecond. Only +the first view of a region pays, and changing the palette or any mariner setting +retires the memo (a candidate carries a resolved colour and the text the mariner's +settings selected). The memo is bounded at a few hundred tiles and released with the +handle. + +```c +/* View-level, globally-decluttered TEXT pass: emits only surviving labels + * (draw_text_str / draw_text), no geometry. Same anchors/space as + * tile57_chart_surface; rotation_rad declutters in the screen frame. */ +tile57_status tile57_chart_labels(tile57_chart *chart, double lon, double lat, double zoom, + double rotation_rad, + uint32_t width, uint32_t height, + const tile57_mariner *m, + const tile57_surface_cb *surface, tile57_error *err); +``` + +There is a pixel-space twin, `tile57_chart_canvas` with a `tile57_canvas_cb` vtable, +that emits the SAME portrayal as resolved paint-order draw calls in canvas +pixels — for a host that wants the engine's own paint pipeline without the PNG +encode. Both callback forms have composed twins (`tile57_compose_canvas` / +`tile57_compose_surface`). + +### Draw-ready GPU scenes (batched buffers) + +The [host surface callback](#host-surface-vector-callbacks) hands back a *stream* of +draw calls in paint order. A GPU host can't draw that stream directly — it must batch +by pipeline, and batching reorders it (see [Paint order](#paint-order)). Rebuilding +the correct order then falls to the host, which means owning a tessellator and a copy +of the S-52 ordering rules. + +`tile57_chart_gpu_scene` does that work for you. It hands geometry back **already +triangulated, already in paint order, and already split into ranges that each draw +with one pipeline**. Upload the buffers, then walk the ranges in order and draw each +one — that is the whole host obligation. The host still owns only what is genuinely +per-frame: the camera, the shaders, and what to do with each vertex's `scamin` / +`disp_cat` visibility gates (baking those in would force a rebuild on every zoom or +category toggle). + +```c +/* One vertex. (x, y) is WORLD position (web-mercator [0,1], y down) — the camera + * transforms it. (ox, oy) is a REFERENCE-PIXEL offset added in SCREEN space after + * projection (zero for area interiors, ±half-width for line edges, the outline for + * marks) — that split is what holds a mark at a constant on-screen size while its + * anchor rides the chart, with no re-tessellation on zoom. */ +typedef struct { + float x, y; /* world position, [0,1] */ + float ox, oy; /* screen-space offset, ref px */ + float scamin; /* SCAMIN 1:N denominator; 0 = always visible */ + uint8_t disp_cat; /* 0 base, 1 standard, 2 other */ + uint8_t map_align; /* nonzero = chart-relative: a rotated view must turn it */ + uint8_t _pad[2]; + uint8_t color[4]; /* straight-alpha RGBA, per-vertex */ + float depth; /* paint-order depth (0,1); later paint = smaller = closer */ +} tile57_gpu_vertex; + +/* A contiguous slice of ONE buffer drawn with ONE pipeline. Ranges arrive already + * sorted by paint_key: draw them in order and the chart is correct. `prim` says + * which buffer first/count address (indices vs quads); `atlas` which texture a + * QUADS range samples. */ +typedef struct { + uint32_t first, count; + uint32_t paint_key; /* engine's paint order; already sorted, opaque */ + uint32_t pattern; /* index into scene.patterns, or TILE57_GPU_NO_PATTERN */ + uint8_t color[4]; /* resolved for the scene's palette */ + uint8_t kind; /* tile57_gpu_kind: AREA/PATTERN/LINE/SYMBOL/SOUNDING/TEXT */ + uint8_t prim; /* tile57_gpu_prim: TRIANGLES (indexed) or QUADS */ + uint8_t atlas; /* tile57_gpu_atlas: NONE/SPRITE/GLYPH[/_BOLD/_ITALIC] */ + uint8_t flags; /* bit 0: OPAQUE — eligible for the depth-tested pass */ +} tile57_gpu_range; + +/* Draw-ready buffers for one view. Every pointer is BORROWED until + * tile57_gpu_scene_free; `owner` is opaque. Upload vertices+indices (the triangle + * buffers) and quads (sprite/SDF), plus the two atlas textures you baked once + * (tile57_bake_sprite_mln, tile57_bake_glyph_sdf), then walk ranges in order. */ +typedef struct { + const tile57_gpu_vertex *vertices; size_t vertex_count; + const uint32_t *indices; size_t index_count; + const tile57_gpu_quad *quads; size_t quad_count; /* symbols + SDF text */ + const tile57_gpu_range *ranges; size_t range_count; + const tile57_gpu_pattern *patterns; size_t pattern_count; + void *owner; +} tile57_gpu_scene; + +/* Portray a view into those buffers — the draw-ready twin of tile57_chart_surface. + * The WHOLE view builds into ONE scene, so labels declutter across it and a name + * can't collide with itself over a tile seam. There is deliberately NO rotation + * parameter: geometry stays north-up in world space and the host applies the view + * rotation (the per-vertex map_align flag turns the marks that must follow the + * chart), so a continuously-turning course-up view never rebuilds. pixel_ratio is + * the display density (1, 2, ...) — pass the SAME value to tile57_bake_sprite_mln + * for the atlas you upload, or the sprite UVs won't index it. On OK the caller owns + * *out and MUST release it with tile57_gpu_scene_free. */ +tile57_status tile57_chart_gpu_scene(tile57_chart *chart, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, double pixel_ratio, + tile57_gpu_scene *out, tile57_error *err); + +/* Composed twin: a whole chart library (tile57_compose_open) into one scene, seams + * stitched across cells. Same buffers, same tile57_gpu_scene_free. */ +tile57_status tile57_compose_gpu_scene(tile57_compose *compose, double lon, double lat, double zoom, + uint32_t width, uint32_t height, + const tile57_mariner *m, double pixel_ratio, + tile57_gpu_scene *out, tile57_error *err); + +/* Release a scene and zero the struct — every borrowed pointer dies here, so finish + * uploading first. Null-safe and safe to call twice. */ +void tile57_gpu_scene_free(tile57_gpu_scene *scene); +``` + +To draw a scene: upload `vertices`+`indices` and `quads` once, then for each range +in order pick a pipeline from `kind`/`prim`/`atlas` and issue one draw — a +`TRIANGLES` range draws indexed from the flat-colour pipeline (or, when +`pattern != TILE57_GPU_NO_PATTERN`, the pattern pipeline with that cell); a `QUADS` +range draws `6·N` vertices from the sprite or SDF-glyph pipeline per `atlas`. Ranges +carrying `flags` bit 0 (OPAQUE) may be drawn front-to-back with a depth test first +for early-Z, then the rest in paint order — the per-vertex `depth` encodes that +order (later paint = smaller = closer). Everything else — pattern tiling +(phase-anchored to the world origin), the `flip`/`tangent_q` upright fix on +tangent-rotated text, applying the view rotation to `map_align` marks — is described +field-by-field in [`tile57.h`](https://github.com/beetlebugorg/tile57/blob/main/include/tile57.h). +The [Paint order](#paint-order) rules apply unchanged: the ranges *are* that order, +so drawing them in sequence is all a host must do to honour it. diff --git a/docs/docs/api/style.md b/docs/docs/api/style.md new file mode 100644 index 00000000..159d67be --- /dev/null +++ b/docs/docs/api/style.md @@ -0,0 +1,103 @@ +--- +title: MapLibre style +slug: /c-api/style +--- + +# Build a MapLibre style + +`tile57_style_build` turns a MapLibre style template + the mariner's S-52 display +options + the S-52 colortables into a concrete style JSON, client-side. The +template + colortables come from the built-in `tile57_style_template` / +`tile57_colortables_default` (or the generated [assets](./assets.md)); the host fills +`tile57_mariner` from its UI. The same `tile57_mariner` struct configures the +[render surfaces](./render.md#render-surfaces). + +```c +typedef enum { TILE57_SCHEME_DAY=0, TILE57_SCHEME_DUSK=1, TILE57_SCHEME_NIGHT=2 } tile57_scheme; +typedef enum { TILE57_DEPTH_METERS=0, TILE57_DEPTH_FEET=1 } tile57_depth_unit; +typedef enum { TILE57_BOUNDARY_SYMBOLIZED=0, TILE57_BOUNDARY_PLAIN=1 } tile57_boundary_style; + +typedef struct tile57_mariner { + tile57_scheme scheme; + double shallow_contour, safety_contour, deep_contour, safety_depth; + bool four_shade_water; + tile57_depth_unit depth_unit; + bool display_base, display_standard, display_other; + bool data_quality, show_inform_callouts, show_meta_bounds, show_isolated_dangers_shallow; + tile57_boundary_style boundary_style; + bool simplified_points, show_full_sector_lines; + bool text_names, show_light_descriptions, text_other; + bool date_dependent, highlight_date_dependent; + char date_view[9]; /* "YYYYMMDD" or "" (empty -> today) */ + bool ignore_scamin; /* debug: drop SCAMIN scale-gating (not S-52) */ + double size_scale; /* physical-scale multiplier; 1.0 = catalogue sizes */ + const int32_t *viewing_groups_off; /* S-52 §14.5 deny-list of `vg` ids turned off */ + uint32_t viewing_groups_off_len; + bool scamin_filter_gate; /* gate SCAMIN with a live filter, not bucket layers */ + bool show_overscale; /* S-52 §10.1.10 overscale indication: the + * AP(OVERSC01) hatch over regions displayed finer + * than their compilation scale. Defaults true. */ + double text_size_scale; /* extra size multiplier for TEXT labels, on top of + * size_scale (the engine scales glyph + collision box + * together). 1.0 = none; 0 reads as 1.0. */ + double sounding_size_scale; /* extra size multiplier for SOUNDINGS, on top of + * size_scale (scales each digit + spacing together). + * 1.0 = none; 0 reads as 1.0. */ + double device_scale; /* device px per reference px — the HiDPI density the + * SURFACE paths are drawn at (2.0 on a Retina backing + * store). Describes the DISPLAY where size_scale + * describes the mariner; the two multiply. Sizes text + * and symbols AND their collision boxes in the units + * the host actually draws in. The pixel outputs ignore + * it (that density is already in the requested + * width/height). 1.0 = a 1x framebuffer; 0 reads as + * 1.0. */ +} tile57_mariner; + +void tile57_mariner_defaults(tile57_mariner *m); /* canonical defaults, date_view = "" */ + +/* enabled_bands: NULL = show all; else only features whose band rank is in the + * array. scamin: the distinct SCAMIN denominators present in the source (e.g. + * from tile57_chart_scamin) — when non-NULL the `_scamin` layers split into per-value + * native-minzoom buckets; scamin_lat is the representative latitude. */ +tile57_status tile57_style_build(const char *template_json, size_t template_len, + const tile57_mariner *m, + const char *colortables_json, size_t colortables_len, + const int32_t *enabled_bands, size_t enabled_band_count, + const int32_t *scamin, size_t scamin_count, double scamin_lat, + uint8_t **out, size_t *out_len, tile57_error *err); + +/* Minimal MapLibre style-mutation ops to turn the style for `old_m` into the style + * for `new_m` (same inputs as tile57_style_build) — for flicker-free mariner + * toggles. Writes a JSON op array to *out/*out_len (free with tile57_free). */ +tile57_status tile57_style_diff(const char *template_json, size_t template_len, + const tile57_mariner *old_m, const tile57_mariner *new_m, + const char *colortables_json, size_t colortables_len, + const int32_t *enabled_bands, size_t enabled_band_count, + const int32_t *scamin, size_t scamin_count, double scamin_lat, + uint8_t **out, size_t *out_len, tile57_error *err); +``` + +The S-52 colortables and base style template are baked into the library, so a host +can build a complete style with no on-disk catalogue or template file (free each +buffer with `tile57_free`): + +```c +/* colortables.json (S-52 token -> hex per day/dusk/night) from the baked profile. */ +tile57_status tile57_colortables_default(uint8_t **out, size_t *out_len, + tile57_error *err); + +/* Base MapLibre style template (layers + chart source + sprite/glyph URLs). scheme + * selects the palette; source_tiles is the {z}/{x}/{y} URL (NULL -> a default + * pmtiles:// source); sprite/glyphs are base URLs (NULL omits those layers); + * minzoom is the chart source's tile floor, emitted verbatim (pass the archive's + * real minzoom); maxzoom 0 -> engine default. tile_encoding is the source's tile + * type (from tile57_info.tile_type): TILE57_TILE_TYPE_MLT emits "encoding":"mlt" + * on the source so maplibre-gl >= 5.12 decodes MLT natively; 0 / MVT emits + * nothing. */ +tile57_status tile57_style_template(tile57_scheme scheme, const char *source_tiles, + const char *sprite, const char *glyphs, + uint32_t minzoom, uint32_t maxzoom, + uint8_t tile_encoding, + uint8_t **out, size_t *out_len, tile57_error *err); +``` diff --git a/docs/docs/c-api.md b/docs/docs/c-api.md index cb02dc29..14d60ba2 100644 --- a/docs/docs/c-api.md +++ b/docs/docs/c-api.md @@ -10,724 +10,41 @@ sidebar_position: 6 [`include/tile57.h`](../../include/tile57.h), prefix `tile57_`. It is a shim over the [Zig API](./zig-api.md); the two stay in lock-step. -The pipeline is three stages, and the header (and this page) is organised the +The pipeline is three stages, and the header (and these pages) are organised the same way: -- **Bake** — ENC source data in, per-chart PMTiles out. Each chart bakes to - its own archive at its own compilation scale, with its M_COVR coverage + - scale embedded in the archive metadata. The bake section also carries the - raw-source readers (chart inventory, feature extraction, exchange-set - catalogue). -- **Render** — a **`tile57`** chart handle opens ONE baked archive and answers - for it with NO composition: metadata (info / SCAMIN / coverage), its stored - tiles verbatim (`tile57_chart_tile` — the primitive for writing your own - compositor), the S-52 cursor pick, and view outputs (`tile57_chart_png` / `_pdf` / - `_canvas` / `_surface`). -- **Compose** — a **`tile57_compose`** handle stitches MANY open charts through - the ownership partition and offers the SAME output set, composed: - `tile57_compose_tile` (what a live tile server hands its HTTP layer — the - bytes are MapLibre Tiles), `_png`, `_pdf`, `_canvas`, `_surface`, `_query`. +- **[Bake](./api/bake.md)** — ENC source data in, per-chart PMTiles out. Each chart + bakes to its own archive at its own compilation scale, with its M_COVR coverage + + scale embedded in the archive metadata. The bake page also carries the raw-source + readers (chart inventory, feature extraction, exchange-set catalogue). +- **[Render](./api/render.md)** — a **`tile57`** chart handle opens ONE baked archive + and answers for it with NO composition: metadata (info / SCAMIN / coverage), its + stored tiles verbatim (`tile57_chart_tile` — the primitive for writing your own + compositor), the S-52 cursor pick, and the render surfaces (PNG / PDF, host vector + callbacks, the draw-ready GPU scene, canvas, cross-view labels). +- **[Compose](./api/compose.md)** — a **`tile57_compose`** handle stitches MANY open + charts through the ownership partition and offers the SAME output set, composed: + `tile57_compose_tile` (what a live tile server hands its HTTP layer — the bytes are + MapLibre Tiles), `_png`, `_pdf`, `_canvas`, `_surface`, `_query`. Everything is **bake, then compose** (or bake, then render): source charts bake once to per-chart archives; every output is produced from baked archives. Style + portrayal-asset generation rounds out the surface: the mariner's S-52 -display options become a concrete MapLibre style JSON plus the colortables and -sprite / pattern / glyph atlases it references. - -## Errors - -Every call that can fail returns a `tile57_status` — `TILE57_OK` (0) or a coarse -cause — and takes an optional caller-owned `tile57_error*` it fills with the -status plus a specific message on failure (a stack local is fine; nothing to -free). Results come back through out-parameters, which are always defined on -return: the result on `TILE57_OK`, `NULL`/0 otherwise. "Nothing produced" is -NOT a failure — a call that finds nothing returns `TILE57_OK` with a -`NULL`/zero out. - -```c -typedef enum { - TILE57_OK = 0, /* success */ - TILE57_ERR_BADARG, /* a NULL or out-of-range argument */ - TILE57_ERR_IO, /* a file/directory could not be opened, read, or written */ - TILE57_ERR_PARSE, /* malformed input (S-57 chart, PMTiles, partition, JSON) */ - TILE57_ERR_NOMEM, /* an allocation failed */ - TILE57_ERR_UNSUPPORTED, /* valid but unusable input */ - TILE57_ERR_RENDER, /* tile generation or rendering failed */ - TILE57_ERR_INTERNAL, /* an unexpected engine failure */ -} tile57_status; - -const char *tile57_status_str(tile57_status status); /* static strerror-style text */ - -#define TILE57_ERROR_MSG_MAX 256 -typedef struct { - tile57_status status; - char message[TILE57_ERROR_MSG_MAX]; /* NUL-terminated; "" when no detail */ -} tile57_error; -``` - -```c -tile57_chart *chart = NULL; -tile57_error err; -if (tile57_chart_open("US5MD1MC.pmtiles", &chart, &err) != TILE57_OK) { - fprintf(stderr, "open failed: %s\n", err.message); /* "path: reason" */ -} -``` - -:::warning Lifetime + threading -No handle is internally synchronized — use one thread per handle. Each must -also outlive every borrower still holding it: a compositor borrows its charts -(close the compositor first, then the charts), and a path-opened chart mmaps -its file, so the file must stay in place while the chart is open. Calls that -return bytes allocate `*out`; free it with `tile57_free(ptr)`. Input -bytes are copied, so the caller may free them right after the call. -::: - -## Bake: ENC charts → per-chart archives - -Tile production is a two-step composite model. First bake each chart to its -own PMTiles at its compilation scale; the archive embeds the chart's M_COVR -coverage, compilation scale, and identity in its metadata. Then open a -**compositor** over the archives and serve any `(z, x, y)` tile on demand — -the compositor stitches the overlapping charts through an ownership partition, -handling cross-band zoom. - -```c -/* Bake ONE chart (+ its .001.. updates, read from disk) to PMTiles bytes over its - * native band zoom range. Returned in *out/*out_len (free with tile57_free); - * NULL/0 when the chart produced no tiles. */ -tile57_status tile57_bake_chart_bytes(const char *path, uint8_t **out, size_t *out_len, - tile57_error *err); - -/* Bake `n` charts IN PARALLEL across up to `workers` threads (a MEMORY bound — - * pass a small count). out_bytes[i]/out_lens[i] receive chart i's archive or - * NULL/0; *out_baked (NULL to ignore) counts the charts that produced bytes. */ -tile57_status tile57_bake_charts(const char *const *paths, size_t n, uint32_t workers, - uint8_t **out_bytes, size_t *out_lens, - size_t *out_baked, tile57_error *err); - -/* Walk in_dir for *.000 charts and bake each IN PARALLEL to the SAME relative - * path under out_dir with a .pmtiles extension (+ an .sha sidecar). - * INCREMENTAL: a chart whose archive is already at least as new as its whole - * input (.000 + update chain) is skipped, so a re-run over an unchanged tree - * bakes nothing — *out_baked counts THIS run, and 0 over a warm cache is - * success. progress (or NULL) fires per chart, possibly from worker threads; - * returning false CANCELS the bake (at chart granularity — the charts in flight - * finish). A cancelled bake is TILE57_OK with *out_baked = what it completed. */ -typedef bool (*tile57_bake_progress)(void *ctx, uint32_t done, uint32_t total); -tile57_status tile57_bake_tree(const char *in_dir, const char *out_dir, uint32_t workers, - tile57_bake_progress progress, void *progress_ctx, - uint32_t *out_baked, tile57_error *err); - -/* Read a PMTiles archive's metadata JSON blob (decompressed); NULL/0 when the - * archive carries none. A per-chart bake embeds the chart's coverage + cscl + - * date/name under a "coverage" key. */ -tile57_status tile57_pmtiles_metadata(const uint8_t *pmtiles, size_t len, - uint8_t **out, size_t *out_len, - tile57_error *err); -``` - -Every baked feature carries the pick-report properties `class` (object-class -acronym), `cell` (source chart stem), and `s57` (the full S-57 attribute set as a -JSON object) — what `tile57_chart_query` and a host inspector read back. - -The `tile57 bake -o out/` CLI produces this structure -directly: `out/tiles/.pmtiles` per chart plus `out/partition.tpart`. - -### Read raw S-57 source data - -The bake section also reads the source data directly — no handle, no bake — for -a host's import UI: - -```c -/* Per-chart metadata of the S-57 data at `path` (one .000, updates applied, or a - * whole ENC_ROOT) as a JSON array: [{"name","scale","edition","update", - * "issueDate","agency","bbox"}, ...] — a host's chart-database scan. */ -tile57_status tile57_enc_charts(const char *path, uint8_t **out, size_t *out_len, - tile57_error *err); - -/* Features for comma-separated object-class acronyms (e.g. "DEPARE,DRGARE") as - * a GeoJSON FeatureCollection: lon/lat geometry, properties = {"class", plus the - * full S-57 acronym->value attribute map}. NULL/0 when nothing matched. */ -tile57_status tile57_enc_features(const char *path, const char *classes, - uint8_t **out, size_t *out_len, tile57_error *err); - -/* The same over in-memory base .000 bytes (from a zip member, say). */ -tile57_status tile57_enc_features_bytes(const uint8_t *base, size_t len, - const char *classes, - uint8_t **out, size_t *out_len, tile57_error *err); - -/* Decode a CATALOG.031 exchange-set catalogue into a JSON array of its CATD - * entries — file path, longName (chart title), impl (BIN/ASC/TXT), bbox. */ -tile57_status tile57_enc_catalog(const uint8_t *catalog_031, size_t len, - uint8_t **out, size_t *out_len, tile57_error *err); -``` - -The CLI mirrors these as `tile57 cells`, `tile57 features`, and -`tile57 catalog`. - -## Render: the `tile57_chart` handle - -A `tile57_chart` is ONE baked PMTiles archive, opened for metadata and -output — with no composition (the compositor below offers the same outputs -across many charts). Open it from a path (mmap'd — a whole chart library can -be open without being resident) or from bytes (copied). - -```c -const char *tile57_version(void); /* "0.3.0" */ - -/* Opaque chart handle: one open baked archive. */ -typedef struct tile57_chart tile57_chart; - -tile57_status tile57_chart_open(const char *path, tile57_chart **out, tile57_error *err); -tile57_status tile57_chart_open_bytes(const uint8_t *pmtiles, size_t len, - tile57_chart **out, tile57_error *err); - -/* Vector-tile encodings an archive can store (reported in tile57_info.tile_type; - * the engine bakes MLT). */ -typedef enum { - TILE57_TILE_TYPE_MVT = 1, /* Mapbox Vector Tile */ - TILE57_TILE_TYPE_MLT = 2, /* MapLibre Tile (the bake default) */ -} tile57_tile_type; - -/* Fixed chart metadata, for a host that frames its own camera. Bounds/anchor - * validity are flagged (false -> those fields are 0). native_scale is the - * compilation scale 1:N the bake embedded (0 = unknown — derive from the zoom - * band). */ -typedef struct { - uint8_t min_zoom, max_zoom; - uint32_t bands; /* bitmask: bit r = band rank r present */ - bool has_bounds; double west, south, east, north; - bool has_anchor; double anchor_lat, anchor_lon, anchor_zoom; - uint8_t tile_type; /* tile57_tile_type */ - int32_t native_scale; -} tile57_info; -void tile57_chart_get_info(tile57_chart *chart, tile57_info *out); - -/* The distinct SCAMIN denominators present in the chart (ascending); NULL/0 when - * none. Free with tile57_free((uint8_t*)*out, *out_len * sizeof(int32_t)). */ -tile57_status tile57_chart_scamin(tile57_chart *chart, int32_t **out, size_t *out_len, - tile57_error *err); - -/* The chart's M_COVR data-coverage polygons, from the coverage the bake embedded: - * ring() is called once per polygon with its exterior ring as npts interleaved - * lon,lat doubles (valid only during the call). OK with no calls when the archive - * embeds none. */ -typedef struct { - void *ctx; - void (*ring)(void *ctx, const double *lonlat, size_t npts); -} tile57_coverage_cb; -tile57_status tile57_chart_coverage(tile57_chart *chart, const tile57_coverage_cb *cb, - tile57_error *err); - -/* The chart's own stored tile at (z,x,y), decompressed (MLT or MVT per - * tile57_info.tile_type), with NO composition — the per-archive primitive for - * an embedder writing its own compositor. NULL/0 when the archive has no tile - * there. */ -tile57_status tile57_chart_tile(tile57_chart *chart, uint8_t z, uint32_t x, uint32_t y, - uint8_t **out, size_t *out_len, tile57_error *err); - -/* Release a chart and all cached tiles (not while a compositor still holds it). */ -void tile57_chart_close(tile57_chart *chart); -``` - -### Query the features under a point (object query / pick) - -The S-52 cursor pick. Given a lon/lat and the current view `zoom`, tile57 replays -the tile at that zoom and reports every feature the point falls in — an area you -are inside, or a line or point symbol within a small radius. Each hit calls you -back with the S-57 object-class acronym, the attribute JSON (acronym to value), -and the source chart name. This is what a chart application shows when you tap a -feature to see what it is. - -Passing the view zoom matters: the query reports the features actually DISPLAYED -at that zoom (it applies the same SCAMIN cull the renderer does), and the pick -tolerance tracks on-screen distance instead of ground distance — so a buoy is just -as easy to tap zoomed out as zoomed in, and a zoomed-out click doesn't return -finer-scale features that aren't drawn. - -```c -typedef struct { - void *ctx; - void (*feature)(void *ctx, const char *cls, size_t cls_len, - const char *s57, size_t s57_len, - const char *chart, size_t chart_len); -} tile57_query_cb; - -/* Calls cb->feature once per displayed feature under (lon,lat) at view `zoom`. - * Callback pointers are valid only during that call. */ -tile57_status tile57_chart_query(tile57_chart *chart, double lon, double lat, double zoom, - const tile57_query_cb *cb, tile57_error *err); -``` - -The class and chart name come through for any hit; the attribute JSON is filled in -from the `s57` pick property baked into the tiles (empty if a chart was baked -without pick attributes). - -### Render a finished view (PNG / PDF), one chart - -The [native S-52 rendering engine](./rendering.md) draws a view of the chart — -centre + fractional zoom + pixel size — by replaying the archive's baked tiles -through the S-52 pixel path: one scene across every covering tile, labels -decluttered over the whole canvas, catalogue symbols replayed as vectors. The -mariner's live-swappable settings (colour scheme, safety-contour danger and -sounding swaps, category/SCAMIN/text gates, size scale) evaluate at render -time; the rest of the portrayal context was fixed at bake time. - -`width`/`height` must be 1..16384 per side; `m` NULL = canonical defaults -(`tile57_mariner_defaults`). The `tile57_mariner` settings struct is shared -with the [style builders](#build-a-maplibre-style) below. - -```c -/* PNG raster in *out/*out_len (free with tile57_free). */ -tile57_status tile57_chart_png(tile57_chart *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, - uint8_t **out, size_t *out_len, tile57_error *err); - -/* Its vector twin: the SAME scene as a deterministic single-page PDF - * (1 px = 1 pt, 72 dpi; vector fills + glyph-outline text). */ -tile57_status tile57_chart_pdf(tile57_chart *chart, double lon, double lat, double zoom, - uint32_t width, uint32_t height, - const tile57_mariner *m, - uint8_t **out, size_t *out_len, tile57_error *err); -``` - -The composed twins — `tile57_compose_png` / `tile57_compose_pdf`, same -parameters over a `tile57_compose` — render the same view across the WHOLE -composed set (see below). - -### Render to a host surface (vector callbacks) - -Instead of a finished raster, tile57 can hand you the portrayed scene as a stream -of draw calls in world space. A GPU host tessellates that stream once, then pans -and zooms by transforming the vertices each frame, so symbols and text stay a -constant size on screen and no re-portrayal is needed while the view moves. - -You fill in a `tile57_surface_cb` vtable and pass it to `tile57_chart_surface` (or -`tile57_compose_surface` for the composed set). Area and line geometry come in -web-mercator world -coordinates (the range 0 to 1, with y pointing down). Point symbols, soundings, and -text come as a world anchor plus a small outline in reference pixels, so you can -draw them at a fixed size on screen. Every call carries the feature's SCAMIN, so you -can hide it by zoom in a shader — together with the display category it came in on, -so you can honour the S-52 rule that SCAMIN never hides a display-base feature -(`f->disp_cat == TILE57_DISP_BASE` => draw it at every zoom). - -You pass the view rotation (`rotation_rad`, 0 = north-up) and apply it to your own -transform. Each rotatable call carries a `tile57_rot_align` saying what its angle is -measured against: `TILE57_ALIGN_VIEWPORT` marks stay upright on screen (a buoy, an -ordinary label); `TILE57_ALIGN_MAP` marks are chart-relative and you add the view -rotation, so they turn with the chart — ORIENT symbols, every linestyle-embedded -symbol (traffic-lane and tidal-stream arrows, bank/dyke ticks), and depth-contour -value labels laid out along their contour. - -```c -typedef struct { double x, y; } tile57_world_point; /* web-mercator 0..1, y down */ -typedef struct { const tile57_world_point *pts; uint32_t n; - const uint32_t *ring_starts; uint32_t ring_count; } tile57_world_rings; -/* The S-52 display category the feature came in on. */ -typedef enum { TILE57_DISP_BASE=0, TILE57_DISP_STANDARD=1, TILE57_DISP_OTHER=2 } tile57_disp_cat; - -typedef struct { const char *cls; int64_t scamin; int32_t display_priority; - tile57_disp_cat disp_cat; } tile57_feature; - -/* What a rotatable call's angle is referenced to: VIEWPORT = screen (stay upright), - * MAP = chart (add the view rotation, turn with the chart). */ -typedef enum { TILE57_ALIGN_VIEWPORT = 0, TILE57_ALIGN_MAP = 1 } tile57_rot_align; - -typedef struct { - void *ctx; /* handed back to every call */ - void (*fill_area) (void *ctx, const tile57_feature *f, const tile57_world_rings *rings, - tile57_color color, int even_odd); - void (*stroke_line)(void *ctx, const tile57_feature *f, const tile57_world_rings *lines, - float width_px, float dash_on, float dash_off, tile57_color color); - /* rings arrive already rotated; align says whether to also add the view rotation. */ - void (*draw_symbol)(void *ctx, const tile57_feature *f, tile57_world_point anchor, - const tile57_local_rings *rings, tile57_color color, int even_odd, - float stroke_w, tile57_rot_align align); - /* text_group is the LABEL's S-52 text group (§14.5): 11 = important text (always - * shown — it ignores the mariner's text switches), 21/26/29 names, 23 light - * descriptions, 0 none. It rides the callback rather than tile57_feature because - * one feature can carry several labels in different groups. */ - void (*draw_text) (void *ctx, const tile57_feature *f, tile57_world_point anchor, - const tile57_local_rings *glyphs, tile57_color color, tile57_color halo, - float halo_px, tile57_rot_align align, int32_t text_group); - /* Optional. Leave NULL to get vector outlines from the two calls above; set them - * to draw point symbols and area patterns from the sprite atlas as textured quads. - * Draw the sprite at rot_deg + (align == MAP ? view_rotation : 0). */ - void (*draw_sprite) (void *ctx, const tile57_feature *f, const char *name, size_t name_len, - tile57_world_point anchor, float rot_deg, tile57_rot_align align, - float half_w_px, float half_h_px); - void (*draw_pattern)(void *ctx, const tile57_feature *f, const char *name, size_t name_len, - const tile57_world_rings *rings); - /* Optional. Text as a UTF-8 string for a host SDF glyph atlas (tile57_bake_glyph_sdf), - * instead of tessellated outlines. Rotate the run by rot_deg + (align == MAP ? - * view_rotation : 0). */ - void (*draw_text_str)(void *ctx, const tile57_feature *f, tile57_world_point anchor, - float ox_px, float oy_px, const char *text, size_t text_len, - float size_px, float rot_deg, tile57_rot_align align, - tile57_color color, tile57_color halo, int32_t text_group); -} tile57_surface_cb; - -/* Portray the view once and drive the callbacks. rotation_rad is the view rotation - * (radians clockwise; 0 = north-up), which you apply to your transform. */ -tile57_status tile57_chart_surface(tile57_chart *chart, double lon, double lat, double zoom, - double rotation_rad, - uint32_t width, uint32_t height, - const tile57_mariner *m, - const tile57_surface_cb *surface, tile57_error *err); -``` - -Set `draw_sprite` and `draw_pattern` once you have the sprite atlas loaded (see -[`tile57_bake_sprite_mln`](#generate-portrayal-assets)). tile57 then hands point -symbols, soundings, and area patterns by name, and you draw them as atlas quads — -smoothed by texture filtering and cheaper than tessellating outlines. If you leave -those two fields NULL, the same features arrive as vector outlines instead. - -tile57 also declutters overlapping text for you before it makes the calls (symbols -and soundings always draw, per S-52), so you don't repeat that work — and it lays -out depth-contour values along their contours, so you get the same labelled contours -as the raster and MapLibre outputs. - -Tell it your framebuffer density with `m.device_scale` (2.0 on a Retina backing -store). The engine sizes text and symbols in reference pixels and you draw them, so -it needs the density to size a label's collision box in the pixels you actually -paint. Draw at 2x while leaving `device_scale` at 1.0 and the declutter reserves -space for glyphs half the size that land on screen; the view comes out overlapping -even though the engine decluttered it correctly for the size it was told. - -#### Paint order - -The calls arrive in S-52 paint order. The engine buffers the scene and sorts it -before calling you, per S-52 Presentation Library §10.3.4.1: - -1. **`display_priority`** — the dominant key, and it "applies irrespective of whether an - object is a point, line or area". A light sector arc at priority 24 paints over - a wreck symbol at 12, even though one is a line and the other a point. -2. **geometry class** — a tiebreak used *only* where `display_priority` is equal: - areas, then area patterns, then lines, then point symbols, then soundings. -3. **emission order** — the tiebreak where both of the above are equal. - -Text is drawn last regardless of priority (§10.3.4.1, §16 rule 3). Draw the calls -in the order you receive them and the picture is right; you need no sort of your own. - -That holds only as long as you *preserve* the order. A GPU renderer usually batches -by draw type — all fills, then all sprites, then all text — to keep pipeline -switches down, and batching reorders the stream by construction: it lifts every call -of one type out of the sequence the engine placed it in. Global paint order is then -broken again, and broken in the way that looks fine on an empty stretch of water and -wrong in a harbour. - -**Do not batch by draw type and then sort each batch by `display_priority`.** That -reproduces the exact inversion this ordering exists to prevent — it makes geometry -class dominant and `display_priority` subordinate, so every sprite covers every line -whatever the priorities say. If you must batch, batch by `display_priority` *band* and -draw the bands in ascending order, switching pipelines within a band as the class -tiebreak requires. `display_priority` is exposed so you can rebuild the real order, not so -you can sort inside a per-type bucket. - -A host that batches per tile must go further: sorting within a tile still leaves -paint order broken across tiles, because tiles are drawn one after another. Walk -the priority bands *outside* the tile loop. - -Both text callbacks carry the label's `text_group`, so a host can style text by its -S-52 role rather than by its feature — draw group 11 (important text: vertical -clearances, bridge and cable legends) larger or bold, and leave ordinary names at -their normal weight. The group is per-LABEL, not per-feature: the same feature can -emit a name in group 26 and a clearance in group 11 on consecutive calls. - -The per-tile form `tile57_chart_tile_surface` takes no rotation: a tile is -tessellated once, north-up, and re-transformed on the GPU each frame, so a -continuously-turning course-up view never re-portrays or re-tessellates it — the -`align` flags carry everything the host needs to turn the right marks with the chart. - -Because `tile57_chart_tile_surface` declutters **within** each tile, a label that -straddles a tile seam collides or repeats across the join. When you cache geometry -per tile but want labels resolved across the whole view, add a single -`tile57_chart_labels` pass (`tile57_compose_labels` for the composed set). It walks -the view's covering tiles into **one** collision pool and emits **only** the -surviving text — through the same `draw_text_str` / `draw_text` callbacks, at the -same world anchors as `tile57_chart_surface` — and draws no fills, lines, symbols, or -soundings. So the host draws geometry + symbols from its per-tile cache and calls -this once per frame (or per view change) to overlay the globally-decluttered text -last (text is drawn on top). - -It is cheap enough to call on every view change. Each covering tile is portrayed -once and its label *candidates* — what a label says, how it is shaped, where it is -anchored — memoize on the chart or compositor. Neither zoom nor rotation is part of -that memo: the collision box, the depth-contour legibility gate and the upright flip -on a tangent-rotated run all derive per call, so a pan, zoom or rotation over tiles -already seen does no portrayal work and settles in well under a millisecond. Only -the first view of a region pays, and changing the palette or any mariner setting -retires the memo (a candidate carries a resolved colour and the text the mariner's -settings selected). The memo is bounded at a few hundred tiles and released with the -handle. - -```c -/* View-level, globally-decluttered TEXT pass: emits only surviving labels - * (draw_text_str / draw_text), no geometry. Same anchors/space as - * tile57_chart_surface; rotation_rad declutters in the screen frame. */ -tile57_status tile57_chart_labels(tile57_chart *chart, double lon, double lat, double zoom, - double rotation_rad, - uint32_t width, uint32_t height, - const tile57_mariner *m, - const tile57_surface_cb *surface, tile57_error *err); -``` - -There is a pixel-space twin, `tile57_chart_canvas` with a `tile57_canvas_cb` vtable, -that emits the SAME portrayal as resolved paint-order draw calls in canvas -pixels — for a host that wants the engine's own paint pipeline without the PNG -encode. Both callback forms have composed twins (`tile57_compose_canvas` / -`tile57_compose_surface`). - -## Compose: many charts, one chart - -The compositor builds (or loads) the ownership partition over its charts' -embedded coverage, then offers the SAME output set as a single chart, composed: -any tile on demand for the cost of a classify plus one decompress or one -decode/clip, plus the composed view outputs and the composed cursor pick. It -**borrows** the charts — their mmap'd archives and decoded coverage — so the -chart set is never fully resident and the charts must outlive the compositor. -Open once, serve many, close. - -```c -/* Opaque runtime-compositor handle. */ -typedef struct tile57_compose tile57_compose; - -/* Coverage/zoom summary filled by tile57_compose_get_meta. */ -typedef struct { - uint8_t min_zoom; - uint8_t max_zoom; /* deepest zoom served (native + one overscale zoom) */ - uint32_t charts; /* coverage-carrying charts held */ - double west, south, east, north; /* union coverage bounds, degrees */ -} tile57_compose_meta; - -/* Open a compositor over `n` open charts. Charts whose archives embed no - * coverage are skipped (they can own no ground); none at all is - * TILE57_ERR_UNSUPPORTED. partition_path (NULL to skip) names a sidecar — - * written by tile57_compose_save_partition (the `tile57 bake` CLI emits - * partition.tpart) — to load and skip the build; a missing/stale one falls back - * to building. Close with tile57_compose_close BEFORE closing the charts. */ -tile57_status tile57_compose_open(tile57_chart *const *charts, size_t n, - const char *partition_path, - tile57_compose **out, tile57_error *err); - -/* Compose tile (z,x,y) on demand into RAW (decompressed) MLT — what a live tile - * server hands its HTTP layer (which gzips on the wire). NULL/0 out with OK = - * no bytes; *out_owned (NULL to ignore) then distinguishes the two empties: - * owned=false: no chart owns this ground — true empty ocean, safe to cache; - * owned=true: a chart owns this ground but produced nothing — transient while - * its per-chart bake is running, suspect once bakes are done. */ -tile57_status tile57_compose_tile(tile57_compose *c, uint8_t z, uint32_t x, uint32_t y, - uint8_t **out, size_t *out_len, bool *out_owned, - tile57_error *err); - -/* The composed view outputs and pick — the section-4 calls across the WHOLE - * composed set: every covering tile is composed on demand (stitched - * through the ownership partition) and replayed through the S-52 pixel path. - * Same parameters, limits, and ownership as the single-chart forms. */ -tile57_status tile57_compose_png(tile57_compose *c, double lon, double lat, double zoom, - uint32_t width, uint32_t height, const tile57_mariner *m, - uint8_t **out, size_t *out_len, tile57_error *err); -tile57_status tile57_compose_pdf(tile57_compose *c, double lon, double lat, double zoom, - uint32_t width, uint32_t height, const tile57_mariner *m, - uint8_t **out, size_t *out_len, tile57_error *err); -tile57_status tile57_compose_canvas(tile57_compose *c, double lon, double lat, double zoom, - uint32_t width, uint32_t height, const tile57_mariner *m, - const tile57_canvas_cb *canvas, tile57_error *err); -tile57_status tile57_compose_surface(tile57_compose *c, double lon, double lat, double zoom, - double rotation_rad, - uint32_t width, uint32_t height, const tile57_mariner *m, - const tile57_surface_cb *surface, tile57_error *err); -/* The composed view-level, globally-decluttered TEXT pass (tile57_chart_labels - * across the composed set): only surviving labels, decluttered across tile AND - * chart seams, no geometry. */ -tile57_status tile57_compose_labels(tile57_compose *c, double lon, double lat, double zoom, - double rotation_rad, - uint32_t width, uint32_t height, const tile57_mariner *m, - const tile57_surface_cb *surface, tile57_error *err); -tile57_status tile57_compose_query(tile57_compose *c, double lon, double lat, double zoom, - const tile57_query_cb *cb, tile57_error *err); - -/* Fill *out with the compositor's zoom range + union coverage bounds. */ -void tile57_compose_get_meta(tile57_compose *c, tile57_compose_meta *out); - -/* Serialize the ownership partition to `path` (a sidecar a later - * tile57_compose_open loads to skip the build). */ -tile57_status tile57_compose_save_partition(tile57_compose *c, const char *path, - tile57_error *err); - -/* Release a compositor. Its charts stay open (and stay yours to close). */ -void tile57_compose_close(tile57_compose *c); -``` - -```c -/* bake -> open -> compose -> serve */ -tile57_chart *charts[2]; -tile57_chart_open("tiles/US5MD1MC.pmtiles", &charts[0], NULL); -tile57_chart_open("tiles/US5MD1MD.pmtiles", &charts[1], NULL); -tile57_compose *cmp = NULL; -tile57_compose_open(charts, 2, "partition.tpart", &cmp, NULL); -uint8_t *tile; size_t n; bool owned; -tile57_compose_tile(cmp, 13, 2359, 3139, &tile, &n, &owned, NULL); -``` - -## Generate portrayal assets - -`tile57_bake_assets` produces all portrayal assets in memory — colour tables, -line styles, and the sprite / area-fill pattern atlases — from the library's -embedded catalogue (`catalog_dir` NULL/"") or an on-disk `PortrayalCatalog`. -Every non-NULL buffer is owned by the library; release the whole struct with -`tile57_assets_free`. - -```c -typedef struct { - uint8_t *colortables; size_t colortables_len; - uint8_t *linestyles; size_t linestyles_len; - uint8_t *sprite_json; size_t sprite_json_len; uint8_t *sprite_png; size_t sprite_png_len; - uint8_t *pattern_json; size_t pattern_json_len; uint8_t *pattern_png; size_t pattern_png_len; -} tile57_assets; - -tile57_status tile57_bake_assets(const char *catalog_dir, tile57_assets *out, - tile57_error *err); -void tile57_assets_free(tile57_assets *out); -``` - -`tile57_bake_sprite_mln` is a focused variant that fills only the `sprite_json` / -`sprite_png` fields with a MapLibre **sprite-mln** atlas: every S-101 symbol packed -into one PNG, each atlas cell centered on its symbol's pivot, plus a JSON index of -`{name: {x, y, width, height, pixelRatio}}`. A GPU host loads this atlas once and -draws point symbols and area patterns as textured quads by name — the atlas the -[host-surface `draw_sprite`/`draw_pattern` callbacks](#render-to-a-host-surface-vector-callbacks) -hand back. `tile57_bake_glyph_sdf` is its text counterpart: an RGBA -signed-distance-field atlas of the label font, for a host that draws text as SDF -quads. Free either with `tile57_assets_free` as above. - -```c -tile57_status tile57_bake_sprite_mln(const char *catalog_dir, tile57_assets *out, - tile57_error *err); -tile57_status tile57_bake_glyph_sdf(tile57_assets *out, tile57_error *err); -``` - -## Build a MapLibre style - -`tile57_style_build` turns a MapLibre style template + the mariner's S-52 display -options + the S-52 colortables into a concrete style JSON, client-side. The -template + colortables come from the built-in `tile57_style_template` / -`tile57_colortables_default` (or the generated assets); the host fills -`tile57_mariner` from its UI. - -```c -typedef enum { TILE57_SCHEME_DAY=0, TILE57_SCHEME_DUSK=1, TILE57_SCHEME_NIGHT=2 } tile57_scheme; -typedef enum { TILE57_DEPTH_METERS=0, TILE57_DEPTH_FEET=1 } tile57_depth_unit; -typedef enum { TILE57_BOUNDARY_SYMBOLIZED=0, TILE57_BOUNDARY_PLAIN=1 } tile57_boundary_style; - -typedef struct tile57_mariner { - tile57_scheme scheme; - double shallow_contour, safety_contour, deep_contour, safety_depth; - bool four_shade_water; - tile57_depth_unit depth_unit; - bool display_base, display_standard, display_other; - bool data_quality, show_inform_callouts, show_meta_bounds, show_isolated_dangers_shallow; - tile57_boundary_style boundary_style; - bool simplified_points, show_full_sector_lines; - bool text_names, show_light_descriptions, text_other; - bool date_dependent, highlight_date_dependent; - char date_view[9]; /* "YYYYMMDD" or "" (empty -> today) */ - bool ignore_scamin; /* debug: drop SCAMIN scale-gating (not S-52) */ - double size_scale; /* physical-scale multiplier; 1.0 = catalogue sizes */ - const int32_t *viewing_groups_off; /* S-52 §14.5 deny-list of `vg` ids turned off */ - uint32_t viewing_groups_off_len; - bool scamin_filter_gate; /* gate SCAMIN with a live filter, not bucket layers */ - bool show_overscale; /* S-52 §10.1.10 overscale indication: the - * AP(OVERSC01) hatch over regions displayed finer - * than their compilation scale. Defaults true. */ - double text_size_scale; /* extra size multiplier for TEXT labels, on top of - * size_scale (the engine scales glyph + collision box - * together). 1.0 = none; 0 reads as 1.0. */ - double sounding_size_scale; /* extra size multiplier for SOUNDINGS, on top of - * size_scale (scales each digit + spacing together). - * 1.0 = none; 0 reads as 1.0. */ - double device_scale; /* device px per reference px — the HiDPI density the - * SURFACE paths are drawn at (2.0 on a Retina backing - * store). Describes the DISPLAY where size_scale - * describes the mariner; the two multiply. Sizes text - * and symbols AND their collision boxes in the units - * the host actually draws in. The pixel outputs ignore - * it (that density is already in the requested - * width/height). 1.0 = a 1x framebuffer; 0 reads as - * 1.0. */ -} tile57_mariner; - -void tile57_mariner_defaults(tile57_mariner *m); /* canonical defaults, date_view = "" */ - -/* enabled_bands: NULL = show all; else only features whose band rank is in the - * array. scamin: the distinct SCAMIN denominators present in the source (e.g. - * from tile57_chart_scamin) — when non-NULL the `_scamin` layers split into per-value - * native-minzoom buckets; scamin_lat is the representative latitude. */ -tile57_status tile57_style_build(const char *template_json, size_t template_len, - const tile57_mariner *m, - const char *colortables_json, size_t colortables_len, - const int32_t *enabled_bands, size_t enabled_band_count, - const int32_t *scamin, size_t scamin_count, double scamin_lat, - uint8_t **out, size_t *out_len, tile57_error *err); - -/* Minimal MapLibre style-mutation ops to turn the style for `old_m` into the style - * for `new_m` (same inputs as tile57_style_build) — for flicker-free mariner - * toggles. Writes a JSON op array to *out/*out_len (free with tile57_free). */ -tile57_status tile57_style_diff(const char *template_json, size_t template_len, - const tile57_mariner *old_m, const tile57_mariner *new_m, - const char *colortables_json, size_t colortables_len, - const int32_t *enabled_bands, size_t enabled_band_count, - const int32_t *scamin, size_t scamin_count, double scamin_lat, - uint8_t **out, size_t *out_len, tile57_error *err); -``` - -The S-52 colortables and base style template are baked into the library, so a host -can build a complete style with no on-disk catalogue or template file (free each -buffer with `tile57_free`): - -```c -/* colortables.json (S-52 token -> hex per day/dusk/night) from the baked profile. */ -tile57_status tile57_colortables_default(uint8_t **out, size_t *out_len, - tile57_error *err); - -/* Base MapLibre style template (layers + chart source + sprite/glyph URLs). scheme - * selects the palette; source_tiles is the {z}/{x}/{y} URL (NULL -> a default - * pmtiles:// source); sprite/glyphs are base URLs (NULL omits those layers); - * minzoom is the chart source's tile floor, emitted verbatim (pass the archive's - * real minzoom); maxzoom 0 -> engine default. tile_encoding is the source's tile - * type (from tile57_info.tile_type): TILE57_TILE_TYPE_MLT emits "encoding":"mlt" - * on the source so maplibre-gl >= 5.12 decodes MLT natively; 0 / MVT emits - * nothing. */ -tile57_status tile57_style_template(tile57_scheme scheme, const char *source_tiles, - const char *sprite, const char *glyphs, - uint32_t minzoom, uint32_t maxzoom, - uint8_t tile_encoding, - uint8_t **out, size_t *out_len, tile57_error *err); -``` - -## Util: warmup + free - -```c -/* Populate the process-global read-only registries (feature catalogue + - * complex-linestyle table) on the calling thread. Call ONCE on your main thread - * before opening or baking charts from worker threads, so concurrent bake/render is - * race-free. Idempotent. */ -void tile57_warmup(void); - -/* Free ANY buffer the engine returned (tiles, style JSON, the scamin array, - * colortables, …) — length-prefixed, so the pointer is all it needs. */ -void tile57_free(void *ptr); -``` - -## Diagnostics header - -[`include/tile57_diag.h`](../../include/tile57_diag.h) (`tile57_diag_*`) exposes -the embedded-Lua / S-101 framework bring-up self-tests — developer tooling, not -part of the embedding API. - -## Versioning - -Pre-1.0 (`0.3.0`). No external consumers yet, so the ABI is not frozen. +display options become a concrete [MapLibre style](./api/style.md) JSON plus the +[colortables and sprite / pattern / glyph atlases](./api/assets.md) it references. + +The [errors & lifecycle](./api/errors-lifecycle.md) conventions — the status/error +protocol, handle lifetime + threading, `tile57_warmup`, and `tile57_free` — apply to +every call on every page below. + +## Sections + +| Page | What it covers | +|------|----------------| +| [Errors & lifecycle](./api/errors-lifecycle.md) | `tile57_status` / `tile57_error`, handle lifetime + threading, `tile57_warmup`, `tile57_free`, diagnostics, versioning | +| [Bake](./api/bake.md) | charts → per-chart PMTiles archives; the raw S-57 source readers | +| [Render](./api/render.md) | the `tile57_chart` handle, the cursor pick, and every render surface (PNG / PDF, host vector callbacks, draw-ready GPU scene, canvas, cross-view labels) | +| [Compose](./api/compose.md) | the `tile57_compose` handle — many charts stitched into one composed output set | +| [Portrayal assets](./api/assets.md) | colour tables, line styles, sprite + pattern + SDF-glyph atlases | +| [MapLibre style](./api/style.md) | the `tile57_mariner` options → a concrete style JSON, plus flicker-free style diffs | diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md new file mode 100644 index 00000000..e2377370 --- /dev/null +++ b/docs/docs/contributing.md @@ -0,0 +1,85 @@ +--- +id: contributing +title: Contributing +slug: /contributing +sidebar_position: 11 +--- + +# Contributing + +tile57 is an **AI-first project**: it is built with AI assistance, and the most +valuable contribution is usually a clear description of what you want — a +requirement or a prototype — rather than finished code. That is not a barrier to +entry; it is the fastest path from an idea to a working feature here. + +## AI-First Development + +This project is built with AI assistance. We encourage contributors to use AI tools +for development and to contribute by providing clear requirements and/or a prototype +of what they'd like rather than code. + +Why this works well for a chart engine: + +- **The spec is the hard part, not the typing.** tile57 implements IHO S-101 / S-57 + decoding, the S-101 Portrayal Catalogue, and S-52 display. Most of the effort is + interpreting the spec correctly — and a precise requirement (which rule, which + object class, what the chart should look like) is exactly what turns into a correct + implementation. +- **A prototype communicates faster than prose.** A screenshot of the wrong + portrayal, a small ENC cell that reproduces a bug, or a rough sketch of the API you + wish existed tells us more than a paragraph. +- **Humans review everything.** AI-assisted changes are reviewed for correctness and + safety before they land — and this being a chart engine, **[not for + navigation](./limitations.md)** is a standing constraint on every change. + +## Ways to contribute + +### 1. Report an issue + +Open a [GitHub issue](https://github.com/beetlebugorg/tile57/issues). A good bug +report includes: + +- **What you expected** — ideally with the relevant S-52 / S-101 rule or a reference + image of the correct portrayal. +- **What happened instead** — a screenshot, the PNG/PDF/ASCII output, or the console + output. +- **How to reproduce** — the smallest ENC cell (or a public NOAA/IHO cell name) and + the exact command or call: e.g. `tile57 png --view --size WxH`. +- **Environment** — OS, Zig version, native vs. WASM, and the tile57 version + (`tile57_version()` / `tile57 --version`). + +### 2. Request a feature (the preferred path) + +The most useful contribution is a **clear requirement or a prototype** of what you'd +like. This is how new features are best started here — describe the outcome and let +the implementation follow. A strong request covers: + +- **Problem & context** — what you're trying to do and why the engine can't do it yet. +- **Proposed behaviour** — what the output or API should be. Concrete beats abstract: + a target image, the exact tiles/PNG you'd expect, or the function signature you want. +- **Spec references** — the S-101/S-52/S-57 sections or object classes involved, if + you know them (we'll find them if you don't). +- **Examples** — input (a cell, a view) → expected output, including edge cases. +- **Done when** — how we'll both know it works: a rule that portrays without error, a + reference render that matches, a cell that now decodes. + +A rough prototype counts as a requirement: a script, a hand-edited style, a mock of +the API, or an image marked up with what's wrong — anything that pins down the intent. + +### 3. Contribute code + +Direct pull requests are welcome. Please: + +- Keep the change focused and describe the intent (link the issue/requirement it + addresses). +- Build and test locally — `zig build && zig build test` (see + [Installation](./installation.md)). +- Expect review for correctness and safety, the same as AI-assisted changes. + +## The process + +1. **Open** an issue or a requirement/prototype. +2. **Discuss** — we refine the requirement together on the issue. +3. **Implement** — often AI-assisted, always human-reviewed. +4. **Verify** — tests, and where portrayal is involved, a reference render. +5. **Document** — the relevant page under [Docs](./intro.md) is updated with the change. diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 5f4b3494..ccf562d0 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -119,17 +119,28 @@ handle-free via `tile57_enc_*`. See the [C API](./c-api.md). Add tile57 as a dependency and `@import("tile57")`: ```zig +const std = @import("std"); const tile57 = @import("tile57"); +// tile57 needs an allocator and a std.Io. Any allocator works; the C ABI uses +// std.heap.c_allocator. +const gpa = std.heap.c_allocator; +var threaded: std.Io.Threaded = .init(gpa, .{}); +defer threaded.deinit(); +const io = threaded.io(); + // Open one baked archive as a chart (mmap'd): metadata, pick, view renders. var chart = try tile57.Chart.openPmtilesPath(io, "out/tiles/US5MD1MC.pmtiles"); defer chart.deinit(); const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null // Or compose the whole bake output and take any output from it. -var src = (try tile57.compose.ComposeSource.openFiles(io, gpa, paths, "out/partition.tpart")).?; +const paths = [_][]const u8{"out/tiles/US5MD1MC.pmtiles"}; +var src = (try tile57.compose.ComposeSource.openFiles(io, gpa, &paths, "out/partition.tpart")).?; defer src.deinit(); const result = try src.tile(gpa, 15, 9371, 12534); // one composed tile + +var settings = tile57.render.resolve.Settings{}; // S-52 display defaults const png = try tile57.compose.renderView(src, -76.48, 38.974, 13.5, 1600, 1200, .day, &settings, .png, null); // one composed view ``` diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 2df239c7..f4ef870f 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -9,9 +9,7 @@ sidebar_position: 1 :::warning Not for navigation -This project is coded almost entirely with AI (Claude) and human-reviewed. It is -an experiment in using AI to implement a large, complex specification (IHO -S-101) — not a certified or tested navigation product. **Do not rely on it for +This is not a certified or tested navigation product. **Do not rely on it for real-world navigation.** See [Known limitations](./limitations.md). ::: @@ -44,24 +42,6 @@ The whole engine is one **Zig** library behind a **C ABI**, with **Go** bindings in the repo; it compiles natively (Linux, macOS, Windows) or to **WASM**. -:::note Goals - -tile57 is an experiment in building a real, spec-faithful nautical chart -engine almost entirely with AI assistance: - -- **AI-written, human-reviewed** — every significant piece was generated by - Claude and reviewed by a human. -- **Spec adherence first** — the actual IHO documents and the official - Portrayal Catalogue, not approximations. -- **Cross-platform via Zig** — one core compiles to native (Linux, macOS, - Windows) and WASM without code changes. -- **Coupled tile + style** — tiles and the MapLibre style that draws them ship - together, so native and web renderers share one chart look. -- **Language-agnostic embedding** — a thin C ABI bridges the Zig core to any - language with C FFI; Go bindings ship in the repo. - -::: - ## The pipeline ``` diff --git a/docs/docs/limitations.md b/docs/docs/limitations.md index 7b69d10a..5c376ca5 100644 --- a/docs/docs/limitations.md +++ b/docs/docs/limitations.md @@ -13,9 +13,9 @@ buoys and beacons, lights (including sector legs and arcs), dangers (obstructions / wrecks / rocks), data-quality zones, restricted/anchorage areas, and text labels. -But "no rule errors" is not the same as "pixel-perfect S-52", and this project is -an AI-built experiment and learning tool — **do not use it for navigation**. This -page is an honest list of what is still incomplete, taken from the engine code. +But "no rule errors" is not the same as "pixel-perfect S-52" — **do not use it for +navigation**. This page is an honest list of what is still incomplete, taken from +the engine code. :::warning Not for navigation See the warning on the [introduction](./intro.md). NOAA ENC charts are U.S. public diff --git a/docs/docs/rendering.md b/docs/docs/rendering.md index 247ece2c..7386bb43 100644 --- a/docs/docs/rendering.md +++ b/docs/docs/rendering.md @@ -143,8 +143,8 @@ tile57_chart_pdf(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &pdf, &plen, NULL); That renders ONE chart, no composition. A view across a whole chart library is the same call on the compositor — `tile57_compose_png` / `_pdf` — which -composes every covering tile through the ownership partition first. See the -[C API](./c-api.md). +composes every covering tile through the ownership partition first. See +[Compose](./api/compose.md). `m.size_scale` calibrates physical size (so 1 S-52 millimetre is a true millimetre on your display). Every field of `tile57_mariner` — categories, text @@ -165,7 +165,9 @@ overlapping. Both interfaces are directly available: build a `PixelSurface`, drive it with `scene.generateTile` / `scene.generateView`, or replay a decoded tile with `scene.replayTile`. See `tools/bake.zig`'s `runRender` for a complete worked -example. +example. For the higher-level `Chart` entry points — `renderView` (PNG / PDF / +canvas), `renderSurfaceView`, and `renderGpuScene` — see the +[Zig API render page](./zig/render.md#render-surfaces). ## Extending it @@ -180,30 +182,36 @@ the ten Surface methods and you receive the full semantic stream — this is how MVT and MLT are done (`TileSurface` in `src/scene/scene.zig`), and how a GeoJSON debug dump or a GPU display list would be done. -**From the C ABI:** both interfaces are exposed as callback tables. -`tile57_chart_canvas` drives a `tile57_canvas_cb` — C function pointers receiving -resolved, flattened paths, patterns, and glyph outlines in pixel space, in -paint order (the Canvas seat). `tile57_chart_surface` drives a `tile57_surface_cb` — -the world-space, semantically tagged stream (per-feature class + SCAMIN, world -anchors, reference-pixel outlines) a GPU host tessellates once and transforms -per frame (the Surface seat). - -That stream arrives in S-52 paint order too: the engine buffers the scene and -sorts it (areas → patterns → lines → symbols → soundings → text, by draw priority -within each class) before calling you, so drawing in callback order is correct -and no host needs its own sort. The catch is that only an order you *preserve* -survives. A GPU renderer that batches by draw type — all fills, then all sprites, -then all text — to minimise pipeline switches has reordered the stream by -construction, and global paint order breaks again. If you batch, sort each batch -by the per-feature `display_priority` and draw the batches in the class order above; that is -what `display_priority` is still exposed for. - -Its two text callbacks also carry the label's S-52 -text group, so a host can draw group 11 (important text) larger or bold and -ordinary names normally — that group belongs to the label, not the feature. -Both have composed twins on the compositor -(`tile57_compose_canvas` / `tile57_compose_surface`). A custom output format in -Zig is still one small file in `src/render/`. +**From the C ABI:** the same engine is exposed three ways, all in S-52 paint order +— see [Render surfaces](./api/render.md#render-surfaces) for the full contract: + +- `tile57_chart_canvas` drives a `tile57_canvas_cb` — resolved, flattened paths, + patterns, and glyph outlines in **pixel** space (the Canvas seat). +- `tile57_chart_surface` drives a `tile57_surface_cb` — the **world-space**, + semantically tagged stream (per-feature class + SCAMIN, world anchors, + reference-pixel outlines) a GPU host tessellates once and transforms per frame + (the Surface seat). +- `tile57_chart_gpu_scene` hands back **draw-ready GPU buffers** — already + triangulated, already in paint order, already batched into one-pipeline ranges — + so a GPU host does no tessellation and owns no copy of the S-52 ordering rules. + +The two callback seats arrive already sorted (areas → patterns → lines → symbols → +soundings → text, by draw priority within each class), so drawing in callback order +is correct and no host needs its own sort — but only an order you *preserve* +survives. A GPU host that batches by draw type to cut pipeline switches reorders the +stream and breaks paint order; the fix is to batch by `display_priority` **band** +(not by pipeline, and *not* by sorting each per-type batch), drawing the bands in +ascending order. That rule is documented in full on the C API page: +[Paint order](./api/render.md#paint-order). The +[draw-ready GPU scene](./api/render.md#draw-ready-gpu-scenes-batched-buffers) +sidesteps it entirely — its ranges *are* the order, so drawing them in sequence is +all a host does. + +The two text callbacks also carry the label's S-52 text group, so a host can draw +group 11 (important text) larger or bold and ordinary names normally — that group +belongs to the label, not the feature. All three seats have composed twins on the +compositor (`tile57_compose_canvas` / `_surface` / `_gpu_scene`). A custom output +format in Zig is still one small file in `src/render/`. For a tile-renderer host that caches geometry per tile (via the per-tile `tile57_chart_tile_surface`), a companion `tile57_chart_labels` diff --git a/docs/docs/zig-api.md b/docs/docs/zig-api.md index a1bdbfaa..387541b2 100644 --- a/docs/docs/zig-api.md +++ b/docs/docs/zig-api.md @@ -11,7 +11,7 @@ Add it as a dependency and `@import("tile57")` for the curated public surface (`src/tile57.zig`). The [C ABI](./c-api.md) is a thin shim over this same API, and both share the same shape: **bake, then compose** (or bake, then render) — source charts bake once to per-chart archives, and every output is produced -from baked archives. +from baked archives. These pages are grouped the same way as the [C API](./c-api.md). :::note Add it as a **path dependency** on a local clone (submodules initialised) — @@ -20,163 +20,14 @@ Add it as a **path dependency** on a local clone (submodules initialised) — [Installation](./installation.md). ::: -## Bake - -Bake each chart to its own PMTiles at its compilation scale — the input the -compositor serves from. Free any returned bytes with `tile57.freeBytes`. - -```zig -// Bake an ENC_ROOT: each chart -> /tiles/.pmtiles + /partition.tpart. -// Incremental: an archive already newer than its whole input is skipped. -const n = try tile57.bake.tree(io, "/enc/ENC_ROOT", "/out", null, 4, null, null); -``` - -| Surface | What it does | -|---------|--------------| -| `tile57.bake.chartBytes(path, rules)` | bake one chart (+ updates) to PMTiles bytes. | -| `tile57.bake.chartsParallel(...)` / `bake.chartsToFiles(...)` | bake many charts in parallel, to memory / to files. | -| `tile57.bake.tree(io, in, out, ...)` | walk an ENC_ROOT, bake each chart to a mirrored path (incremental). | -| `tile57.bake.pmtilesMetadata(a, bytes)` | read an archive's metadata JSON (embedded coverage + scamin). | -| `tile57.bake.Progress` | the optional progress-callback type. | - -Reading raw S-57 source data (a chart inventory, GeoJSON feature extraction) -goes through a streaming `Chart` — see `openPath` below. - -## Render: the `Chart` - -A `Chart` is one open chart: metadata, feature extraction, the S-52 cursor -pick, and view renders — with no composition. Tiles across many charts come -from the compositor (next section). - -```zig -const tile57 = @import("tile57"); - -// A baked archive, mmap'd (never fully resident). -var chart = try tile57.Chart.openPmtilesPath(io, "US5MD1MC.pmtiles"); -defer chart.deinit(); - -const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null -// … chart.renderView(…) / chart.queryPoint(…) … -``` - -What a chart can do depends on how it was opened: - -| Open | Backend | Serves | -|------|---------|--------| -| `openPmtilesPath(io, path)` | baked archive, mmap'd | metadata (embedded coverage + scale), query, view renders (tile replay), raw tiles via `pmtilesReader()`. | -| `openBytes(bytes, .pmtiles, …)` | baked archive, copied | the same, from memory. | -| `openBytes(cell_bytes, .auto, rules_dir)` | ONE live S-57 chart, fully portrayed | metadata, query, view renders with the S-101 rules evaluated live. | -| `openPath(path, rules_dir, pick_attrs)` | streaming ENC_ROOT (or a single `.000`) | metadata + extraction ONLY: `chartsJson`, `featuresJson`, `scamin`, bounds. Charts are enumerated up front and parsed on demand, so a whole catalogue opens instantly. No view renders, no tiles. | -| `openCharts(charts, …)` / `openChartsStreaming(metas, reader, …)` | the same, from in-memory charts / a host reader callback | as `openPath`. | - -`Chart` methods: - -| Method | Purpose | -|--------|---------| -| `renderView(lon, lat, zoom, w, h, palette, settings, output, cb)` | render a view through the native S-52 pixel path — PNG, PDF, or a callback canvas. | -| `renderSurfaceView(lon, lat, zoom, w, h, palette, settings, cb)` | drive world-space surface callbacks (the GPU vector twin). | -| `renderAscii(lon, lat, zoom, cols, rows, palette, settings, ansi)` | the same view as a terminal text grid. | -| `queryPoint(lon, lat, zoom, cb)` | the S-52 cursor pick — features under a point at the view zoom. | -| `chartsJson()` / `featuresJson(classes)` | per-chart metadata / GeoJSON feature extraction (the `cells` / `features` CLI). | -| `coverage()` | the M_COVR data-coverage rings (a live chart, or the copy a per-chart bake embeds in its archive metadata). | -| `bounds() -> ?[4]f64` | geographic extent `[w, s, e, n]`, if known. | -| `anchor()` | a good initial camera (lat, lon, zoom) on real data. | -| `bands() -> u32` | bitmask of navigational bands present. | -| `zoomRange()` | the min/max zoom the chart covers. | -| `nativeScale() -> i32` | the compilation scale 1:N (a live chart or an archive's embedded metadata; 0 if unknown). | -| `scamin() -> ![]u32` | the distinct SCAMIN denominators present (the live SCAMIN manifest). | -| `tileType()` | the tile encoding the chart's tiles use (MVT/MLT). | -| `format() -> Format` | the resolved backend (after `.auto`). | -| `pmtilesReader()` / `decodedCoverage()` | the archive reader (raw per-archive tiles — the primitive for writing your own compositor) + the decoded per-chart coverage; what the built-in compositor borrows. | -| `deinit()` | release the chart and its cached tiles. | - -`tile57.Format` is `.auto` / `.pmtiles` / `.s57`. `rules_dir` is the S-101 -portrayal rules directory for live S-57 charts; `null` (or `""`) uses the rules -embedded in the binary (or `TILE57_S101_RULES` if set), so no on-disk catalogue -is required; a path overrides with an on-disk catalogue. - -The streaming open uses the extern types `tile57.ChartMeta` (bbox + `cscl`), -`tile57.ChartBytes` (the chart's base + updates, ownership transferred to the -library), and `tile57.ChartReadFn` (the reader callback). Multi-chart input for -`openCharts` is `tile57.ChartInput`. - -## Compose - -The runtime compositor stitches per-chart archives into one seamless chart: -any `(z, x, y)` tile on demand through the ownership partition (charts never -double-draw where they meet), and the same view outputs as a single chart, -composed. - -```zig -// Open the compositor over the archives + partition, then compose tiles. -var src = (try tile57.compose.ComposeSource.openFiles(io, gpa, paths, "/out/partition.tpart")).?; -defer src.deinit(); -const result = try src.tile(gpa, 13, 2359, 3139); // result.tile: ?[]u8, result.owned: bool - -// The composed view outputs live beside the Chart ones: -const png = try tile57.compose.renderView(src, lon, lat, 13.5, 1600, 1200, .day, &settings, .png, null); -``` - -A host that already holds open charts composes over them instead — the -compositor borrows each chart's mmap'd reader + decoded coverage, so the charts -must outlive it: - -```zig -const archives = [_]tile57.compose.ChartArchive{ - .{ .reader = chart.pmtilesReader().?, .cov = chart.decodedCoverage().? }, -}; -var src = (try tile57.compose.ComposeSource.open(gpa, &archives, null)).?; -``` - -| Surface | What it does | -|---------|--------------| -| `ComposeSource.openFiles(io, gpa, paths, part)` | open a `ComposeSource` over on-disk archives + a partition. | -| `ComposeSource.open(gpa, archives, part)` | the same over borrowed `ChartArchive`s (already-open charts). | -| `ComposeSource.tile(gpa, z, x, y)` | compose one tile on demand (raw MLT + the ownership flag). | -| `tile57.compose.renderView(src, ...)` | the composed view render — PNG, PDF, or a callback canvas. | -| `tile57.compose.renderSurfaceView(src, ...)` | the composed world-space surface stream. | -| `tile57.compose.queryPoint(src, lon, lat, zoom, cb)` | the composed cursor pick, across chart boundaries. | -| `tile57.compose.tile(...)` | the stateless core `ComposeSource.tile` uses. | -| `tile57.partition` | the ownership partition and its `.tpart` sidecar (serialize / deserialize). | - -## Style + portrayal assets - -```zig -// MapLibre style from a template + mariner S-52 display settings + colortables. -const json = try tile57.style.buildFromTemplate(/* … */); // tile57.Mariner settings -``` - -| Surface | What it does | -|---------|--------------| -| `tile57.style` | the MapLibre style: `json`, `Options`, `diff`, `buildFromTemplate`, color tables, line styles. | -| `tile57.style.mariner` | the S-52 mariner settings model and expression builders. | -| `tile57.Mariner` | the S-52 mariner display options struct (`style.mariner.Settings`). | -| `tile57.sprite` | S-101 sprite + area-fill pattern atlases (SVG raster). | - -## Tiling + encoding - -The mid-level packages, for callers that compose their own pipeline: - -| Module | Role | -|--------|------| -| `tile57.mvt` / `tile57.mlt` | Mapbox Vector Tile / MapLibre Tile encode/decode | -| `tile57.tile` | web-mercator tiling + clipping | -| `tile57.pmtiles` | PMTiles read/write | -| `tile57.band` | compilation-scale → zoom-range mapping | -| `tile57.bake_enc` | banded multi-chart ENC_ROOT → PMTiles | -| `tile57.scene` | S-57 feature → tile-surface scene generation | -| `tile57.render` | the Surface/Canvas rendering path (PNG, PDF, ASCII, callbacks) | - -## Raw formats (advanced) - -The pure-Zig foundational parsers under `tile57.formats`: - -| Module | Role | -|--------|------| -| `tile57.formats.iso8211` | ISO/IEC 8211 records | -| `tile57.formats.s57` | the S-57 chart parser + geometry | -| `tile57.formats.s101` | the S-101 catalogue, adapter, and instruction stream | - -`tile57.coverage` is the per-chart M_COVR coverage sidecar (carried in an -archive's PMTiles metadata). `tile57.version` is the package version string -(`"0.3.0"`), matching `build.zig.zon` and `tile57_version()`. +## Sections + +| Page | What it covers | +|------|----------------| +| [Errors & lifecycle](./zig/errors-lifecycle.md) | the error-union model, handle lifetime + threading, `tile57.warmup`, `tile57.freeBytes`, the version string | +| [Bake](./zig/bake.md) | `tile57.bake` — charts → per-chart PMTiles archives | +| [Render](./zig/render.md) | the `Chart` — open modes, the render surfaces (pixel / surface callbacks / GPU scene / ASCII), the cursor pick, and metadata | +| [Compose](./zig/compose.md) | `tile57.compose` — the `ComposeSource` stitching many charts into one | +| [Portrayal assets](./zig/assets.md) | `tile57.sprite` — the sprite + area-fill pattern atlases | +| [MapLibre style](./zig/style.md) | `tile57.style` + `tile57.Mariner` — a concrete style JSON, colour tables, line styles | +| [Low-level modules](./zig/low-level.md) | Zig-only packages with no C ABI equivalent: tiling/encoding and the raw `tile57.formats` parsers | diff --git a/docs/docs/zig/assets.md b/docs/docs/zig/assets.md new file mode 100644 index 00000000..3a42a6d6 --- /dev/null +++ b/docs/docs/zig/assets.md @@ -0,0 +1,36 @@ +--- +title: Portrayal assets +slug: /zig-api/assets +--- + +# Portrayal assets + +`tile57.sprite` rasterizes the S-101 portrayal symbols and area-fill patterns into +the atlases a host loads once and draws from — the same atlas the +[host-surface `draw_sprite` / `draw_pattern` callbacks](../api/render.md#host-surface-vector-callbacks) +and the [draw-ready GPU scene](../api/render.md#draw-ready-gpu-scenes-batched-buffers) +sample by name. Inputs are the catalogue's SVG symbol / area-fill sources + the CSS; +output is an `Atlas` (packed PNG + a JSON index of cell rects). Colour tables and +line styles live with the [MapLibre style](./style.md) builders. + +```zig +// The point-symbol atlas: every S-101 symbol packed into one sheet. +pub fn sprite.spriteAtlas(a: std.mem.Allocator, srcs: []const sprite.SvgSrc, + css_data: []const u8) !sprite.Atlas + +// The area-fill pattern atlas (fill patterns + the symbols they place). +pub fn sprite.patternAtlas(a: std.mem.Allocator, fills: []const sprite.AreaFillSrc, + symbols: []const sprite.SvgSrc, css_data: []const u8) !sprite.Atlas + +// The MapLibre "sprite-mln" atlas — every symbol + area-fill pattern in one sheet, +// each cell centered on its pivot, at display density `ratio` (1, 2, …). This is +// the atlas a GPU host uploads; pass the SAME ratio you pass renderGpuScene / +// tile57_bake_sprite_mln, or the sprite UVs won't index it. +pub fn sprite.spriteMln(a: std.mem.Allocator, symbols: []const sprite.SvgSrc, + fills: []const sprite.AreaFillSrc, css_data: []const u8, + soundings: []const []const u8, ratio: f64) !sprite.Atlas +``` + +`sprite.Atlas` carries the packed pixels + the `{name → CellRect}` index. +`sprite.glyph.build(a, font, cps, em_px, pad)` is the SDF label-glyph counterpart, +for a host that draws text as SDF quads. diff --git a/docs/docs/zig/bake.md b/docs/docs/zig/bake.md new file mode 100644 index 00000000..89572455 --- /dev/null +++ b/docs/docs/zig/bake.md @@ -0,0 +1,63 @@ +--- +title: Bake +slug: /zig-api/bake +--- + +# Bake + +Tile production is **bake, then compose**. First bake each chart to its own +PMTiles at its compilation scale — the archive embeds the chart's M_COVR +coverage, compilation scale, and identity in its metadata. Then open a +[compositor](./compose.md) over the archives and serve any `(z, x, y)` tile on +demand. Strictly one chart, one archive. + +Every returned byte buffer is `gpa`-owned; free it with `tile57.freeBytes` (see +[Errors & lifecycle](./errors-lifecycle.md)). + +```zig +// Bake an ENC_ROOT: each chart -> /tiles/.pmtiles + /partition.tpart. +const n = try tile57.bake.tree(io, "/enc/ENC_ROOT", "/out", null, 4, null, null); +``` + +```zig +// Bake ONE chart (+ its .001.. updates, read from disk) to PMTiles bytes over its +// native band zoom range. Returns null when the chart produced no tiles. +// rules_dir null/"" uses the embedded S-101 catalogue. +pub fn bake.chartBytes(cell_path: []const u8, rules_dir: ?[]const u8) !?[]u8 + +// Bake N charts IN PARALLEL across `workers` threads (a MEMORY bound — pass a +// small count). out[i] receives chart i's archive bytes, or null. +pub fn bake.chartsParallel(paths: []const []const u8, rules_dir: ?[]const u8, + workers: usize, out: []?[]u8) void + +// The same, writing each archive straight to its out_paths[i] file (the engine +// writes + frees each archive, so the host never holds N in memory). Returns the +// count written; `progress` fires per chart and may cancel by returning false. +pub fn bake.chartsToFiles(io: std.Io, in_paths: []const []const u8, + out_paths: []const []const u8, rules_dir: ?[]const u8, + workers: usize, progress: bake.Progress, progress_ctx: ?*anyopaque) usize + +// Walk in_dir for *.000 charts and bake each IN PARALLEL to the SAME relative path +// under out_dir with a .pmtiles extension. INCREMENTAL: a chart whose archive is +// already at least as new as its whole input (.000 + update chain) is skipped, so a +// re-run over an unchanged tree bakes nothing. Returns the count baked THIS run. +pub fn bake.tree(io: std.Io, in_dir: []const u8, out_dir: []const u8, + rules_dir: ?[]const u8, workers: usize, + progress: bake.Progress, progress_ctx: ?*anyopaque) !usize + +// Read a baked archive's metadata JSON (decompressed) — a per-chart bake embeds +// the chart's coverage + compilation scale + date/name. null when it carries none. +pub fn bake.pmtilesMetadata(a: std.mem.Allocator, archive: []const u8) !?[]u8 +``` + +`bake.Progress` is the optional progress-callback type — it fires per chart +(serialised) for an import progress bar and may **cancel** by returning `false`; a +cancelled `tree` run resumes where it left off, baking only what the cancel left +undone. + +The `tile57 bake -o out/` CLI produces this structure +directly: `out/tiles/.pmtiles` per chart plus `out/partition.tpart`. + +Reading raw S-57 source data — a chart inventory or GeoJSON feature extraction with +no bake — goes through a streaming `Chart`; see [`openPath`](./render.md) and +`chartsJson` / `featuresJson` on the [Render](./render.md#metadata--extraction) page. diff --git a/docs/docs/zig/compose.md b/docs/docs/zig/compose.md new file mode 100644 index 00000000..111cf1f9 --- /dev/null +++ b/docs/docs/zig/compose.md @@ -0,0 +1,161 @@ +--- +title: Compose +slug: /zig-api/compose +--- + +# Compose + +The runtime compositor stitches per-chart archives into one seamless chart: any +`(z, x, y)` tile on demand through the ownership partition (charts never +double-draw where they meet), and the same view outputs as a single +[chart](./render.md), composed. It **borrows** its charts — their mmap'd readers + +decoded coverage — so the chart set is never fully resident and the charts must +outlive the source. Open once, serve many, `deinit`. + +```zig +// Open the compositor over on-disk archives + a partition, then compose tiles. +var src = (try tile57.compose.ComposeSource.openFiles(io, gpa, paths, "/out/partition.tpart")).?; +defer src.deinit(); +const result = try src.tile(gpa, 13, 2359, 3139); // result.tile: ?[]u8, result.owned: bool + +// The composed view outputs live beside the Chart ones: +const png = try tile57.compose.renderView(src, lon, lat, 13.5, 1600, 1200, .day, &settings, .png, null); +``` + +```zig +// Open over per-chart PMTiles paths (mmap'd; the chart set is never fully +// resident). load_partition (null to skip) names a .tpart sidecar to load and +// skip the build. Null when no archive carries coverage. +pub fn ComposeSource.openFiles(io: std.Io, gpa: std.mem.Allocator, + paths: []const []const u8, load_partition: ?[]const u8) !?*ComposeSource + +// Open over already-open charts' archives (everything is BORROWED — the charts +// must outlive this source). Build each ChartArchive from a chart's +// pmtilesReader() + decodedCoverage(). +pub fn ComposeSource.open(gpa: std.mem.Allocator, archives: []const ChartArchive, + load_partition: ?[]const u8) !?*ComposeSource + +// Compose one tile on demand. TileResult = { tile: ?[]u8, owned: bool }: a null +// tile with owned=false is true empty ocean (safe to cache); owned=true but null +// is a chart owning the ground that produced nothing (transient while its bake runs). +pub fn ComposeSource.tile(self: *ComposeSource, gpa: std.mem.Allocator, + z: u8, tx: u32, ty: u32) !TileResult + +// Serialize the ownership partition (a sidecar a later openFiles loads to skip +// the build). +pub fn ComposeSource.serializePartition(self: *ComposeSource, gpa: std.mem.Allocator) ![]u8 + +// Release the source. Its charts stay open (and stay yours to close). +pub fn ComposeSource.deinit(self: *ComposeSource) void +``` + +A host that already holds open charts composes over them instead — the compositor +borrows each chart's mmap'd reader + decoded coverage, so the charts must outlive it: + +```zig +const archives = [_]tile57.compose.ChartArchive{ + .{ .reader = chart.pmtilesReader().?, .cov = chart.decodedCoverage().? }, +}; +var src = (try tile57.compose.ComposeSource.open(gpa, &archives, null)).?; +``` + +## Composed render surfaces + +Each composed output is the matching single-chart [render surface](./render.md#render-surfaces) +over the whole set instead of one archive — same `palette` (`.day` / `.dusk` / +`.night`) and `settings` (`*const tile57.Mariner`), same callbacks, but taking a +`*ComposeSource` as the first argument and decluttering labels across tile **and +chart** seams. They live under the `compose` name because their render path depends +on `Chart`, while the underlying `compose` module is a dependency leaf. + +### Finished view (PNG / PDF) + +```zig +pub fn compose.renderView(src: *ComposeSource, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, + palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, + output: render.pixel.Output, cb_table: ?*const render.cb_canvas.CCanvas) ![]u8 +``` + +`output` selects `.png` / `.pdf` / a callback canvas (pass `cb_table` for the +canvas, else `null`). Returns `gpa`-owned bytes — free with `tile57.freeBytes`. The +composed twin of [`Chart.renderView`](./render.md#finished-view-png--pdf), rendered +across every covering tile. + +```zig +const png = try tile57.compose.renderView(src, -76.48, 38.974, 13.5, 1600, 1200, .day, &settings, .png, null); +defer tile57.freeBytes(png); +``` + +### Host surface (vector callbacks) + +```zig +pub fn compose.renderSurfaceView(src: *ComposeSource, lon: f64, lat: f64, zoom: f64, rotation_rad: f64, + w: u32, h: u32, palette: render.resolve.PaletteId, + settings: *const render.resolve.Settings, cb: *const render.vector.CSurface) !void +``` + +Drives the same `tile57.render.vector.CSurface` vtable as +[`Chart.renderSurfaceView`](./render.md#host-surface-vector-callbacks) — the vector +twin a GPU host tessellates once and transforms per frame — but portrayed across the +whole set. `rotation_rad` is the view rotation (radians CW; 0 = north-up), applied +by the host. The callback contract and the S-52 paint-order obligation are on the C +API page: [Host surface](../api/render.md#host-surface-vector-callbacks) and +[Paint order](../api/render.md#paint-order). + +### Draw-ready GPU scene + +```zig +pub fn compose.renderGpuScene(src: *ComposeSource, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, + palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, + pixel_ratio: f64) !*GpuScene +``` + +The GPU twin of `renderSurfaceView`: a whole chart library into one +`tile57.GpuScene`, seams stitched across cells. Use it exactly like +[`Chart.renderGpuScene`](./render.md#draw-ready-gpu-scene) — upload `gs.scene`'s +buffers, walk `gs.scene.ranges` in order and draw each, then `gs.deinit()`. +`pixel_ratio` is the display density, matching the sprite atlas you upload. + +```zig +var gs = try tile57.compose.renderGpuScene(src, -76.48, 38.974, 13.5, 1600, 1200, .day, &settings, 2.0); +defer gs.deinit(); +for (gs.scene.ranges) |r| { + // same draw loop as Chart.renderGpuScene — pick a pipeline from r.prim/r.atlas, + // draw r.first/r.count, in order. + _ = r; +} +``` + +### Cross-view label pass + +```zig +pub fn compose.renderLabels(src: *ComposeSource, lon: f64, lat: f64, zoom: f64, rotation_rad: f64, + w: u32, h: u32, palette: render.resolve.PaletteId, + settings: *const render.resolve.Settings, cb: *const render.vector.CSurface) !void +``` + +A view-level, globally-decluttered **text-only** pass — it emits only the surviving +labels through the same `CSurface` text callbacks (at the same world anchors as +`renderSurfaceView`), decluttered across tile and chart seams, and draws no fills, +lines, symbols, or soundings. A host that caches geometry per tile draws that cache +itself, then calls this once per frame to overlay the globally-decluttered text last +(text draws on top). See the C API's +[cross-view label pass](../api/render.md#per-tile-surface--cross-view-labels). + +### Cursor pick + +```zig +pub fn compose.queryPoint(src: *ComposeSource, lon: f64, lat: f64, zoom: f64, + cb: *const render.query.QueryCb) !void +``` + +The S-52 §10.8 cursor pick across chart boundaries — the composed twin of +[`Chart.queryPoint`](./render.md#query-the-features-under-a-point), reporting each +feature under `(lon, lat)` at the view `zoom` (class + S-57 attribute JSON + source +cell) through `cb`. + +--- + +`tile57.compose.tile` is the stateless core `ComposeSource.tile` uses, and +`tile57.partition` is the ownership partition and its `.tpart` sidecar +(serialize / deserialize). diff --git a/docs/docs/zig/errors-lifecycle.md b/docs/docs/zig/errors-lifecycle.md new file mode 100644 index 00000000..531d219a --- /dev/null +++ b/docs/docs/zig/errors-lifecycle.md @@ -0,0 +1,54 @@ +--- +title: Errors & lifecycle +slug: /zig-api/errors-lifecycle +--- + +# Errors & lifecycle + +The cross-cutting conventions the other Zig API pages rely on: how calls report +failure, how long a handle lives and on which thread, warming the process-global +registries, freeing returned bytes, and the package version. + +## Errors + +The Zig surface uses ordinary Zig **error unions** — a fallible call returns `!T` +and you `try` it (or `catch` to handle). There are no status codes; the C ABI's +`tile57_status` / `tile57_error` are a shim the [C API](../c-api.md) layers on top +of these errors. "Nothing produced" is not an error: a call that finds nothing +returns an empty/`null` value, not a failure. + +```zig +var chart = tile57.Chart.openPmtilesPath(io, "US5MD1MC.pmtiles") catch |err| { + std.log.err("open failed: {s}", .{@errorName(err)}); + return err; +}; +defer chart.deinit(); +``` + +## Lifetime + threading + +No handle is internally synchronized — use one thread per handle. Each must also +outlive every borrower still holding it: the compositor borrows its charts (their +mmap'd readers + decoded coverage), so it must be `deinit`'d before the charts, and +a path-opened chart mmaps its file, so the file must stay in place while the chart +is open. A `Chart` is released with `chart.deinit()`; a `ComposeSource` with +`src.deinit()`. + +## Warmup + freeing bytes + +```zig +// Populate the process-global read-only registries (feature catalogue + +// complex-linestyle table) on the calling thread. Call ONCE on your main thread +// before opening or baking charts from worker threads, so concurrent +// bake/render is race-free. Idempotent. +tile57.warmup(); + +// Free ANY byte buffer the engine returned (tiles, style JSON, PNG bytes, …). +tile57.freeBytes(bytes); +``` + +## Version + +`tile57.version` is the package version string (`"0.3.0"`), matching +`build.zig.zon` and the C ABI's `tile57_version()`. The package requires Zig 0.16. +Pre-1.0: no external consumers yet, so the API is not frozen. diff --git a/docs/docs/zig/low-level.md b/docs/docs/zig/low-level.md new file mode 100644 index 00000000..bd6f20b7 --- /dev/null +++ b/docs/docs/zig/low-level.md @@ -0,0 +1,36 @@ +--- +title: Low-level modules +slug: /zig-api/low-level +--- + +# Low-level modules + +These packages have no C ABI equivalent — they are Zig-only, for callers that +compose their own pipeline below the `Chart` / `ComposeSource` surface. + +## Tiling + encoding + +The mid-level packages: + +| Module | Role | +|--------|------| +| `tile57.mvt` / `tile57.mlt` | Mapbox Vector Tile / MapLibre Tile encode/decode | +| `tile57.tile` | web-mercator tiling + clipping | +| `tile57.pmtiles` | PMTiles read/write | +| `tile57.band` | compilation-scale → zoom-range mapping | +| `tile57.bake_enc` | banded multi-chart ENC_ROOT → PMTiles | +| `tile57.scene` | S-57 feature → tile-surface scene generation | +| `tile57.render` | the Surface/Canvas rendering path (PNG, PDF, ASCII, callbacks) | + +## Raw formats (advanced) + +The pure-Zig foundational parsers under `tile57.formats`: + +| Module | Role | +|--------|------| +| `tile57.formats.iso8211` | ISO/IEC 8211 records | +| `tile57.formats.s57` | the S-57 chart parser + geometry | +| `tile57.formats.s101` | the S-101 catalogue, adapter, and instruction stream | + +`tile57.coverage` is the per-chart M_COVR coverage sidecar (carried in an +archive's PMTiles metadata). diff --git a/docs/docs/zig/render.md b/docs/docs/zig/render.md new file mode 100644 index 00000000..d9d56383 --- /dev/null +++ b/docs/docs/zig/render.md @@ -0,0 +1,221 @@ +--- +title: Render +slug: /zig-api/render +--- + +# Render: the `Chart` + +A `Chart` is one open chart — metadata, feature extraction, the S-52 cursor +pick, and view renders, with no composition. Tiles across many charts come from +the [compositor](./compose.md). + +## Opening a chart + +Opening (and baking) takes a `std.Io` and an allocator. Set those up once — any +allocator works; the C ABI uses `std.heap.c_allocator`: + +```zig +const std = @import("std"); +const tile57 = @import("tile57"); + +const gpa = std.heap.c_allocator; +var threaded: std.Io.Threaded = .init(gpa, .{}); +defer threaded.deinit(); +const io = threaded.io(); +``` + +The common case is one **baked PMTiles archive**, opened `mmap`'d — a whole chart +library can be open without being resident, and the file stays borrowed for the +chart's lifetime (released in `deinit`): + +```zig +var chart = try tile57.Chart.openPmtilesPath(io, "out/tiles/US5MD1MC.pmtiles"); +defer chart.deinit(); + +const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null +// … chart.renderView(…) / chart.queryPoint(…) below … +``` + +That is all most callers need. To serve tiles across *many* archives, open them +with the [compositor](./compose.md) instead. + +### Other open modes + +A chart can also come from memory or from live S-57 source. What it can serve +depends on how it was opened: + +```zig +// A baked archive from memory (fmt .pmtiles, copied) — the same as +// openPmtilesPath but without a file. OR ONE live S-57 cell fully portrayed +// (fmt .auto / .s57): renders views with the S-101 rules evaluated at call time. +// rules_dir null/"" uses the embedded catalogue. +pub fn Chart.openBytes(bytes: []const u8, fmt: Format, rules_dir: ?[]const u8) !*Chart + +// A streaming ENC_ROOT (or a single .000): enumerate the cells and peek each +// bbox + compilation scale at open, then read cell bytes on demand (freed on LRU +// eviction), so a whole catalogue opens instantly. Metadata + extraction ONLY +// (chartsJson / featuresJson / scamin / bounds) — no view renders, no tiles. +pub fn Chart.openPath(path: []const u8, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart + +// The same streaming surface from in-memory charts / a host reader callback. +pub fn Chart.openCharts(cells: []const ChartInput, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart +pub fn Chart.openChartsStreaming(metas: []const ChartMeta, reader: ChartReadFn, + user: ?*anyopaque, rules_dir: ?[]const u8, pick_attrs: bool) !*Chart +``` + +`Format` is `.auto` / `.pmtiles` / `.s57`. `rules_dir` is the S-101 portrayal +rules directory for live S-57 charts; `null` (or `""`) uses the rules embedded in +the binary (or `TILE57_S101_RULES` if set), so no on-disk catalogue is required; a +path overrides with an on-disk catalogue. The streaming open uses the extern types +`tile57.ChartMeta` (bbox + `cscl`), `tile57.ChartBytes` (a chart's base + updates, +ownership transferred to the library), and `tile57.ChartReadFn` (the reader +callback); multi-chart input for `openCharts` is `tile57.ChartInput`. + +## Render surfaces + +Every render surface draws the SAME portrayal of the SAME view — centre + +fractional zoom + pixel size — replaying the archive's baked tiles through the +S-52 pixel path: one scene across every covering tile, labels decluttered over the +whole canvas, catalogue symbols replayed as vectors. They differ only in what they +hand back. `palette` is `tile57.render.resolve.PaletteId` (`.day` / `.dusk` / +`.night`); `settings` is `*const tile57.Mariner` — the same S-52 display-options +struct the [style builder](./style.md) takes (aliased as `render.resolve.Settings`), +evaluated at render time. The composed twins live on the [compositor](./compose.md). + +### Finished view (PNG / PDF) + +```zig +// Render a VIEW through the native S-52 pixel path: real portrayal, vector +// symbols, labels + declutter over the whole canvas. `output` selects PNG / PDF / +// a callback canvas (cb_table). Returns gpa-owned bytes — free with +// tile57.freeBytes. Cell-backed sources only (a baked archive replays its tiles). +pub fn Chart.renderView(self: *Chart, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, + palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, + output: render.pixel.Output, cb_table: ?*const render.cb_canvas.CCanvas) ![]u8 +``` + +### Host surface (vector callbacks) + +```zig +// Portray the view once and drive world-space surface callbacks — the vector twin +// a GPU host tessellates once and transforms per frame, so symbols and text stay a +// constant on-screen size while the view moves. rotation_rad is the view rotation +// (radians CW; 0 = north-up), applied by the host. +pub fn Chart.renderSurfaceView(self: *Chart, lon: f64, lat: f64, zoom: f64, rotation_rad: f64, + w: u32, h: u32, palette: render.resolve.PaletteId, + settings: *const render.resolve.Settings, + cb: *const render.vector.CSurface) !void +``` + +`cb` is a `tile57.render.vector.CSurface` vtable — the same surface the C ABI's +`tile57_surface_cb` wraps. The callback semantics (world coordinates, SCAMIN and +display-category gates, `map`/`viewport` rotation alignment) and the S-52 +**paint order** obligation are documented once on the C API page: +[Host surface](../api/render.md#host-surface-vector-callbacks) and +[Paint order](../api/render.md#paint-order). + +### Draw-ready GPU scene + +For a GPU host, `renderGpuScene` hands geometry back **already triangulated, already +in paint order, and already split into ranges that each draw with one pipeline** — so +the host owns no tessellator and no copy of the S-52 ordering rules. Upload the +buffers, then walk the ranges in order. + +```zig +// Portray the whole view into draw-ready GPU buffers. No rotation parameter, +// deliberately: geometry stays north-up in world space and the host applies the +// view rotation, so a course-up view that turns continuously never rebuilds. +// pixel_ratio is the display density (1, 2, …), matching the sprite atlas you +// upload. The result owns its arena; release it with GpuScene.deinit. +pub fn Chart.renderGpuScene(self: *Chart, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, + palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, + pixel_ratio: f64) !*GpuScene +``` + +The returned `*tile57.GpuScene` owns an arena; call `.deinit()` when you have +finished uploading. Its buffers live on `.scene`, a `tile57.render.gpu.Scene`: + +```zig +const gpu = tile57.render.gpu; // Scene / Vertex / Quad / Range / NO_PATTERN + +var gs = try chart.renderGpuScene(-76.48, 38.974, 13.5, 1600, 1200, .day, &settings, 2.0); +defer gs.deinit(); + +const s: gpu.Scene = gs.scene; +// Upload once — plus the sprite + SDF-glyph atlas textures you baked from +// tile57.sprite (see Portrayal assets). +uploadVertices(s.vertices); // []const gpu.Vertex — world (x,y) + screen-space (ox,oy) + scamin/disp_cat + color + depth +uploadIndices(s.indices); // []const u32 +uploadQuads(s.quads); // []const gpu.Quad — sprite + SDF-glyph vertices, 6 per quad + +// Draw every range IN ORDER (they arrive sorted by paint_key — drawing them in +// sequence is all it takes to honour S-52 paint order): +for (s.ranges) |r| { // []const gpu.Range + switch (r.prim) { + .triangles => drawIndexed( + r.first, r.count, r.color, + if (r.pattern != gpu.NO_PATTERN) s.patterns[r.pattern] else null, + ), + .quads => drawQuads(r.first, r.count, r.atlas), // r.atlas: .sprite / .glyph / .glyph_bold / .glyph_italic + } +} +``` + +Each vertex splits its **world** position (which the camera transforms) from a +**screen-space** reference-pixel offset `(ox, oy)`, so a symbol or label holds a +constant on-screen size while its anchor rides the chart — no re-tessellation on +zoom. Per-vertex `scamin` / `disp_cat` are the visibility gates the host applies in +the shader (rather than rebuilding per zoom), and a range whose `flags` bit 0 is set +is OPAQUE — eligible for a front-to-back depth-tested pass (the per-vertex `depth` +encodes paint order: later paint = smaller = closer). The remaining field-by-field +semantics — pattern tiling, the upright fix on tangent-rotated text, `map_align` +rotation — are identical to the C ABI and documented once on the C API page: +[Draw-ready GPU scenes](../api/render.md#draw-ready-gpu-scenes-batched-buffers). + +The composed twin — a whole chart library into one scene — is +[`tile57.compose.renderGpuScene`](./compose.md#composed-render-surfaces). + +### Terminal (ASCII) + +```zig +// The same view as a terminal text grid (Unicode; ansi = colour escapes). +pub fn Chart.renderAscii(self: *Chart, lon: f64, lat: f64, zoom: f64, cols: u32, rows: u32, + palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, + ansi: bool) ![]u8 +``` + +## Query the features under a point + +```zig +// The S-52 cursor pick (§10.8): replay the finest tile covering (lon,lat) and +// report each feature the point falls in — class + S-57 attribute JSON + source +// cell — via cb. Passing the view zoom applies the same SCAMIN cull the renderer +// does, so it returns only the features actually displayed at that zoom. +pub fn Chart.queryPoint(self: *Chart, lon: f64, lat: f64, zoom: f64, + cb: *const render.query.QueryCb) !void +``` + +## Metadata + extraction + +All JSON accessors are `gpa`-owned; free with `tile57.freeBytes`. + +```zig +pub fn Chart.chartsJson(self: *Chart) !?[]u8 // per-cell metadata JSON array (the `cells` CLI) +pub fn Chart.featuresJson(self: *Chart, classes: []const u8) !?[]u8 // GeoJSON for comma-separated classes +pub fn Chart.coverage(self: *const Chart) ?[]const []const []const LonLat // M_COVR data-coverage rings +pub fn Chart.bounds(self: *Chart) ?[4]f64 // geographic extent [w, s, e, n], if known +pub fn Chart.anchor(self: *Chart) ?struct { lat, lon, zoom } // a good initial camera on real data +pub fn Chart.bands(self: *Chart) u32 // bitmask of navigational bands present +pub fn Chart.zoomRange(self: *Chart) struct { min: u8, max: u8 } // the zoom span the chart covers +pub fn Chart.nativeScale(self: *const Chart) i32 // compilation scale 1:N (0 = unknown) +pub fn Chart.scamin(self: *Chart) ![]u32 // the distinct SCAMIN denominators (the live manifest) +pub fn Chart.tileType(self: *Chart) pmtiles.TileType // the tile encoding (MVT / MLT) +pub fn Chart.format(self: *Chart) Format // the resolved backend (after .auto) +pub fn Chart.pmtilesReader(self: *Chart) ?*pmtiles.Reader // raw per-archive tiles — the primitive + // for writing your own compositor +pub fn Chart.decodedCoverage(self: *const Chart) ?ChartCoverage // decoded coverage; what the compositor borrows +pub fn Chart.deinit(self: *Chart) void // release the chart and its cached tiles +``` + +`pmtilesReader()` + `decodedCoverage()` are the two handles the built-in +[compositor](./compose.md) borrows when you compose over already-open charts. diff --git a/docs/docs/zig/style.md b/docs/docs/zig/style.md new file mode 100644 index 00000000..23fe1c61 --- /dev/null +++ b/docs/docs/zig/style.md @@ -0,0 +1,64 @@ +--- +title: MapLibre style +slug: /zig-api/style +--- + +# Build a MapLibre style + +`tile57.style` turns a MapLibre style template + the mariner's S-52 display +settings + the S-52 colour tables into a concrete style JSON, in pure Zig (no +libc/fs — you read the catalogue bytes and pass them in). The style and the tiles +come from the same S-101 catalogue, so the two stay in sync: RGB lives only in the +colour tables, and the tiles carry colour *tokens*. The same `tile57.Mariner` +settings configure the [render surfaces](./render.md#render-surfaces). + +```zig +// Regenerate every layer from a template + the mariner settings baked in. The +// template carries only the host's source config (sprite / glyphs / chart +// tiles+zoom); this lifts that out and rebuilds the layer set. Returns +// alloc-owned bytes; a bad template or unusable colortables returns the template +// unchanged. now_unix drives date-dependent features. +pub fn style.buildFromTemplate(alloc: std.mem.Allocator, template_json: []const u8, + m: *const style.mariner.Settings, colortables_json: []const u8, + enabled_bands: ?[]const i32, now_unix: i64) ![]u8 + +// Same, threading a SCAMIN manifest (the distinct denominators present + a +// representative latitude) so the runtime style gets the SAME per-value +// native-minzoom bucket layers the offline bundle does. Empty scamin == plain +// buildFromTemplate. +pub fn style.buildFromTemplateScamin(alloc: std.mem.Allocator, template_json: []const u8, + m: *const style.mariner.Settings, colortables_json: []const u8, + enabled_bands: ?[]const i32, now_unix: i64, + scamin: []const u32, scamin_lat: f64) ![]u8 + +// Build a style.json directly from Options (the lower-level entry the two builders +// call). style.Options is the full input set. +pub fn style.json(alloc: std.mem.Allocator, opts: style.Options) ![]u8 + +// The minimal MapLibre style-mutation ops to turn old_json into new_json — for +// flicker-free mariner toggles (retint / refilter without a full reload). +pub fn style.diff(alloc: std.mem.Allocator, old_json: []const u8, new_json: []const u8) ![]u8 +``` + +`tile57.Mariner` (`style.mariner.Settings`) is the S-52 mariner display-options +struct — colour scheme, safety/shallow/deep contours, category and text switches, +size scales, SCAMIN and date-dependent gates. `style.mariner` also holds the +builders that encode those settings as MapLibre expressions. + +## Colour tables + line styles + +The colour tables and line styles the style references are produced from the S-101 +catalogue XML, also in pure Zig: + +```zig +// Parse ColorProfiles/colorProfile.xml -> colortables.json: +// {"day":{TOKEN:"#rrggbb",…},"dusk":{…},"night":{…}}. Tokens sorted per palette. +pub fn style.colorTablesJson(alloc: std.mem.Allocator, xml: []const u8) ![]u8 + +// Parse the LineStyles/*.xml sources -> linestyles.json (period, dash array, pen +// colour token + width, placed symbols). ids sorted; pure-symbol styles dropped. +pub fn style.linestylesJson(alloc: std.mem.Allocator, srcs: []const style.LineStyleSrc) ![]u8 +``` + +The sprite + pattern atlases the style references come from `tile57.sprite` (see +[Portrayal assets](./assets.md)). diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 84a03ec4..5e0b4a77 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -50,6 +50,11 @@ const config = { navbar: { title: 'tile57', items: [ + { + to: '/contributing', + label: 'Contributing', + position: 'right', + }, { href: 'https://github.com/beetlebugorg/chartplotter-go', label: 'chartplotter-go', diff --git a/docs/sidebars.js b/docs/sidebars.js index 33ed45ed..375b239a 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -7,12 +7,38 @@ const sidebars = { 'installation', 'getting-started', 'cli', - 'zig-api', - 'c-api', + { + type: 'category', + label: 'Zig API', + link: {type: 'doc', id: 'zig-api'}, + items: [ + 'zig/errors-lifecycle', + 'zig/bake', + 'zig/render', + 'zig/compose', + 'zig/assets', + 'zig/style', + 'zig/low-level', + ], + }, + { + type: 'category', + label: 'C API', + link: {type: 'doc', id: 'c-api'}, + items: [ + 'api/errors-lifecycle', + 'api/bake', + 'api/render', + 'api/compose', + 'api/assets', + 'api/style', + ], + }, 'architecture', 'rendering', 'tile-schema', 'limitations', + 'contributing', ], }; diff --git a/include/tile57.h b/include/tile57.h index 558c41ac..86fbe23b 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -741,6 +741,14 @@ typedef struct { uint8_t disp_cat; /* S-52 display category: 0 base, 1 standard, 2 other */ uint8_t map_align; /* nonzero = chart-relative: a rotated view must turn it */ uint8_t _pad[2]; + uint8_t color[4]; /* straight-alpha RGBA, per-vertex — contiguous ranges of + * different colours can merge into ONE draw; the range's + * color field is advisory metadata now */ + float depth; /* paint-order depth in (0,1): LATER paint = SMALLER + * (closer). Draw OPAQUE ranges (range flags bit 0) + * front-to-back, depth test LESS + write; then everything + * else in paint order, test LESS, no write. 0 = always + * passes. */ } tile57_gpu_vertex; /* What a range draws. The host picks a pipeline from this and nothing more — @@ -802,6 +810,9 @@ typedef struct { uint8_t map_align; uint8_t flip; /* 1 => flip the run 180° to stay upright (see above) */ uint8_t tangent_q; /* run angle over a full turn, tangent_q/256*2π */ + float depth; /* paint-order depth, same contract as the vertex's — + * linestyle-brick quads ride LOW paint bands and must + * lose to opaque fills above them */ } tile57_gpu_quad; /* One area-fill pattern cell: RGBA8, w * h * 4 bytes, row-major. It is @@ -843,7 +854,8 @@ typedef struct { uint8_t kind; /* tile57_gpu_kind */ uint8_t prim; /* tile57_gpu_prim */ uint8_t atlas; /* tile57_gpu_atlas (QUADS only) */ - uint8_t _pad; + uint8_t flags; /* bit 0: OPAQUE (pattern-less triangles, all alpha 255) — + * eligible for the front-to-back depth-tested pass */ } tile57_gpu_range; /* Draw-ready buffers for one view. Every pointer is BORROWED and stays valid @@ -1249,6 +1261,16 @@ tile57_status tile57_style_template(tile57_scheme scheme, const char *source_til * baking charts from worker threads, so those globals are fully populated first and * concurrent bake/render is race-free (the allocator is thread-safe and the portrayal * context is thread-local). Cheap and safe to call more than once. */ +/* Drop the engine's reclaimable caches (per-tile GPU geometry pool). For a + * host answering an OS memory warning. Call with NO scene build in flight. */ +void tile57_trim_caches(void); + +/* GPU-scene ABI self-description: sizeof(tile57_gpu_vertex) | + * sizeof(tile57_gpu_quad)<<8 | sizeof(tile57_gpu_range)<<16. Compare against + * your compiled sizeofs at startup — a header/library skew otherwise renders + * garbage (sheared vertex stream), not an error. */ +uint32_t tile57_abi_gpu_layout(void); + void tile57_warmup(void); /* Free ANY buffer the engine returned (tiles, style JSON, the scamin array, diff --git a/src/bundle.zig b/src/bundle.zig index 41fec901..86a7ad65 100644 --- a/src/bundle.zig +++ b/src/bundle.zig @@ -245,9 +245,10 @@ pub fn emitSpriteMln(io: std.Io, a: std.mem.Allocator, catalog_dir: []const u8, } // Read + parse one cell (base .000 + sequential updates) from `dir`; null on -// any failure. The parsed cell allocates from smp_allocator (thread-safe). +// any failure. The parsed cell allocates from c_allocator (thread-safe, and +// returns freed pages to the OS between cells — see chart.zig's gpa note). fn readParseCell(io: std.Io, dir: std.Io.Dir, bpath: []const u8) ?engine.s57.Cell { - const gpa = std.heap.smp_allocator; + const gpa = std.heap.c_allocator; const base = dir.readFileAlloc(io, bpath, gpa, .unlimited) catch return null; defer gpa.free(base); if (base.len == 0) return null; diff --git a/src/capi.zig b/src/capi.zig index 5a239675..ca5704bf 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -20,10 +20,11 @@ const errors = @import("errors"); // the engine error taxonomy + describe() // catalogue. Symbols/linestyles are NOT embedded here (only the bake exe needs them). const colorprofile_registry = @import("colorprofile_registry"); -// smp_allocator (Zig's fast thread-safe GPA), not page_allocator: the live -// tile/chart path makes many small, short-lived allocations; page_allocator -// would mmap each one. Matches the bake CLI's allocator choice. -const gpa = std.heap.smp_allocator; +// c_allocator, not smp_allocator: smp never returns freed slabs to the OS, so +// the host app's footprint sticks at the worst transient peak forever. libc +// malloc unmaps large blocks on free and Instruments can see it. Hot paths +// allocate through arenas, so per-alloc speed is not the bottleneck. +const gpa = std.heap.c_allocator; const Chart = chart.Chart; // Wall-clock time for "today" date resolution in tile57_style_build. Zig 0.16 @@ -1182,6 +1183,7 @@ export fn tile57_compose_labels( export fn tile57_compose_query(handle: ?*compose.ComposeSource, lon: f64, lat: f64, zoom: f64, cb: ?*const CQueryCb, err: ?*CError) callconv(.c) c_int { const src = handle orelse return failWith(err, .badarg, "compose handle must not be null"); const cbp = cb orelse return failWith(err, .badarg, "cb must not be null"); + src.explainPoint(gpa, lon, lat, zoom); // every tap logs the serving story of that spot chart.composeQueryPoint(src, lon, lat, zoom, cbp) catch |e| return fail(err, e); return OK; } @@ -1713,7 +1715,32 @@ export fn tile57_mariner_defaults(cm: ?*CMariner) callconv(.c) void { /// Populate the process-global read-only registries (S-100 catalogue + linestyles) on /// the calling thread. Call ONCE on the main thread before opening/baking charts from /// worker threads, so concurrent bake/render is race-free. See tile57.h. +var g_warmup_logged = false; +/// Drop the engine's reclaimable caches (the per-tile GPU geometry pool — +/// the largest). For a host answering an OS memory warning. MUST be called +/// with no scene build in flight (the caches feed the build in progress). +export fn tile57_trim_caches() callconv(.c) void { + chart.geomDropAll(); +} + +/// GPU-scene ABI self-description: sizeof(vertex) | sizeof(quad)<<8 | +/// sizeof(range)<<16. A host compiled against a NEWER tile57.h than the +/// library it links renders GARBAGE (a 28-byte shader stride over a 24-byte +/// stream shears every vertex after the first) — comparing this at open turns +/// silent shear into a loud refusal, and a host calling it against a library +/// too old to export it fails at LINK time, which is better still. +export fn tile57_abi_gpu_layout() callconv(.c) u32 { + const g = @import("render").gpu; + return @as(u32, @sizeOf(g.Vertex)) | (@as(u32, @sizeOf(g.Quad)) << 8) | (@as(u32, @sizeOf(g.Range)) << 16); +} + export fn tile57_warmup() callconv(.c) void { + if (!g_warmup_logged) { + g_warmup_logged = true; + // Which engine THIS process actually linked — the one line that settles + // every "is the app running the latest?" question at runtime. + std.debug.print("tile57 engine @ {s}\n", .{@import("buildinfo").commit}); + } chart.warmup(); } diff --git a/src/chart.zig b/src/chart.zig index 8762db72..0ffd9e7d 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -36,10 +36,13 @@ const style = @import("style"); // displayDenomZ (the physical display-scale for const cell_coverage = @import("coverage"); // per-cell M_COVR coverage embedded in archive metadata const compose_mod = @import("compose"); // the runtime compositor (compose-backed view renders) -// smp_allocator (Zig's fast thread-safe GPA), not page_allocator: the engine -// makes many small, short-lived allocations (tile cache, cell dupes, index -// lists); page_allocator would mmap each one. Matches the bake CLI + C ABI. -const gpa = std.heap.smp_allocator; +// c_allocator, not smp_allocator: smp's per-CPU slab freelists never return +// pages to the OS, so a long-lived host process's footprint ratchets up to the +// worst transient peak (compose bursts) and never recovers. libc malloc frees +// large blocks (arena chunks) back to the OS and is visible to Instruments. +// Hot-path allocation flows through arenas, so per-alloc speed is not the +// bottleneck. Matches the bake CLI + C ABI. +const gpa = std.heap.c_allocator; // The S-52 colour tables, parsed once per process from the embedded profile (see // Chart.viewColorsRef). Immutable after init — every chart shares these, so the @@ -628,12 +631,15 @@ fn attachEmbeddedCoverage(src: *Chart) void { gpa.destroy(ar); } }.f; + // Gunzip with gpa and free after decode: the JSON TEXT (bigger than the + // decoded rings) must not sit in the retained coverage arena as garbage. const json: []const u8 = switch (h.internal_compression) { .none => raw, - .gzip => gzip.decompress(a, raw) catch return drop(cov_arena), + .gzip => gzip.decompress(gpa, raw) catch return drop(cov_arena), else => return drop(cov_arena), }; - const cov = (cell_coverage.decodeFromMetadata(a, json) catch null) orelse return drop(cov_arena); + defer if (h.internal_compression == .gzip) gpa.free(json); + const cov = (cell_coverage.decodeFromMetadata(a, gpa, json) catch null) orelse return drop(cov_arena); if (cov.cscl == 0 and cov.cov1.len == 0) return drop(cov_arena); src.cell_cov = cov; src.coverage_arena = cov_arena; @@ -880,6 +886,7 @@ const BakeFileCtx = struct { rules_dir: ?[]const u8, io: std.Io, ok: []bool, + ms: []i64, // per-cell wall time — the bake profiles itself (slowest cells printed at the end) progress: BakeProgress, progress_ctx: ?*anyopaque, done: std.atomic.Value(u32), @@ -888,6 +895,11 @@ const BakeFileCtx = struct { }; fn bakeOneToFile(ctx: *BakeFileCtx, i: usize) void { + const t0 = std.Io.Clock.awake.now(ctx.io); + defer { + const t1 = std.Io.Clock.awake.now(ctx.io); + ctx.ms[i] = @intCast(@divTrunc(t1.nanoseconds - t0.nanoseconds, 1_000_000)); + } const arc = (bakeChartBytes(ctx.in_paths[i], ctx.rules_dir) catch null) orelse return; defer freeBytes(arc); std.Io.Dir.cwd().writeFile(ctx.io, .{ .sub_path = ctx.out_paths[i], .data = arc }) catch return; @@ -930,7 +942,10 @@ pub fn bakeChartsToFiles(io: std.Io, in_paths: []const []const u8, out_paths: [] const ok = gpa.alloc(bool, in_paths.len) catch return 0; defer gpa.free(ok); @memset(ok, false); - var ctx = BakeFileCtx{ .next = std.atomic.Value(usize).init(0), .in_paths = in_paths, .out_paths = out_paths, .rules_dir = rules_dir, .io = io, .ok = ok, .progress = progress, .progress_ctx = progress_ctx, .done = std.atomic.Value(u32).init(0), .cancel = std.atomic.Value(bool).init(false) }; + const cell_ms = gpa.alloc(i64, in_paths.len) catch return 0; + defer gpa.free(cell_ms); + @memset(cell_ms, 0); + var ctx = BakeFileCtx{ .next = std.atomic.Value(usize).init(0), .in_paths = in_paths, .out_paths = out_paths, .rules_dir = rules_dir, .io = io, .ok = ok, .ms = cell_ms, .progress = progress, .progress_ctx = progress_ctx, .done = std.atomic.Value(u32).init(0), .cancel = std.atomic.Value(bool).init(false) }; var n = @min(@max(workers, 1), in_paths.len); if (n > MAX_BAKE_WORKERS) n = MAX_BAKE_WORKERS; if (n <= 1) { @@ -946,6 +961,28 @@ pub fn bakeChartsToFiles(io: std.Io, in_paths: []const []const u8, out_paths: [] for (ok) |o| { if (o) count += 1; } + // The bake profiles itself: total per-cell work and the slowest cells, + // every run — 'the bake is slow' must never again need external tooling + // to answer WHERE. + { + var total: i64 = 0; + for (cell_ms) |m| total += m; + std.debug.print("bake profile: {d} cells, {d} ms cell-work total\n", .{ in_paths.len, total }); + var shown: usize = 0; + while (shown < 10) : (shown += 1) { + var best: usize = 0; + var best_ms: i64 = -1; + for (cell_ms, 0..) |m, mi| { + if (m > best_ms) { + best_ms = m; + best = mi; + } + } + if (best_ms <= 0) break; + std.debug.print(" slow cell: {d} ms {s}\n", .{ best_ms, in_paths[best] }); + cell_ms[best] = -1; + } + } return count; } @@ -1089,7 +1126,10 @@ fn buildGpuAtlases(a: std.mem.Allocator, ratio: f64) !struct { sprites: render.g // sprite atlas: reuse the same builder tile57_bake_sprite_mln does at the // SAME display ratio, so the cell rects are byte-for-byte the layout the // host's uploaded PNG carries (the normalized UVs must index that texture). - var atlas = try sprite.spriteMln(a, sym_srcs, fill_srcs, css_data, &[_][]const u8{}, ratio); + // Layout only: the scene consumer reads cells + dims, never the pixels — + // the full bake here (composite + zlib) was ~2/3 of the render path's + // cycles in a field profile whenever the shared atlases (re)built. + var atlas = try sprite.spriteMlnOpts(a, sym_srcs, fill_srcs, css_data, &[_][]const u8{}, ratio, false); var sprites = render.gpu.SpriteAtlas{ .width = atlas.width, .height = atlas.height, .ppm = @floatCast(sprite.px_per_unit * 100.0 * ratio) }; var cit = atlas.cells.iterator(); while (cit.next()) |e| { @@ -1116,12 +1156,23 @@ fn buildGpuAtlases(a: std.mem.Allocator, ratio: f64) !struct { sprites: render.g // NOT cached here: they declutter across the whole view every call (see the label // pass in renderGpuScene), so a name never repeats across a tile seam. const GeomKey = struct { handle: usize, z: u8, x: u32, y: u32 }; -const GeomEntry = struct { scene: *GpuScene, gen: u64 }; +const GeomEntry = struct { scene: *GpuScene, gen: u64, bytes: usize }; var g_geom: std.AutoHashMapUnmanaged(GeomKey, GeomEntry) = .empty; var g_geom_gen: u64 = 0; +var g_geom_bytes: usize = 0; +/// Entries at/after this generation belong to the walk in progress — its parts +/// still reference their arenas, so eviction never crosses it (set by +/// renderComposeGpuScene; engine calls are single-threaded per the contract). +var g_geom_floor: u64 = 0; var g_geom_hash: u64 = 0; var g_geom_hash_set = false; const GEOM_CACHE_MAX = 1024; +// The cache is bounded by BYTES as well as entries: 1024 tessellated tiles can +// be gigabytes, and on a memory-limited device (iOS jetsam) that grows the +// process to where big allocations FAIL — scenes stop assembling exactly on +// the widest views. 160 MB holds several views' worth of tiles; past it the +// LRU pays a re-portray instead of the process paying with its life. +const GEOM_CACHE_MAX_BYTES: usize = 160 << 20; /// Content hash of the geometry-affecting settings. A byte hash won't do — /// Settings has slice fields (whose pointers move per call) and floats (whose @@ -1155,10 +1206,41 @@ fn geomInvalidate(s: *const render.resolve.Settings) void { var it = g_geom.valueIterator(); while (it.next()) |e| e.scene.deinit(); g_geom.clearRetainingCapacity(); + g_geom_bytes = 0; g_geom_hash = hh; g_geom_hash_set = true; } +/// Drop EVERY cached tile: the memory-pressure valve. Called when a scene +/// assembly fails allocation — reclaiming the cache and re-portraying beats +/// a build that fails identically every frame forever. ONLY safe between +/// walks: a walk's parts reference cached arenas until assemble copies out. +pub fn geomDropAll() void { + var it = g_geom.valueIterator(); + while (it.next()) |e| e.scene.deinit(); + g_geom.clearRetainingCapacity(); + g_geom_bytes = 0; +} + +/// The MID-WALK pressure valve: drop every cached tile EXCEPT the walk in +/// progress's own (gen >= g_geom_floor) — those arenas are still referenced +/// by the walk's parts, and freeing them is a use-after-free in assemble +/// (crashed in memcpy on device). +pub fn geomDropCold() void { + var doomed = std.ArrayList(GeomKey).empty; + defer doomed.deinit(gpa); + var it = g_geom.iterator(); + while (it.next()) |kv| { + if (kv.value_ptr.gen < g_geom_floor) doomed.append(gpa, kv.key_ptr.*) catch {}; + } + for (doomed.items) |k| { + if (g_geom.fetchRemove(k)) |kv| { + g_geom_bytes -= @min(g_geom_bytes, kv.value.bytes); + kv.value.scene.deinit(); + } + } +} + /// Drop every cached tile belonging to a handle — called when it closes, so a /// later handle reusing the address never reads its geometry. pub fn geomDropHandle(handle: usize) void { @@ -1169,7 +1251,10 @@ pub fn geomDropHandle(handle: usize) void { if (kv.key_ptr.handle == handle) doomed.append(gpa, kv.key_ptr.*) catch {}; } for (doomed.items) |k| { - if (g_geom.fetchRemove(k)) |kv| kv.value.scene.deinit(); + if (g_geom.fetchRemove(k)) |kv| { + g_geom_bytes -= @min(g_geom_bytes, kv.value.bytes); + kv.value.scene.deinit(); + } } } @@ -1184,23 +1269,31 @@ fn geomGet(key: GeomKey) ?*GpuScene { fn geomPut(key: GeomKey, sc: *GpuScene) void { g_geom_gen += 1; - g_geom.put(gpa, key, .{ .scene = sc, .gen = g_geom_gen }) catch { + const bytes = sc.arena.queryCapacity(); + g_geom.put(gpa, key, .{ .scene = sc, .gen = g_geom_gen, .bytes = bytes }) catch { sc.deinit(); return; }; - if (g_geom.count() <= GEOM_CACHE_MAX) return; - // Evict the least-recently-used (linear scan; the map is bounded). - var oldest_key: ?GeomKey = null; - var oldest_gen: u64 = std.math.maxInt(u64); - var it = g_geom.iterator(); - while (it.next()) |kv| { - if (kv.value_ptr.gen < oldest_gen) { - oldest_gen = kv.value_ptr.gen; - oldest_key = kv.key_ptr.*; + g_geom_bytes += bytes; + // Evict least-recently-used until under BOTH bounds (linear scans; the map + // is bounded). Entries stored this generation are never evicted here — + // they are this walk's own tiles, still referenced by the caller. + while (g_geom.count() > GEOM_CACHE_MAX or g_geom_bytes > GEOM_CACHE_MAX_BYTES) { + var oldest_key: ?GeomKey = null; + var oldest_gen: u64 = std.math.maxInt(u64); + var it = g_geom.iterator(); + while (it.next()) |kv| { + if (kv.value_ptr.gen < oldest_gen) { + oldest_gen = kv.value_ptr.gen; + oldest_key = kv.key_ptr.*; + } } - } - if (oldest_key) |ok| { - if (g_geom.fetchRemove(ok)) |kv| kv.value.scene.deinit(); + if (oldest_gen >= g_geom_floor) break; // only this walk's own tiles remain + const doomed = oldest_key orelse break; + if (g_geom.fetchRemove(doomed)) |kv| { + g_geom_bytes -= @min(g_geom_bytes, kv.value.bytes); + kv.value.scene.deinit(); + } else break; } } @@ -1242,17 +1335,29 @@ fn sharedGpuAtlases(ratio: f64) SharedAtlases { break; }; aa.* = std.heap.ArenaAllocator.init(gpa); + const bt0 = std.Io.Clock.awake.now(std.Io.Threaded.global_single_threaded.io()); if (buildGpuAtlases(aa.allocator(), g_atlas_ratio)) |built| { + const bt1 = std.Io.Clock.awake.now(std.Io.Threaded.global_single_threaded.io()); + std.debug.print("gpu atlases built @ {d:.2}x in {d} ms\n", .{ g_atlas_ratio, @divTrunc(bt1.nanoseconds - bt0.nanoseconds, 1_000_000) }); g_atlas_sprites = built.sprites; g_atlas_glyphs = built.glyphs; g_atlas_glyphs_bold = built.glyphs_bold; g_atlas_glyphs_italic = built.glyphs_italic; g_atlas_ok = true; - } else |_| { + g_atlas_state.store(2, .release); + } else |err| { aa.deinit(); gpa.destroy(aa); + // A FAILED build must not latch: one transient OutOfMemory here + // used to null the atlases for the rest of the process — every + // symbol then TESSELLATES (the no-atlas fallback): black-blob + // symbols, quads=0, and 5-10x the vertices, whose memory + // pressure feeds the very OOM that started it. Reset to 0 so + // the next scene retries; report every failure. + std.debug.print("gpu atlases: build FAILED ({s}) — will retry next scene; symbols tessellate until then\n", .{@errorName(err)}); + geomDropCold(); // reclaim (walk-safe: never the in-flight walk's tiles) + g_atlas_state.store(0, .release); } - g_atlas_state.store(2, .release); break; } std.atomic.spinLoopHint(); @@ -1434,7 +1539,21 @@ pub fn renderComposeSurfaceView(src: *compose_mod.ComposeSource, lon: f64, lat: /// tile source so a host draws a chart LIBRARY without owning a scene. Result /// owns its arena (GpuScene.deinit). pub fn renderComposeGpuScene(src: *compose_mod.ComposeSource, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, pixel_ratio: f64) !*GpuScene { + // A failure here (the error set is allocation) is almost always the + // process squeezed by its own caches (a memory-limited device under + // jetsam pressure): reclaim the biggest pool and retry ONCE. Without + // this the host retries the identical failing build every frame, + // forever, displaying a stale band's scene — "cells at the wrong zooms". + return renderComposeGpuSceneInner(src, lon, lat, zoom, w, h, palette, settings, pixel_ratio) catch |err| { + std.debug.print("gpu scene: build failed ({s}) — dropping tile geometry cache and retrying\n", .{@errorName(err)}); + geomDropAll(); + return renderComposeGpuSceneInner(src, lon, lat, zoom, w, h, palette, settings, pixel_ratio); + }; +} + +fn renderComposeGpuSceneInner(src: *compose_mod.ComposeSource, lon: f64, lat: f64, zoom: f64, w: u32, h: u32, palette: render.resolve.PaletteId, settings: *const render.resolve.Settings, pixel_ratio: f64) !*GpuScene { geomInvalidate(settings); + g_geom_floor = g_geom_gen + 1; // eviction never touches this walk's tiles const out = try gpa.create(GpuScene); errdefer gpa.destroy(out); out.* = .{ .arena = std.heap.ArenaAllocator.init(gpa), .scene = undefined }; @@ -1448,21 +1567,59 @@ pub fn renderComposeGpuScene(src: *compose_mod.ComposeSource, lon: f64, lat: f64 const pt: f32 = @floatCast(256.0 * std.math.pow(f64, 2.0, zoom - @round(zoom))); var vt = scene.ViewTiles.init(lon, lat, zoom, w, h, pt); + // A tile that fails to build or store leaves a tile-shaped NODATA hole in + // the scene, so it must never be silent: count and name the failures. (A + // healthy build prints nothing.) + var failed: u32 = 0; + var total: u32 = 0; + var empty: u32 = 0; // tiles that contributed NO geometry this call + var fresh: u32 = 0; // tiles portrayed this call (the rest were cache hits) + var last_err: []const u8 = ""; + // Empty tiles are NEVER cached: a truly-empty (open ocean) tile rebuilds + // for the cost of one partition classify, and a TRANSIENTLY empty one — + // whatever emptied it — must not become a hole that sticks until eviction. + // They still contribute to THIS call, so their arenas live until after + // assemble copies out of them. + var ephemeral = std.ArrayList(*GpuScene).empty; + defer for (ephemeral.items) |e| e.deinit(); while (vt.next()) |t| { + total += 1; const key = GeomKey{ .handle = @intFromPtr(src), .z = t.z, .x = t.x, .y = t.y }; if (geomGet(key) == null) { - const built = renderComposeTileGpuScene(src, t.z, t.x, t.y, palette, settings, pixel_ratio) catch continue; - geomPut(key, built); + fresh += 1; + if (renderComposeTileGpuScene(src, t.z, t.x, t.y, palette, settings, pixel_ratio)) |built| { + if (built.scene.vertices.len == 0 and built.scene.quads.len == 0) { + empty += 1; + if (ephemeral.append(sa, built)) |_| { + parts.append(sa, built.scene) catch {}; + cands.appendSlice(sa, built.candidates) catch {}; + } else |_| built.deinit(); + continue; + } + geomPut(key, built); + } else |err| { + failed += 1; + last_err = @errorName(err); + continue; + } } if (geomGet(key)) |g| { + if (g.scene.vertices.len == 0 and g.scene.quads.len == 0) empty += 1; parts.append(sa, g.scene) catch {}; cands.appendSlice(sa, g.candidates) catch {}; + } else { + failed += 1; // built but could not be cached (geomPut freed it) + last_err = "CachePutFailed"; } } + if (failed > 0) std.debug.print("gpu scene z{d}: {d}/{d} tiles FAILED ({s}) — tile-shaped holes\n", .{ vt.z, failed, total, last_err }); + // Not necessarily wrong (open ocean beyond coverage IS empty), but the + // first thing to read when tile-shaped holes appear over charted ground. + if (empty > 0) std.debug.print("gpu scene z{d}: {d}/{d} tiles empty ({d} fresh)\n", .{ vt.z, empty, total, fresh }); parts.append(sa, try render.gpu.assembleLabels(sa, sa, cands.items, zoom, settings.ignore_scamin)) catch {}; - out.scene = try render.gpu.assemble(out.arena.allocator(), parts.items); + out.scene = try render.gpu.assemble(out.arena.allocator(), sa, parts.items); return out; } @@ -1492,12 +1649,32 @@ pub fn renderComposeTileGpuScene(src: *compose_mod.ComposeSource, z: u8, x: u32, gs.setTile(z, x, y); const surf = gs.asSurface(); try surf.beginScene(z); - if (src.tile(sa, z, x, y) catch null) |res| { + // A per-tile OutOfMemory reclaims the biggest pool and retries once — + // without this, coarse tiles vanished one by one on a memory-limited + // device while every counter upstream read healthy. + const tile_res = src.tile(sa, z, x, y) catch |err| blk: { + if (err == error.OutOfMemory) { + geomDropCold(); // NOT geomDropAll: the walk's own tiles are still referenced + break :blk src.tile(sa, z, x, y); + } + break :blk err; + }; + if (tile_res) |res| { if (res.tile) |bytes| { if (mlt.decode(sa, bytes)) |layers| { - scene.replayTile(sa, surf, layers) catch {}; - } else |_| {} + scene.replayTile(sa, surf, layers) catch |err| { + std.debug.print("TILE LOST z{d}/{d}/{d}: replay FAILED ({s}) after {d} served bytes\n", .{ z, x, y, @errorName(err), bytes.len }); + }; + } else |err| { + std.debug.print("TILE LOST z{d}/{d}/{d}: decode FAILED ({s}) on {d} served bytes\n", .{ z, x, y, @errorName(err), bytes.len }); + } + } else { + // Nothing served: say why — the owner with no tile, or charted + // ground the tier map gave to nobody. (True ocean stays silent.) + src.explainEmpty(z, x, y); } + } else |err| { + std.debug.print("TILE LOST z{d}/{d}/{d}: compose FAILED ({s})\n", .{ z, x, y, @errorName(err) }); } out.scene = try gs.build(out.arena.allocator()); out.candidates = try gs.takeCandidates(out.arena.allocator()); @@ -2274,7 +2451,7 @@ pub const Chart = struct { // re-shaping (that was cached per tile). parts.append(sa, try render.gpu.assembleLabels(sa, sa, cands.items, zoom, settings.ignore_scamin)) catch {}; - out.scene = try render.gpu.assemble(out.arena.allocator(), parts.items); + out.scene = try render.gpu.assemble(out.arena.allocator(), sa, parts.items); return out; } diff --git a/src/compose/compose.zig b/src/compose/compose.zig index 111bfe21..3d013276 100644 --- a/src/compose/compose.zig +++ b/src/compose/compose.zig @@ -49,21 +49,68 @@ pub fn toPlaneCells(a: std.mem.Allocator, loaded: []const LoadedCov) ![]geometry const cells = try a.alloc(geometry.plane.Cell, n); for (loaded, 0..) |lc, i| { - const out = try a.alloc(geometry.plane.Poly, lc.coverage.len); - for (lc.coverage, 0..) |feat, fi| { - const rings = try a.alloc([]const geometry.plane.Pt, feat.len); + var out = std.ArrayList(geometry.plane.Poly).empty; + for (lc.coverage) |feat| { + const rings = try a.alloc([]geometry.plane.Pt, feat.len); for (feat, 0..) |ring, ri| { const pts = try a.alloc(geometry.plane.Pt, ring.len); for (ring, 0..) |p, pi| pts[pi] = .{ .x = p.lon_e7, .y = p.lat_e7 }; + // UNWRAP: an antimeridian-crossing ring jumps ±360° between + // neighbours; make longitudes continuous so the polygon is a + // polygon, not a world-spanning accident. + if (pts.len > 1) { + var prev = pts[0].x; + for (pts[1..]) |*q| { + var x = q.x; + while (x - prev > 1_800_000_000) x -= 3_600_000_000; + while (prev - x > 1_800_000_000) x += 3_600_000_000; + q.x = x; + prev = x; + } + } rings[ri] = pts; } - out[fi] = rings; + // SPLIT at ±180°: a flat plane has no wraparound, so a cell whose + // (unwrapped) coverage leaves [-180,180] is cut into per-world-copy + // parts, each shifted back into range. Without this the flat + // even-odd face of a Pacific antimeridian cell OWNS bands of ground + // across the whole world — stripes of stolen, unserveable tiles. + var min_x: i64 = std.math.maxInt(i64); + var max_x: i64 = std.math.minInt(i64); + for (rings) |ring| for (ring) |p| { + min_x = @min(min_x, p.x); + max_x = @max(max_x, p.x); + }; + const HALF: i64 = 1_800_000_000; + const FULL: i64 = 3_600_000_000; + if (min_x >= -HALF and max_x <= HALF) { + try out.append(a, rings); + } else { + var win: i64 = -1; + while (win <= 1) : (win += 1) { + const w0 = -HALF + win * FULL; + const w1 = HALF + win * FULL; + if (max_x <= w0 or min_x >= w1) continue; + const rect = [_]geometry.plane.Pt{ + .{ .x = w0, .y = -900_000_000 }, .{ .x = w1, .y = -900_000_000 }, + .{ .x = w1, .y = 900_000_000 }, .{ .x = w0, .y = 900_000_000 }, + .{ .x = w0, .y = -900_000_000 }, + }; + const rect_rings = [_][]const geometry.plane.Pt{&rect}; + const part = geometry.boolean.compute(a, rings, &rect_rings, .intersect) catch continue; + if (part.len == 0) continue; + for (part) |ring| for (ring) |*p| { + p.x -= win * FULL; + }; + try out.append(a, part); + } + } } cells[i] = .{ .cscl = lc.cscl, .band_floor = band.bandZooms(band.bandOf(lc.cscl)).min, .order = order[i], - .cov1 = out, + .cov1 = try out.toOwnedSlice(a), .light_bbox = if (lc.light_reach) |lr| lr.bbox else null, .light_range_m = if (lc.light_reach) |lr| lr.range_m else 0, }; @@ -118,6 +165,9 @@ pub fn worldAxisToTile(w: f64, scale: f64) u32 { const N_COMPOSE_LAYERS = mvt.VECTOR_LAYERS.len; +/// explainEmpty print budget (process-wide) — see ComposeSource.explainEmpty. +var g_explain_count: usize = 0; + // The tile-index cover (nw..se) of an owner face's lon/lat bbox at zoom `scale = 1< |sv| .{ .string = try a.dupe(u8, sv) }, + else => p.value, + }, + }; + return out; +} + +/// One cross-band fill contribution: cell `ci`'s features, clipped to `region` +/// (the ground the finer bands left bare within one tile), served with deep +/// overscale. Regions are exact-integer boolean results, so the fill can never +/// double-draw over finer-band ground. +const ExtraFill = struct { ci: u32, region: []const []const geometry.plane.Pt, deep: bool }; + +fn composeSeamTile(ta: std.mem.Allocator, part: *const geometry.partition.Partition, readers: []const *pmtiles.Reader, contribs: []const ExtraFill, reach_cells: []const u32, z: u8, tx: u32, ty: u32) !?[]u8 { const compose = clip; var buckets: [N_COMPOSE_LAYERS]std.ArrayList(mvt.Feature) = undefined; for (&buckets) |*b| b.* = std.ArrayList(mvt.Feature).empty; - for (slots) |slot| { - const face = map.faces[slot]; - const ci = face.index; - const layers = (try ownerTile(ta, readers[ci], part.cells[ci].cscl, z, tx, ty)) orelse continue; - const face_px = try compose.projectFace(ta, face.owned, z, tx, ty); + // EVERY contribution arrives with its region already rect-clipped to the + // tile, so projection, clipping and memory here are bounded by the TILE — + // never by the face (whole-cell faces at coarse tiers are hundreds of + // thousands of points; projecting them per tile made per-tile arenas grow + // by hundreds of MB, whose doubling requests FAILED on a memory-limited + // device: the field's 'compose FAILED (OutOfMemory)'). + // Each contributor's DECODED tile lives only for its own clip: decode into + // a per-contributor sub-arena, deep-dupe the (borrowed) properties of the + // clipped survivors into `ta`, reset. Holding every contributor's decode + // until encode peaked at 2.5 GB on a many-cell coarse tile — a guaranteed + // per-tile OutOfMemory on a memory-limited device, forever, for exactly + // those tiles. + var sub = std.heap.ArenaAllocator.init(std.heap.c_allocator); + defer sub.deinit(); + for (contribs) |ex| { + // EVERYTHING transient for this contributor — its decoded tile, the + // face projection, and every feature clip's boolean intermediates — + // lives in the reset-per-contributor scratch; only the clipped + // SURVIVORS are copied into the tile arena. Clip intermediates + // accumulating across thousands of features were the last + // hundreds-of-MB spike that still OOM'd the fattest coarse tiles on + // a memory-limited device. + _ = sub.reset(.retain_capacity); + const sa2 = sub.allocator(); + const layers = (try ownerTile(sa2, readers[ex.ci], part.cells[ex.ci].cscl, z, tx, ty, ex.deep)) orelse continue; + const face_px = try compose.projectFace(sa2, ex.region, z, tx, ty); if (face_px.len == 0) continue; for (layers) |layer| { const li = layerIndex(layer.name) orelse continue; - for (layer.features) |feat| try compose.clipFeatureToFace(ta, &buckets[li], feat, face_px); + var tmpb = std.ArrayList(mvt.Feature).empty; + for (layer.features) |feat| try compose.clipFeatureToFace(sa2, &tmpb, feat, face_px); + for (tmpb.items) |f| try buckets[li].append(ta, try dupeFeature(ta, f)); } } // Reach-ring cells: no owned ground in this tile, but their light sector @@ -185,14 +294,15 @@ fn composeSeamTile(ta: std.mem.Allocator, part: *const geometry.partition.Partit // clipFeatureToFace exception, minus a face. Everything else in the tile // (ground the cell doesn't own here) stays with its owners. for (reach_cells) |ci| { - const layers = (try ownerTile(ta, readers[ci], part.cells[ci].cscl, z, tx, ty)) orelse continue; + _ = sub.reset(.retain_capacity); + const layers = (try ownerTile(sub.allocator(), readers[ci], part.cells[ci].cscl, z, tx, ty, false)) orelse continue; for (layers) |layer| { const li = layerIndex(layer.name) orelse continue; for (layer.features) |feat| { if (feat.geom_type != .linestring or !compose.isLightFigure(feat)) continue; const parts = try ta.alloc([]const mvt.Point, feat.parts.len); for (feat.parts, 0..) |p, i| parts[i] = try ta.dupe(mvt.Point, p); - try buckets[li].append(ta, .{ .geom_type = .linestring, .parts = parts, .properties = feat.properties }); + try buckets[li].append(ta, .{ .geom_type = .linestring, .parts = parts, .properties = try dupeProps(ta, feat.properties) }); } } } @@ -213,8 +323,49 @@ fn composeSeamTile(ta: std.mem.Allocator, part: *const geometry.partition.Partit /// server wants — the HTTP layer gzips on the wire). gpa-owned; null if no cell owns this tile. This /// is the runtime compositor: with the partition loaded once, serving a tile is a classify plus /// either one memcpy/decompress or one decode/clip/encode, not a whole-district pass. +/// Sutherland–Hodgman rect clip of a ring bag: cuts boolean operands to tile +/// size in LINEAR time, so the cross-band fill's cost per tile is bounded by +/// the tile — never by the face. A tier-0 face is a whole coastal cell +/// (hundreds of thousands of points); exact booleans against it requested +/// oversized allocations that FAILED tile-by-tile on a memory-limited device, +/// at exactly the coarse zooms. +fn rectClipRings(a: std.mem.Allocator, rings: []const []const geometry.plane.Pt, box: geometry.plane.Box) ![][]geometry.plane.Pt { + var out = std.ArrayList([]geometry.plane.Pt).empty; + for (rings) |ring| { + var cur = std.ArrayList(geometry.plane.Pt).empty; + try cur.appendSlice(a, ring); + inline for (.{ + .{ .axis = 0, .lim = "min_x", .keep_ge = true }, + .{ .axis = 0, .lim = "max_x", .keep_ge = false }, + .{ .axis = 1, .lim = "min_y", .keep_ge = true }, + .{ .axis = 1, .lim = "max_y", .keep_ge = false }, + }) |cl| { + if (cur.items.len == 0) break; + const lim: i64 = @field(box, cl.lim); + var nxt = std.ArrayList(geometry.plane.Pt).empty; + const npts = cur.items.len; + for (cur.items, 0..) |p, k| { + const q = cur.items[(k + 1) % npts]; + const pv: i64 = if (cl.axis == 0) p.x else p.y; + const qv: i64 = if (cl.axis == 0) q.x else q.y; + const pin = if (cl.keep_ge) pv >= lim else pv <= lim; + const qin = if (cl.keep_ge) qv >= lim else qv <= lim; + if (pin) try nxt.append(a, p); + if (pin != qin) { + const t = @as(f64, @floatFromInt(lim - pv)) / @as(f64, @floatFromInt(qv - pv)); + const ox: i64 = if (cl.axis == 0) lim else p.x + @as(i64, @intFromFloat(@round(t * @as(f64, @floatFromInt(q.x - p.x))))); + const oy: i64 = if (cl.axis == 1) lim else p.y + @as(i64, @intFromFloat(@round(t * @as(f64, @floatFromInt(q.y - p.y))))); + try nxt.append(a, .{ .x = ox, .y = oy }); + } + } + cur = nxt; + } + if (cur.items.len >= 3) try out.append(a, try cur.toOwnedSlice(a)); + } + return out.toOwnedSlice(a); +} + pub fn composeTile(gpa: std.mem.Allocator, part: *const geometry.partition.Partition, readers: []const *pmtiles.Reader, z: u8, tx: u32, ty: u32, want_gzip: bool) !TileResult { - const compose = clip; const map = part.mapForZoom(z) orelse return .{ .tile = null, .owned = false }; const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); @@ -229,30 +380,34 @@ pub fn composeTile(gpa: std.mem.Allocator, part: *const geometry.partition.Parti // Every other contributing owner (seam, or fully-owned-but-no-native) is collected in face order. // `owned` = at least one cell's coverage face covers this tile (the partition says it SHOULD // render here) — so a caller can tell a transient/erroneous empty from true empty ocean. + const dbg = std.c.getenv("TILE57_COMPOSE_DEBUG") != null; var owned = false; - var slots = std.ArrayList(u32).empty; + var contribs = std.ArrayList(ExtraFill).empty; var verbatim: ?usize = null; // cell index of the unique tile+buffer-owning cell - for (map.faces, 0..) |face, slot| { + const no_fill = std.c.getenv("TILE57_NO_FILL") != null; // measurement valve + const cb0 = tileClassifyBox(z, tx, ty); + for (map.faces) |face| { if (face.owned.len == 0) continue; const ci = face.index; const cscl = part.cells[ci].cscl; const bb = faceTileBBox(face, scale); if (tx < bb.tx0 or tx > bb.tx1 or ty < bb.ty0 or ty > bb.ty1) continue; - var grid = try geometry.plane.EdgeGrid.init(ta, face.owned, tileWidthE7(z)); - defer grid.deinit(); - const cls = grid.classify(tileClassifyBox(z, tx, ty)); - if (cls == .full) continue; // owns none of this tile + // Rect-clip the face to the tile FIRST: everything downstream — + // classify, projection, booleans, memory — is bounded by the tile, + // never by the face (whole-cell faces at coarse tiers OOM'd a + // memory-limited device tile by tile). + const clipped = rectClipRings(ta, face.owned, cb0) catch continue; + if (dbg) std.debug.print("cmp z{d}/{d}/{d} tier{d} ci{d} cscl{d} clippedRings={d} hasTile={}\n", .{ z, tx, ty, map.tier, ci, cscl, clipped.len, ownerHasTile(readers[ci], cscl, z, tx, ty) catch false }); + if (clipped.len == 0) continue; // owns none of this tile owned = true; if (!(try ownerHasTile(readers[ci], cscl, z, tx, ty))) continue; - if (cls == .empty) { // owns the whole tile: its face projection can't be empty + var grid = try geometry.plane.EdgeGrid.init(ta, clipped, tileWidthE7(z)); + defer grid.deinit(); + if (grid.classify(cb0) == .empty) { // owns the whole tile (buffer included) verbatim = ci; - try slots.append(ta, @intCast(slot)); - continue; } - const face_px = try compose.projectFace(ta, face.owned, z, tx, ty); - if (face_px.len == 0) continue; - try slots.append(ta, @intCast(slot)); + try contribs.append(ta, .{ .ci = @intCast(ci), .region = clipped, .deep = false }); } // Reach ring (spec §2.3, the cross-TILE half): a cell owning ground at this @@ -266,7 +421,7 @@ pub fn composeTile(gpa: std.mem.Allocator, part: *const geometry.partition.Parti { var contributed = try ta.alloc(bool, part.cells.len); @memset(contributed, false); - for (slots.items) |slot| contributed[map.faces[slot].index] = true; + for (contribs.items) |c| contributed[c.ci] = true; var seen = try ta.alloc(bool, part.cells.len); @memset(seen, false); for (map.faces) |face| { @@ -295,9 +450,95 @@ pub fn composeTile(gpa: std.mem.Allocator, part: *const geometry.partition.Parti if (try readers[ci].getCompressed(z, tx, ty)) |blob| return .{ .tile = try gpa.dupe(u8, blob), .owned = true }; } else if (try readers[ci].getTile(ta, z, tx, ty)) |raw| return .{ .tile = try gpa.dupe(u8, raw), .owned = true }; }; - if (slots.items.len == 0 and reach.items.len == 0) return .{ .tile = null, .owned = owned }; + // Cross-band fill (partial OR whole-tile): whatever ground the governing + // band leaves bare in THIS tile composes from coarser bands, clipped to + // exactly the bare region via the exact integer booleans — so the fill can + // never double-draw over finer-band ground — and served with deep + // overscale (the paper-chart / ECDIS behaviour, and what the partition + // docs promise: "the ground is owned by a coarser band, reached by + // querying that band's map"). + // (fill contributions append into `contribs` with deep=true) + // The whole residual computation lives in a throwaway arena: the boolean + // chain's intermediates over hundreds of contributors were ~100 MB per fat + // tile in the tile arena; only surviving fill regions are copied out. + var fill_arena = std.heap.ArenaAllocator.init(std.heap.c_allocator); + defer fill_arena.deinit(); + const fa = fill_arena.allocator(); + if (verbatim == null and !no_fill) fill: { + const cb = tileClassifyBox(z, tx, ty); + const rect = [_]geometry.plane.Pt{ + .{ .x = cb.min_x, .y = cb.min_y }, .{ .x = cb.max_x, .y = cb.min_y }, + .{ .x = cb.max_x, .y = cb.max_y }, .{ .x = cb.min_x, .y = cb.max_y }, + .{ .x = cb.min_x, .y = cb.min_y }, + }; + var residual: [][]geometry.plane.Pt = blk: { + const rings = try fa.alloc([]geometry.plane.Pt, 1); + rings[0] = try fa.dupe(geometry.plane.Pt, &rect); + break :blk rings; + }; + // Ping-pong compaction: the boolean chain's intermediates accumulate + // in the arena (hundreds of steps on a fat tile); every 16 steps the + // small surviving residual is copied into the drained side and the + // other resets, bounding the chain's peak to ~16 steps of scratch. + var fill_arena2 = std.heap.ArenaAllocator.init(std.heap.c_allocator); + defer fill_arena2.deinit(); + var arenas = [2]*std.heap.ArenaAllocator{ &fill_arena, &fill_arena2 }; + var cur_a: usize = 0; + var steps: usize = 0; + for (contribs.items) |c| { + residual = geometry.boolean.compute(arenas[cur_a].allocator(), residual, c.region, .diff) catch break :fill; + if (residual.len == 0) break :fill; // governing band covers the whole tile + steps += 1; + if (steps % 16 == 0) { + const other = 1 - cur_a; + const oa = arenas[other].allocator(); + const moved = oa.alloc([]geometry.plane.Pt, residual.len) catch break :fill; + for (residual, 0..) |ring, ri| moved[ri] = oa.dupe(geometry.plane.Pt, ring) catch break :fill; + _ = arenas[cur_a].reset(.retain_capacity); + residual = moved; + cur_a = other; + } + } + var mi: usize = 0; + while (mi < part.maps.len and &part.maps[mi] != map) mi += 1; + var ci_map = mi + 1; + while (ci_map < part.maps.len and residual.len > 0) : (ci_map += 1) { + for (part.maps[ci_map].faces) |face| { + if (residual.len == 0) break; + if (face.owned.len == 0) continue; + const ci = face.index; + const bb = faceTileBBox(face, scale); + if (tx < bb.tx0 or tx > bb.tx1 or ty < bb.ty0 or ty > bb.ty1) continue; + if (!(try ownerHasTileDeep(readers[ci], part.cells[ci].cscl, z, tx, ty, true))) continue; + const wa = arenas[cur_a].allocator(); + const clipped = rectClipRings(wa, face.owned, cb) catch continue; + if (clipped.len == 0) continue; + const region = geometry.boolean.compute(wa, clipped, residual, .intersect) catch continue; + if (region.len == 0) continue; + // Copy the surviving region OUT of the throwaway arenas. + const kept = try ta.alloc([]geometry.plane.Pt, region.len); + for (region, 0..) |ring, ri| kept[ri] = try ta.dupe(geometry.plane.Pt, ring); + try contribs.append(ta, .{ .ci = @intCast(ci), .region = kept, .deep = true }); + residual = geometry.boolean.compute(wa, residual, region, .diff) catch break; + steps += 1; + if (steps % 16 == 0) { + const other = 1 - cur_a; + const oa2 = arenas[other].allocator(); + const moved2 = oa2.alloc([]geometry.plane.Pt, residual.len) catch break; + for (residual, 0..) |ring, ri| moved2[ri] = oa2.dupe(geometry.plane.Pt, ring) catch break; + _ = arenas[cur_a].reset(.retain_capacity); + residual = moved2; + cur_a = other; + } + } + } + if (contribs.items.len > 0) owned = true; + } - const enc = (try composeSeamTile(ta, part, map, readers, slots.items, reach.items, z, tx, ty)) orelse return .{ .tile = null, .owned = owned }; + if (contribs.items.len == 0 and reach.items.len == 0) + return .{ .tile = null, .owned = owned }; + + const enc = (try composeSeamTile(ta, part, readers, contribs.items, reach.items, z, tx, ty)) orelse return .{ .tile = null, .owned = owned }; // want_gzip → match the archive's stored (gzipped) bytes; else hand back the raw MLT. const bytes = if (want_gzip) try pmtiles.StreamWriter.gzipTile(gpa, enc) else try gpa.dupe(u8, enc); return .{ .tile = bytes, .owned = true }; @@ -323,6 +564,8 @@ pub const ComposeSource = struct { // borrows them from the charts, which must outlive this source. owns_archives: bool = true, part: geometry.partition.Partition, + /// Cell names aligned with `readers` — diagnostics only (explainEmpty). + names: []const []const u8 = &.{}, /// False when the partition had to be BUILT (no sidecar, or one that no /// longer matches this cell set). The C layer uses it to refresh the cache /// on disk, so a stale sidecar heals itself instead of costing every open. @@ -347,6 +590,92 @@ pub const ComposeSource = struct { pub fn tile(self: *ComposeSource, gpa: std.mem.Allocator, z: u8, tx: u32, ty: u32) !TileResult { return composeTile(gpa, &self.part, self.readers, z, tx, ty, false); } + + /// The complete serving story of ONE point at ONE zoom — printed on every + /// cursor pick, so tapping a hole in the chart explains the hole: the tile + /// address, the owner (or NOBODY) at the governing tier and every coarser + /// one, whether each owner's archive HAS the tile (normal and deep + /// overscale), and what composeTile actually returns. + pub fn explainPoint(self: *ComposeSource, gpa_: std.mem.Allocator, lon: f64, lat: f64, zoom: f64) void { + const zc = std.math.clamp(zoom, 0, 22); + const z: u8 = @intFromFloat(@round(zc)); + const w = @import("tiles").tile.lonLatToWorld(lon, lat); + const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); + const tx = worldAxisToTile(w[0], scale); + const ty = worldAxisToTile(w[1], scale); + std.debug.print("tap ({d:.5},{d:.5}) z{d:.2} -> tile {d}/{d}/{d}\n", .{ lon, lat, zoom, z, tx, ty }); + const px: i64 = @intFromFloat(@round(lon * 1e7)); + const py: i64 = @intFromFloat(@round(lat * 1e7)); + var gov = true; + for (self.part.maps) |*m| { + if (m.tier > z and gov) continue; // finer than governing: irrelevant + var owner: ?usize = null; + for (m.faces) |f| { + if (f.owned.len == 0) continue; + if (geometry.boolean.pointInEvenOdd(f.owned, px, py)) { + owner = f.index; + break; + } + } + if (owner) |ci| { + const has = ownerHasTileDeep(self.readers[ci], self.part.cells[ci].cscl, z, tx, ty, false) catch false; + const deep = ownerHasTileDeep(self.readers[ci], self.part.cells[ci].cscl, z, tx, ty, true) catch false; + const name = if (ci < self.names.len) self.names[ci] else "?"; + std.debug.print(" tier{d}{s}: owner {s} (1:{d}) hasTile={} deepOverscale={}\n", .{ m.tier, if (gov) " (governing)" else "", name, self.part.cells[ci].cscl, has, deep }); + } else { + std.debug.print(" tier{d}{s}: owner NOBODY\n", .{ m.tier, if (gov) " (governing)" else "" }); + } + gov = false; + } + const res = self.tile(gpa_, z, tx, ty) catch { + std.debug.print(" composeTile: ERROR\n", .{}); + return; + }; + if (res.tile) |b| { + std.debug.print(" composeTile: {d} bytes (owned={})\n", .{ b.len, res.owned }); + gpa_.free(b); + } else std.debug.print(" composeTile: NOTHING (owned={})\n", .{res.owned}); + } + + /// Name, for the log, every cell whose owned face covers tile (z,tx,ty) — + /// and whether its archive HAS the tile. Called by the scene builder for a + /// tile that is OWNED yet served nothing: the one line that says which cell + /// swallowed the ground and why (no tile at this zoom vs clipped-empty). + /// Capped so an ocean of legitimate empties cannot flood a session. + pub fn explainEmpty(self: *ComposeSource, z: u8, tx: u32, ty: u32) void { + if (g_explain_count >= 80) return; + const map = self.part.mapForZoom(z) orelse return; + const scale: f64 = @floatFromInt(@as(u64, 1) << @intCast(z)); + var spoke = false; + for (map.faces) |face| { + if (face.owned.len == 0) continue; + const ci = face.index; + const bb = faceTileBBox(face, scale); + if (tx < bb.tx0 or tx > bb.tx1 or ty < bb.ty0 or ty > bb.ty1) continue; + var grid = geometry.plane.EdgeGrid.init(self.gpa, face.owned, tileWidthE7(z)) catch continue; + defer grid.deinit(); + if (grid.classify(tileClassifyBox(z, tx, ty)) == .full) continue; // owns none of this tile + g_explain_count += 1; + spoke = true; + const has = ownerHasTile(self.readers[ci], self.part.cells[ci].cscl, z, tx, ty) catch false; + const name = if (ci < self.names.len) self.names[ci] else "?"; + std.debug.print("empty-owned z{d}/{d}/{d}: {s} (1:{d}, tier{d}) hasTile={}\n", .{ z, tx, ty, name, self.part.cells[ci].cscl, map.tier, has }); + } + if (spoke) return; + // NO face owns this tile at this tier — yet some cell's COVERAGE + // contains its centre: the tier map has a gap where the library has + // ground. That is a partition defect, and it must not be silent. + const tb = @import("tiles").tile.tileBoundsLonLat(z, tx, ty); + const cx: i64 = @intFromFloat(@round((tb[0] + tb[2]) * 0.5 * 1e7)); + const cy: i64 = @intFromFloat(@round((tb[1] + tb[3]) * 0.5 * 1e7)); + for (self.part.cells, 0..) |c, ci| { + if (!pointInCoverage(cx, cy, c.cov1)) continue; + g_explain_count += 1; + const name = if (ci < self.names.len) self.names[ci] else "?"; + std.debug.print("UNOWNED-GAP z{d}/{d}/{d} (tier{d}): covered by {s} (1:{d}) but owned by NOBODY\n", .{ z, tx, ty, map.tier, name, c.cscl }); + return; // one witness cell is enough + } + } /// Serialize the resident ownership partition to a sidecar blob (gpa-owned) a later open can /// load to skip the owned-face build. pub fn serializePartition(self: *ComposeSource, gpa: std.mem.Allocator) ![]u8 { @@ -437,12 +766,15 @@ fn openSourceFiles(io: std.Io, gpa: std.mem.Allocator, paths: []const []const u8 filemap.unmap(map); continue; }; - const meta = readMetaJson(a, rp) orelse { + // Metadata JSON text + parser scratch go through gpa and are freed + // here; only the decoded coverage lands in the compositor's arena. + const meta = readMetaJson(gpa, rp) orelse { rp.deinit(); filemap.unmap(map); continue; }; - const cc = (coverage.decodeFromMetadata(a, meta) catch null) orelse { + defer if (rp.header.internal_compression == .gzip) gpa.free(meta); + const cc = (coverage.decodeFromMetadata(a, gpa, meta) catch null) orelse { rp.deinit(); filemap.unmap(map); continue; @@ -556,17 +888,81 @@ fn cellOrderLt(x: LoadedCov, y: LoadedCov) bool { }; } -fn finishOpen( - gpa: std.mem.Allocator, - src: *ComposeSource, +/// True when archive `x` serves strictly better than `y` for the SAME cell +/// edition: wider zoom span first (an old bake predating fill-down/fill-up +/// carries a narrower window — the classic stale twin), then more addressed +/// tiles, then more tile bytes. All read from the PMTiles header, so the +/// choice is a property of the archives — never of discovery order. +fn servesBetter(x: *const pmtiles.Reader, y: *const pmtiles.Reader) bool { + if (x.header.min_zoom != y.header.min_zoom) return x.header.min_zoom < y.header.min_zoom; + if (x.header.max_zoom != y.header.max_zoom) return x.header.max_zoom > y.header.max_zoom; + if (x.header.num_addressed_tiles != y.header.num_addressed_tiles) return x.header.num_addressed_tiles > y.header.num_addressed_tiles; + return x.header.tile_data_length > y.header.tile_data_length; +} + +/// Collapse SAME-(name, date) twin archives to ONE — the most capable. Two +/// bakes of one cell edition carry the same DSID name+date (the date is the +/// cell's, not the bake's), so the ownership tie-break cannot order them and +/// used to fall through to input order: which twin won the ground depended on +/// the host's directory enumeration, so the same library could render +/// differently on two machines — with the stale twin's ground appearing only +/// in the zoom window its older bake carried. Twins are adjacent after +/// canonicalizeCellOrder; the arrays are compacted in place and the kept +/// length returned. Distinct DATES are NOT collapsed: those are different +/// editions, and the newer-date-first clip order already supersedes cleanly. +fn dedupTwinArchives( readers: []const *pmtiles.Reader, maps: []const []align(std.heap.page_size_min) const u8, shims: []const LoadedCov, + owns_archives: bool, +) usize { + const n = shims.len; + if (n < 2) return n; + const rs = @constCast(readers); + const ms = @constCast(maps); + const ss = @constCast(shims); + var w: usize = 0; + var i: usize = 0; + var dropped: usize = 0; + while (i < n) { + var best = i; + var j = i + 1; + while (j < n and std.mem.eql(u8, ss[j].name, ss[i].name) and std.mem.eql(u8, ss[j].date, ss[i].date)) : (j += 1) { + if (servesBetter(rs[j], rs[best])) best = j; + } + for (i..j) |k| { + if (k == best) continue; + dropped += 1; + if (owns_archives) { + rs[k].deinit(); + filemap.unmap(ms[k]); + } + } + rs[w] = rs[best]; + ss[w] = ss[best]; + if (ms.len == n) ms[w] = ms[best]; + w += 1; + i = j; + } + if (dropped > 0) std.debug.print("compose: {d} twin archive(s) of already-present cell editions dropped (kept the widest-serving)\n", .{dropped}); + return w; +} + +fn finishOpen( + gpa: std.mem.Allocator, + src: *ComposeSource, + readers_in: []const *pmtiles.Reader, + maps_in: []const []align(std.heap.page_size_min) const u8, + shims_in: []const LoadedCov, load_partition: ?[]const u8, owns_archives: bool, ) !*ComposeSource { const a = src.arena.allocator(); - canonicalizeCellOrder(readers, maps, shims); + canonicalizeCellOrder(readers_in, maps_in, shims_in); + const kept = dedupTwinArchives(readers_in, maps_in, shims_in, owns_archives); + const readers = readers_in[0..kept]; + const maps = if (maps_in.len == shims_in.len) maps_in[0..kept] else maps_in; + const shims = shims_in[0..kept]; var minz: u8 = 255; var maxz: u8 = 0; var ubox = [4]f64{ 1e9, 1e9, -1e9, -1e9 }; // union coverage [w, s, e, n] @@ -583,6 +979,35 @@ fn finishOpen( ubox[3] = @max(ubox[3], sh.bounds[3]); } + // The composition-set facts, printed so a field report never has to be + // inferred: how many archives, how many DISTINCT cells (adjacent after the + // canonical name+date sort), how many names carry multiple editions, and + // how many archives start above z0 (a pre-fill-down bake — such an archive + // serves nothing at coarse zooms). Names of multi-edition groups follow, + // capped, so the claim is checkable against the actual files. + { + var distinct: usize = 0; + var multi: usize = 0; + var floored: usize = 0; + var i: usize = 0; + while (i < shims.len) { + var j = i + 1; + while (j < shims.len and std.mem.eql(u8, shims[j].name, shims[i].name)) : (j += 1) {} + distinct += 1; + if (j - i > 1) { + multi += 1; + if (multi <= 12) { + std.debug.print("compose: editions of {s}:", .{shims[i].name}); + for (i..j) |k| std.debug.print(" {s}(z{d}..{d})", .{ shims[k].date, readers[k].header.min_zoom, readers[k].header.max_zoom }); + std.debug.print("\n", .{}); + } + } + i = j; + } + for (readers) |rp| floored += @intFromBool(rp.header.min_zoom > 0); + std.debug.print("compose: {d} archives, {d} distinct cells, {d} with multiple editions, {d} archives starting above z0\n", .{ shims.len, distinct, multi, floored }); + } + const cells = try toPlaneCells(a, shims); for (cells, readers) |*c, rp| c.reach = @max(bandReach(c.cscl), rp.header.max_zoom); @@ -595,10 +1020,15 @@ fn finishOpen( } if (!loaded) src.part = try geometry.partition.build(gpa, cells); src.part_loaded = loaded; + std.debug.print("compose: partition {s}\n", .{if (loaded) "LOADED from sidecar" else "BUILT fresh (no/stale sidecar)"}); + + const names = try a.alloc([]const u8, shims.len); + for (shims, 0..) |sh, i| names[i] = sh.name; const fill_max = @min(maxz + band.FILLUP_DZ, band.FILLUP_CEIL); src.maps = maps; src.readers = readers; + src.names = names; src.owns_archives = owns_archives; src.minz = minz; src.maxz = maxz; @@ -628,12 +1058,19 @@ fn decodeTile(a: std.mem.Allocator, tt: pmtiles.TileType, raw: []const u8) ![]mv // ancestor tile with the features scaled up into this descendant (overscale). null = nothing // reachable (below native, or a coarse-only zoom beyond the fill-up window, where the client // camera + MapLibre overzoom take over). Everything is arena-allocated in `a`. -fn ownerTile(a: std.mem.Allocator, r: *pmtiles.Reader, cscl: i32, z: u8, tx: u32, ty: u32) !?[]mvt.DecodedLayer { +/// `deep_overscale` widens the ancestor window from the band fill-up (+1 zoom) +/// to DEEP_OVERSCALE_DZ — the cross-BAND compose fallback: where the governing +/// band has no data at all, the best coarser chart serves scaled up (the +/// paper-chart / ECDIS overscale behaviour) instead of a void. +const DEEP_OVERSCALE_DZ: u8 = 8; + +fn ownerTile(a: std.mem.Allocator, r: *pmtiles.Reader, cscl: i32, z: u8, tx: u32, ty: u32, deep_overscale: bool) !?[]mvt.DecodedLayer { const tt = r.header.tile_type; if (try r.getTile(a, z, tx, ty)) |raw| return try decodeTile(a, tt, raw); const nmax = band.bandZooms(band.bandOf(cscl)).max; - if (z <= nmax or z > nmax + band.FILLUP_DZ or z > band.FILLUP_CEIL) return null; + const max_serve: u8 = if (deep_overscale) nmax +| DEEP_OVERSCALE_DZ else @min(nmax + band.FILLUP_DZ, band.FILLUP_CEIL); + if (z <= nmax or z > max_serve) return null; const shift: u5 = @intCast(z - nmax); const anc = (try r.getTile(a, nmax, tx >> shift, ty >> shift)) orelse return null; const layers = try decodeTile(a, tt, anc); @@ -654,9 +1091,13 @@ pub fn bandReach(cscl: i32) u8 { // tile-major compositor's discovery pass uses this to reproduce the compose predicate, and // the two passes must agree on which tiles compose. fn ownerHasTile(r: *pmtiles.Reader, cscl: i32, z: u8, tx: u32, ty: u32) !bool { + return ownerHasTileDeep(r, cscl, z, tx, ty, false); +} +fn ownerHasTileDeep(r: *pmtiles.Reader, cscl: i32, z: u8, tx: u32, ty: u32, deep_overscale: bool) !bool { if ((try r.getCompressed(z, tx, ty)) != null) return true; const nmax = band.bandZooms(band.bandOf(cscl)).max; - if (z <= nmax or z > nmax + band.FILLUP_DZ or z > band.FILLUP_CEIL) return false; + const max_serve: u8 = if (deep_overscale) nmax +| DEEP_OVERSCALE_DZ else @min(nmax + band.FILLUP_DZ, band.FILLUP_CEIL); + if (z <= nmax or z > max_serve) return false; const shift: u5 = @intCast(z - nmax); return (try r.getCompressed(nmax, tx >> shift, ty >> shift)) != null; } diff --git a/src/coverage/coverage.zig b/src/coverage/coverage.zig index 5130e98d..e274c1fe 100644 --- a/src/coverage/coverage.zig +++ b/src/coverage/coverage.zig @@ -123,9 +123,13 @@ pub fn encodeJson(a: std.mem.Allocator, cov: ChartCoverage) ![]u8 { } /// Extract the coverage embedded in a PMTiles metadata JSON blob, or null if absent / -/// unparseable. The whole result (rings + strings) is allocated in `a`. -pub fn decodeFromMetadata(a: std.mem.Allocator, metadata_json: []const u8) !?ChartCoverage { - var parsed = std.json.parseFromSlice(Envelope, a, metadata_json, .{ .ignore_unknown_fields = true }) catch return null; +/// unparseable. The RESULT (rings + strings) is allocated in `a`; the JSON +/// parser's scratch goes through `scratch`, which must be able to actually +/// free (NOT an arena) — callers passing a retained arena for both kept the +/// parser's whole intermediate DTO alive for the arena's lifetime, tens of MB +/// across a full library open. +pub fn decodeFromMetadata(a: std.mem.Allocator, scratch: std.mem.Allocator, metadata_json: []const u8) !?ChartCoverage { + var parsed = std.json.parseFromSlice(Envelope, scratch, metadata_json, .{ .ignore_unknown_fields = true }) catch return null; defer parsed.deinit(); // frees the DTO's own arena; the copies below live in `a` const dto = parsed.value.coverage orelse return null; return ChartCoverage{ @@ -204,7 +208,7 @@ test "coverage round-trips through the metadata envelope, integers exact" { // Embed under "coverage" alongside the other metadata keys the decoder must skip. const meta = try std.fmt.allocPrint(a, "{{\"name\":\"chartplotter\",\"format\":\"pbf\",\"scamin\":[1000,2000],\"coverage\":{s}}}", .{inner}); - const got = (try decodeFromMetadata(a, meta)) orelse return error.TestUnexpectedResult; + const got = (try decodeFromMetadata(a, testing.allocator, meta)) orelse return error.TestUnexpectedResult; try testing.expectEqualStrings("US5MD1MC", got.name); try testing.expectEqualStrings("20210115", got.date); try testing.expectEqual(@as(i32, 20_000), got.cscl); @@ -224,7 +228,7 @@ test "metadata without a coverage key decodes to null" { defer arena.deinit(); const a = arena.allocator(); const meta = "{\"name\":\"chartplotter\",\"format\":\"pbf\",\"scamin\":[1000]}"; - try testing.expect((try decodeFromMetadata(a, meta)) == null); + try testing.expect((try decodeFromMetadata(a, testing.allocator, meta)) == null); } test "bboxOf spans all rings; empty coverage yields a zero bbox" { diff --git a/src/geometry/partition.zig b/src/geometry/partition.zig index 5070eac8..2c65f0c1 100644 --- a/src/geometry/partition.zig +++ b/src/geometry/partition.zig @@ -150,7 +150,13 @@ pub fn build(gpa: std.mem.Allocator, cells: []const plane.Cell) !Partition { // which is what makes an incremental recompose safe when coverage is unchanged. pub const MAGIC = [4]u8{ 'T', '5', '7', 'P' }; -pub const FORMAT_VERSION: u32 = 2; // 2: fill-up gap-filler faces (finer cells own uncovered ground at coarse tiers) +// The version is the ALGORITHM generation, not just the byte layout: a sidecar +// carries the bake-time partition VERBATIM, and the input key validates only +// the input cells — never the faces. Faces computed by an older, buggier build +// otherwise outlive every fix (a field device rendered a Great Lakes cell +// owning Gulf-of-Mexico ground from exactly such a sidecar). Bump on ANY +// change to the owned-face computation. +pub const FORMAT_VERSION: u32 = 3; // 3: antimeridian coverage split + serve-floor semantics pub const LoadError = error{ BadMagic, diff --git a/src/render/gpu.zig b/src/render/gpu.zig index bfd7b28c..cd37c8dd 100644 --- a/src/render/gpu.zig +++ b/src/render/gpu.zig @@ -61,6 +61,19 @@ pub const Vertex = extern struct { /// bricks): a rotated view must turn it. Zero means screen-upright. map_align: u8, _pad: [2]u8 = .{ 0, 0 }, + /// Straight-alpha RGBA, resolved for the scene's palette. Per-VERTEX (not + /// per-range) so a host can draw contiguous ranges of DIFFERENT colours in + /// one call — at coastal zooms the per-range uniform+draw churn measured + /// as the frame-rate cap on a phone. Range.color remains as advisory + /// metadata. + color: [4]u8 = .{ 0, 0, 0, 255 }, + /// Paint-order depth in (0,1): LATER paint = SMALLER value (closer). + /// Assigned per RANGE by build/assemble after the paint sort. A host draws + /// OPAQUE ranges front-to-back with depth test LESS + write (hidden + /// fragments never shade), then blended content in paint order with test + /// LESS, no write — under-an-opaque is culled, everything else blends + /// exactly as painter's order did. 0 (the default) always passes. + depth: f32 = 0, }; /// One textured-quad vertex — a symbol sprite or an SDF glyph. `x,y` is the @@ -94,6 +107,10 @@ pub const Quad = extern struct { /// 256 * 2π`), so the flip shader recovers cos/sin(tangent) with no rebuild. /// Meaningful only when `flip` is set; 0 otherwise. tangent_q: u8 = 0, + /// Paint-order depth, same contract as Vertex.depth: quads are NOT all + /// top-band content (linestyle bricks ride LOW bands under area fills) — + /// without this they float above every fill in a depth-tested pass. + depth: f32 = 0, }; /// `Range.pattern` when the range is not an area-fill pattern — which is every @@ -247,7 +264,10 @@ pub const Range = extern struct { kind: Kind, prim: Prim, atlas: AtlasId, - _pad: u8 = 0, + /// Bit 0: OPAQUE — a pattern-less triangle range whose every colour has + /// alpha 255. Such ranges are eligible for a host's front-to-back + /// depth-tested pass; everything else must blend in paint order. + flags: u8 = 0, }; /// A finished scene. Everything borrows the arena passed to `endScene` and dies @@ -381,6 +401,10 @@ pub const GpuSurface = struct { candidates: std.ArrayList(LabelCandidate) = .empty, // Keyed by (face_idx << 16 | gid): glyph ids are per-face (outline fallback). glyph_cache: std.AutoHashMapUnmanaged(u32, []const []const cv.Point) = .empty, + /// The current tile's EFFECTIVE safety contour (mariner's value snapped to + /// the tile's ladder — see Surface.set_contour_ladder). Drives live water + /// shading, the danger-symbol swap, and the bold safety-contour line. + eff_safety: ?f64 = null, const vtable = rs.Surface.VTable{ .beginScene = beginScene, @@ -394,8 +418,22 @@ pub const GpuSurface = struct { .endFeature = endFeature, .endScene = endScene, .size_scale = sizeScale, + .set_contour_ladder = setContourLadder, }; + fn setContourLadder(ctx: *anyopaque, ladder: []const f64) void { + const self = sp(ctx); + self.eff_safety = rs.Surface.effectiveSafety(self.settings.safety_contour, ladder); + } + + /// Settings with the SNAPPED safety contour — what live shading resolves + /// against, so the split always coincides with a contour that exists. + fn effSettings(self: *GpuSurface) resolve.Settings { + var m = self.settings.*; + if (self.eff_safety) |v| m.safety_contour = v; + return m; + } + pub fn init(a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, settings: *const resolve.Settings, zoom: f64) !GpuSurface { return .{ .a = a, @@ -522,10 +560,16 @@ pub const GpuSurface = struct { fn endFeature(_: *anyopaque) anyerror!void {} - fn fillArea(ctx: *anyopaque, token: rs.ColorToken, rings: []const []const rs.TilePoint, _: ?rs.DepthRange) anyerror!void { + fn fillArea(ctx: *anyopaque, token: rs.ColorToken, rings: []const []const rs.TilePoint, depth: ?rs.DepthRange) anyerror!void { const self = sp(ctx); if (!resolve.visible(&self.cur, "", self.zoom, self.settings)) return; - try self.push(.area, self.rgba(token), .{ .fill = .{ .rings = rings, .rule = .nonzero } }); + // Depth areas re-resolve their shade against the LIVE mariner contours + // (snapped): the baked token was fixed at bake defaults, and using it + // froze the app's water shading — contour changes moved only danger + // symbols. Mirrors vector/pixel (which always did this). + var eff = self.effSettings(); + const name = if (depth) |d| resolve.seabedToken(d, &eff) else token; + try self.push(.area, self.rgba(name), .{ .fill = .{ .rings = rings, .rule = .nonzero } }); } /// An area-fill pattern: the polygon interior, plus the cell to tile over it. @@ -570,10 +614,24 @@ pub const GpuSurface = struct { fn strokeLine(ctx: *anyopaque, token: rs.ColorToken, width_px: f64, dash: rs.Dash, lines: []const []const rs.TilePoint, valdco: ?f64) anyerror!void { const self = sp(ctx); if (!resolve.visible(&self.cur, "", self.zoom, self.settings)) return; + // THE safety contour (S-52 §10.5.5): the depth contour matching the + // effective safety value draws bold and solid — the boundary between + // safe and unsafe water must be unmistakable, and it must be the SAME + // contour the shading split sits on (both use the snapped value). + var w = width_px; + var dsh = dash; + if (valdco) |v| { + if (self.eff_safety) |eff| { + if (@abs(v - eff) < 0.01) { + w = @max(width_px * 2.5, 2.0); + dsh = .solid; + } + } + } try self.push(.line, self.rgba(token), .{ .stroke = .{ .lines = lines, - .half_w = @floatCast(@max(width_px, 0.5) * 0.5), - .dash = dash, + .half_w = @floatCast(@max(w, 0.5) * 0.5), + .dash = dsh, } }); // A depth-contour value rides the line as a MAP-aligned, tangent-rotated // label candidate — the same placement the vector path emits. @@ -736,7 +794,10 @@ pub const GpuSurface = struct { // style, so mirror that toggle here (as vector.zig / pixel.zig do). if (!self.settings.show_inform_callouts and std.mem.eql(u8, name, "INFORM01")) return; var eff = name; - if (danger_depth) |dd| eff = if (dd > self.settings.safety_contour) "DANGER02" else "DANGER01"; + if (danger_depth) |dd| { + const sc = self.eff_safety orelse self.settings.safety_contour; + eff = if (dd > sc) "DANGER02" else "DANGER01"; + } const s = store.get(eff) orelse return; try self.emitSprite(.symbol, eff, s, at, rot_deg, scale, self.refDev(), rot_north); } @@ -1107,6 +1168,11 @@ pub const GpuSurface = struct { pub fn build(self: *GpuSurface, arena: Allocator) !Scene { std.mem.sort(Op, self.ops.items, {}, opLt); + // Grow the working lists in the SCRATCH allocator, not `arena`: an + // ArrayList growing inside an arena strands every outgrown copy there + // for the arena's lifetime — for a cached tile scene that ~doubled the + // resident cost of every entry. The final slices are duped into `arena` + // at the end (everything is by-value, so relocation is safe). var verts = std.ArrayList(Vertex).empty; var indices = std.ArrayList(u32).empty; var quads = std.ArrayList(Quad).empty; @@ -1120,11 +1186,11 @@ pub const GpuSurface = struct { if (op.geom == .sprite) { const sq = op.geom.sprite; const first = quads.items.len; - try self.emitSpriteGeom(arena, &quads, op, sq.anchor, sq.quads, sq.weight); + try self.emitSpriteGeom(self.a, &quads, op, sq.anchor, sq.quads, sq.weight); const count = quads.items.len - first; if (count == 0) continue; if (coalesce(&ranges, op, .quads, sq.atlas, first, count)) continue; - try ranges.append(arena, .{ + try ranges.append(self.a, .{ .first = @intCast(first), .count = @intCast(count), .paint_key = op.paint_key, @@ -1138,15 +1204,15 @@ pub const GpuSurface = struct { } const first = indices.items.len; switch (op.geom) { - .fill => |f| try self.emitFill(arena, &verts, &indices, op, f.rings, f.rule), - .stroke => |s| try self.emitStroke(arena, &verts, &indices, op, s.lines, s.half_w), - .mark => |m| try self.emitMarkGeom(arena, &verts, &indices, op, m.anchor, m.rings, m.rule), + .fill => |f| try self.emitFill(self.a, &verts, &indices, op, f.rings, f.rule), + .stroke => |s| try self.emitStroke(self.a, &verts, &indices, op, s.lines, s.half_w), + .mark => |m| try self.emitMarkGeom(self.a, &verts, &indices, op, m.anchor, m.rings, m.rule), .sprite => unreachable, } const count = indices.items.len - first; if (count == 0) continue; if (coalesce(&ranges, op, .triangles, .none, first, count)) continue; - try ranges.append(arena, .{ + try ranges.append(self.a, .{ .first = @intCast(first), .count = @intCast(count), .paint_key = op.paint_key, @@ -1155,8 +1221,21 @@ pub const GpuSurface = struct { .prim = .triangles, .atlas = .none, .color = op.color, + .flags = if (op.pattern == NO_PATTERN and op.color[3] == 255) 1 else 0, }); } + // Paint-order depth, per RANGE: range i of N gets (N-i)/(N+1) — later + // paint = closer. Written through each range's index span (a vertex + // belongs to exactly one range). assemble() reassigns per view. + const nr = ranges.items.len; + for (ranges.items, 0..) |r, i| { + const d: f32 = @floatCast(@as(f64, @floatFromInt(nr - i)) / @as(f64, @floatFromInt(nr + 1))); + if (r.prim == .triangles) { + for (indices.items[r.first..][0..r.count]) |idx| verts.items[idx].depth = d; + } else { + for (quads.items[r.first..][0..r.count]) |*q| q.depth = d; + } + } // Pattern cells were interned into the surface's (scratch) allocator, but // the scene must outlive it — so copy each cell's PIXELS into `arena`, not // just the struct. Duping the struct alone leaves rgba dangling once the @@ -1166,10 +1245,10 @@ pub const GpuSurface = struct { dst.* = .{ .w = src.w, .h = src.h, .rgba = try arena.dupe(u8, src.rgba) }; } return .{ - .vertices = try verts.toOwnedSlice(arena), - .indices = try indices.toOwnedSlice(arena), - .quads = try quads.toOwnedSlice(arena), - .ranges = try ranges.toOwnedSlice(arena), + .vertices = try arena.dupe(Vertex, verts.items), + .indices = try arena.dupe(u32, indices.items), + .quads = try arena.dupe(Quad, quads.items), + .ranges = try arena.dupe(Range, ranges.items), .patterns = pats, }; } @@ -1195,9 +1274,15 @@ pub const GpuSurface = struct { fn coalesce(ranges: *std.ArrayList(Range), op: Op, prim: Prim, atlas: AtlasId, first: usize, count: usize) bool { if (ranges.items.len == 0) return false; const prev = &ranges.items[ranges.items.len - 1]; + const op_flags: u8 = if (prim == .triangles and op.pattern == NO_PATTERN and op.color[3] == 255) 1 else 0; + // OPAQUE ranges never merge across colours: each keeps its own depth, + // so overlapping same-band fills of different colours resolve by depth + // exactly as painter's order did. Blended ranges may colour-merge — + // they still draw in buffer order. + if (prev.flags != op_flags) return false; + if (op_flags == 1 and !std.mem.eql(u8, &prev.color, &op.color)) return false; if (prev.prim == prim and prev.atlas == atlas and prev.paint_key == op.paint_key and prev.kind == op.kind and prev.pattern == op.pattern and - std.mem.eql(u8, &prev.color, &op.color) and prev.first + prev.count == first) { prev.count += @intCast(count); @@ -1246,6 +1331,7 @@ pub const GpuSurface = struct { .scamin = op.scamin, .disp_cat = op.disp_cat, .map_align = op.map_align, + .color = op.color, }; } @@ -1322,7 +1408,9 @@ pub const GpuSurface = struct { /// tessellate) happened once per tile; this is memcpy + an offset fixup + a sort. /// Everything is copied into `arena`, so the result is independent of the input /// scenes' lifetimes (a cached tile may be evicted after). -pub fn assemble(arena: Allocator, scenes: []const Scene) !Scene { +pub fn assemble(arena: Allocator, scratch: Allocator, scenes: []const Scene) !Scene { + // Working lists grow in `scratch` (stale growth copies die with it); only + // the final slices are duped into `arena` — see GpuSurface.build. var verts = std.ArrayList(Vertex).empty; var indices = std.ArrayList(u32).empty; var quads = std.ArrayList(Quad).empty; @@ -1333,32 +1421,64 @@ pub fn assemble(arena: Allocator, scenes: []const Scene) !Scene { const ibase: u32 = @intCast(indices.items.len); const qbase: u32 = @intCast(quads.items.len); const pbase: u32 = @intCast(patterns.items.len); - try verts.appendSlice(arena, s.vertices); - for (s.indices) |idx| try indices.append(arena, idx + vbase); - try quads.appendSlice(arena, s.quads); + try verts.appendSlice(scratch, s.vertices); + for (s.indices) |idx| try indices.append(scratch, idx + vbase); + try quads.appendSlice(scratch, s.quads); // Pattern pixels live in the source scene's arena; copy them so the result - // outlives it. - for (s.patterns) |cell| try patterns.append(arena, .{ .w = cell.w, .h = cell.h, .rgba = try arena.dupe(u8, cell.rgba) }); + // outlives it (straight into `arena` — pixels are duped exactly once). + for (s.patterns) |cell| try patterns.append(scratch, .{ .w = cell.w, .h = cell.h, .rgba = try arena.dupe(u8, cell.rgba) }); for (s.ranges) |r| { var nr = r; nr.first = r.first + (if (r.prim == .triangles) ibase else qbase); if (r.pattern != NO_PATTERN) nr.pattern = r.pattern + pbase; - try ranges.append(arena, nr); + try ranges.append(scratch, nr); } } - // Cross-tile paint order: one global sort by the engine's key. Ties (same - // class/priority in different tiles) draw in any order — same paint band. - std.mem.sort(Range, ranges.items, {}, struct { + // Cross-tile paint order: one global STABLE sort by the engine's key (ties + // keep tile order, so the layout below is deterministic). + std.sort.block(Range, ranges.items, {}, struct { fn lt(_: void, a: Range, b: Range) bool { return a.paint_key < b.paint_key; } }.lt); + // Re-lay the index and quad streams IN SORTED RANGE ORDER, so ranges that + // draw identically sit contiguously and a host can merge whole paint bands + // into single draw calls. Without this, the global sort interleaves tiles + // and same-band ranges land at scattered offsets — a phone-measured + // frame-rate cap of thousands of draws where dozens suffice. One extra + // linear copy, on the build thread. + var indices2 = try scratch.alloc(u32, indices.items.len); + var quads2 = try scratch.alloc(Quad, quads.items.len); + var ipos: u32 = 0; + var qpos: u32 = 0; + for (ranges.items) |*r| { + if (r.prim == .triangles) { + @memcpy(indices2[ipos..][0..r.count], indices.items[r.first..][0..r.count]); + r.first = ipos; + ipos += r.count; + } else { + @memcpy(quads2[qpos..][0..r.count], quads.items[r.first..][0..r.count]); + r.first = qpos; + qpos += r.count; + } + } + // Whole-view paint-order depth, per RANGE (overwrites the per-tile values: + // the global sort interleaved tiles). Later paint = closer; see Vertex.depth. + const nr = ranges.items.len; + for (ranges.items, 0..) |r, i| { + const d: f32 = @floatCast(@as(f64, @floatFromInt(nr - i)) / @as(f64, @floatFromInt(nr + 1))); + if (r.prim == .triangles) { + for (indices2[r.first..][0..r.count]) |idx| verts.items[idx].depth = d; + } else { + for (quads2[r.first..][0..r.count]) |*q| q.depth = d; + } + } return .{ - .vertices = try verts.toOwnedSlice(arena), - .indices = try indices.toOwnedSlice(arena), - .quads = try quads.toOwnedSlice(arena), - .ranges = try ranges.toOwnedSlice(arena), - .patterns = try patterns.toOwnedSlice(arena), + .vertices = try arena.dupe(Vertex, verts.items), + .indices = try arena.dupe(u32, indices2[0..ipos]), + .quads = try arena.dupe(Quad, quads2[0..qpos]), + .ranges = try arena.dupe(Range, ranges.items), + .patterns = try arena.dupe(PatternCell, patterns.items), }; } @@ -1916,7 +2036,7 @@ const TextFixture = struct { fn full(self: *TextFixture, a: Allocator) !Scene { const geom = try self.gs.build(a); const labels = try self.labelScene(a); - return assemble(a, &.{ geom, labels }); + return assemble(a, a, &.{ geom, labels }); } /// One label alone: the baseline a crowded scene is measured against. fn one(a: Allocator, colors: *const resolve.Colors, settings: *const resolve.Settings, text: []const u8) !Scene { @@ -2115,11 +2235,14 @@ test "gpu: the C scene structs match their tile57.h layout" { // misread — wrong colours, wrong offsets, no error anywhere. These numbers // came from a C program compiled against the header (sizeof + offsetof), so // a Zig-side change that breaks the C view fails here instead of on a chart. - try testing.expectEqual(@as(usize, 24), @sizeOf(Vertex)); + try testing.expectEqual(@as(usize, 32), @sizeOf(Vertex)); try testing.expectEqual(@as(usize, 8), @offsetOf(Vertex, "ox")); try testing.expectEqual(@as(usize, 16), @offsetOf(Vertex, "scamin")); try testing.expectEqual(@as(usize, 20), @offsetOf(Vertex, "disp_cat")); try testing.expectEqual(@as(usize, 21), @offsetOf(Vertex, "map_align")); + try testing.expectEqual(@as(usize, 24), @offsetOf(Vertex, "color")); + try testing.expectEqual(@as(usize, 28), @offsetOf(Vertex, "depth")); + try testing.expectEqual(@as(usize, 23), @offsetOf(Range, "flags")); try testing.expectEqual(@as(usize, 24), @sizeOf(Range)); try testing.expectEqual(@as(usize, 0), @offsetOf(Range, "first")); @@ -2131,7 +2254,8 @@ test "gpu: the C scene structs match their tile57.h layout" { try testing.expectEqual(@as(usize, 21), @offsetOf(Range, "prim")); try testing.expectEqual(@as(usize, 22), @offsetOf(Range, "atlas")); - try testing.expectEqual(@as(usize, 40), @sizeOf(Quad)); + try testing.expectEqual(@as(usize, 44), @sizeOf(Quad)); + try testing.expectEqual(@as(usize, 40), @offsetOf(Quad, "depth")); try testing.expectEqual(@as(usize, 16), @offsetOf(Quad, "u")); try testing.expectEqual(@as(usize, 24), @offsetOf(Quad, "color")); try testing.expectEqual(@as(usize, 28), @offsetOf(Quad, "weight")); diff --git a/src/render/pixel.zig b/src/render/pixel.zig index a7377f17..27bb40ca 100644 --- a/src/render/pixel.zig +++ b/src/render/pixel.zig @@ -192,6 +192,8 @@ pub const PixelSurface = struct { cur: rs.FeatureMeta = .{}, cur_visible: bool = true, + eff_safety: ?f64 = null, + const vtable = rs.Surface.VTable{ .beginScene = beginScene, .beginFeature = beginFeature, @@ -204,8 +206,21 @@ pub const PixelSurface = struct { .endFeature = endFeature, .endScene = endScene, .size_scale = sizeScale, + .set_contour_ladder = setContourLadder, }; + fn setContourLadder(ctx: *anyopaque, ladder: []const f64) void { + const self = sp(ctx); + self.eff_safety = rs.Surface.effectiveSafety(self.settings.safety_contour, ladder); + } + + /// Settings with the SNAPPED safety contour (see gpu.zig's twin). + fn effSettings(self: anytype) resolve.Settings { + var m2 = self.settings.*; + if (self.eff_safety) |v| m2.safety_contour = v; + return m2; + } + /// `a` should be the same scratch arena the engine allocates geometry /// from — buffered ops live until endScene, exactly like the tile /// surface's feature lists. @@ -286,7 +301,8 @@ pub const PixelSurface = struct { const ft = rs.fillToken(token); // A depth area re-shades LIVE against the mariner's contours (SEABED01) — the // baked token carries the bake context's contours, not this mariner's. - const name = if (depth) |d| resolve.seabedToken(d, self.settings) else ft.name; + var effm = self.effSettings(); + const name = if (depth) |d| resolve.seabedToken(d, &effm) else ft.name; var col = self.resolveColor(name); col.a = ft.alpha; try self.push(.area, .{ .fill = .{ .rings = try self.toCanvas(rings), .color = col } }); @@ -386,7 +402,10 @@ pub const PixelSurface = struct { // Live danger swap (mirrors mariner.pointSymbolImage): a danger lying // DEEPER than the mariner's safety contour draws the subdued DANGER02. var eff = name; - if (danger_depth) |dd| eff = if (dd > self.settings.safety_contour) "DANGER02" else "DANGER01"; + if (danger_depth) |dd| { + const sc = self.eff_safety orelse self.settings.safety_contour; + eff = if (dd > sc) "DANGER02" else "DANGER01"; + } const s = store.get(eff) orelse return; // unknown glyph: skip (the tile // path shows QUESMRK1 for unmapped CLASSES; an unmapped symbol NAME is // a catalogue gap and drawing nothing beats a wrong mark) diff --git a/src/render/resolve.zig b/src/render/resolve.zig index 7bd2129a..23d0d448 100644 --- a/src/render/resolve.zig +++ b/src/render/resolve.zig @@ -119,9 +119,14 @@ pub fn seabedToken(d: rs.DepthRange, m: *const Settings) []const u8 { if (band(d1, d2, 0)) return "DEPVS"; return "DEPIT"; } - if (band(d1, d2, m.deep_contour)) return "DEPDW"; + // S-52 orders the ladder shallow <= safety <= deep. Un-normalized, a + // safety contour DEEPER than the deep contour let the deep test match + // first and shaded genuinely UNSAFE water in the white deep shade. + const eff_deep = @max(m.deep_contour, m.safety_contour); + const eff_shallow = @min(m.shallow_contour, m.safety_contour); + if (band(d1, d2, eff_deep)) return "DEPDW"; if (band(d1, d2, m.safety_contour)) return "DEPMD"; - if (band(d1, d2, m.shallow_contour)) return "DEPMS"; + if (band(d1, d2, eff_shallow)) return "DEPMS"; if (band(d1, d2, 0)) return "DEPVS"; return "DEPIT"; } diff --git a/src/render/surface.zig b/src/render/surface.zig index c2d74fcb..69e44a8d 100644 --- a/src/render/surface.zig +++ b/src/render/surface.zig @@ -163,8 +163,33 @@ pub const Surface = struct { /// The baked tile stays display-independent (the disk cache survives a /// display change). Null on render surfaces (they walk, not store). store_complex_run: ?*const fn (*anyopaque, style: []const u8, color: ColorToken, width_px: f64, arc0: f64, run: []const TilePoint) anyerror!void = null, + /// Present on RENDER surfaces: the tile's available depth-contour + /// ladder (distinct DEPCN valdco + DEPARE drval1 values, unsorted). + /// The surface snaps the mariner's safety contour to the next DEEPER + /// value in it (S-52: a safety contour absent from the data promotes + /// to the next deeper one; the shading split and the bold line must + /// coincide). replayTile calls this per tile before emitting; the + /// slice is valid only for the call. Null on the bake encoder. + set_contour_ladder: ?*const fn (*anyopaque, ladder: []const f64) void = null, }; + pub fn setContourLadder(self: Surface, ladder: []const f64) void { + if (self.vtable.set_contour_ladder) |f| f(self.ptr, ladder); + } + + /// The S-52 effective safety contour against a tile's ladder: the least + /// available value >= the mariner's, else the deepest available, else the + /// mariner's own (no ladder to snap to). Shared by every render surface. + pub fn effectiveSafety(safety: f64, ladder: []const f64) f64 { + var next: ?f64 = null; + var deepest: ?f64 = null; + for (ladder) |v| { + if (deepest == null or v > deepest.?) deepest = v; + if (v >= safety and (next == null or v < next.?)) next = v; + } + return next orelse (deepest orelse safety); + } + pub fn beginScene(self: Surface, z: u8) anyerror!void { return self.vtable.beginScene(self.ptr, z); } @@ -209,3 +234,17 @@ pub const Surface = struct { return self.vtable.store_complex_run.?(self.ptr, style, color, width_px, arc0, run); } }; + +test "effectiveSafety: next-deeper snap, deepest fallback, empty ladder" { + const t = @import("std").testing; + const ladder = [_]f64{ 2, 5.4, 9.1, 18.2, 30 }; + // exact hit stays + try t.expectEqual(@as(f64, 9.1), Surface.effectiveSafety(9.1, &ladder)); + // between rungs -> next DEEPER + try t.expectEqual(@as(f64, 18.2), Surface.effectiveSafety(10, &ladder)); + try t.expectEqual(@as(f64, 5.4), Surface.effectiveSafety(2.2, &ladder)); + // deeper than everything -> deepest available + try t.expectEqual(@as(f64, 30), Surface.effectiveSafety(50, &ladder)); + // no ladder -> the mariner's own value + try t.expectEqual(@as(f64, 7), Surface.effectiveSafety(7, &.{})); +} diff --git a/src/render/vector.zig b/src/render/vector.zig index 04581ff9..9b1598e6 100644 --- a/src/render/vector.zig +++ b/src/render/vector.zig @@ -351,6 +351,8 @@ pub const VectorSurface = struct { /// holding it costs one Op header per call, not a second copy of the scene. ops: std.ArrayListUnmanaged(Op) = .empty, + eff_safety: ?f64 = null, + const vtable = rs.Surface.VTable{ .beginScene = beginScene, .beginFeature = beginFeature, @@ -365,8 +367,21 @@ pub const VectorSurface = struct { // Render surface: the engine walks complex-linestyle periods at this scale. // (No store_complex_run — this surface WALKS/renders runs, never stores.) .size_scale = sizeScale, + .set_contour_ladder = setContourLadder, }; + fn setContourLadder(ctx: *anyopaque, ladder: []const f64) void { + const self = sp(ctx); + self.eff_safety = rs.Surface.effectiveSafety(self.settings.safety_contour, ladder); + } + + /// Settings with the SNAPPED safety contour (see gpu.zig's twin). + fn effSettings(self: anytype) resolve.Settings { + var m2 = self.settings.*; + if (self.eff_safety) |v| m2.safety_contour = v; + return m2; + } + pub fn init(a: Allocator, colors: *const resolve.Colors, palette: resolve.PaletteId, settings: *const resolve.Settings, cb: *const CSurface) VectorSurface { return .{ .a = a, @@ -638,7 +653,8 @@ pub const VectorSurface = struct { // A depth area re-shades LIVE against the mariner's contours (SEABED01) — the // baked token carries the bake context's contours, not this mariner's. Keep the // baked transparency: the swap is of the colour, not of the fill's opacity. - const name = if (depth) |d| resolve.seabedToken(d, self.settings) else ft.name; + var effm = self.effSettings(); + const name = if (depth) |d| resolve.seabedToken(d, &effm) else ft.name; var col = self.resolveColor(name); col.a = ft.alpha; try self.push(.area, .{ .fill = .{ .rings = wr, .color = ccolor(col), .even_odd = 0 } }); @@ -738,7 +754,10 @@ pub const VectorSurface = struct { // the style, so mirror that toggle here. if (!self.settings.show_inform_callouts and std.mem.eql(u8, name, "INFORM01")) return; var eff = name; - if (danger_depth) |dd| eff = if (dd > self.settings.safety_contour) "DANGER02" else "DANGER01"; + if (danger_depth) |dd| { + const sc = self.eff_safety orelse self.settings.safety_contour; + eff = if (dd > sc) "DANGER02" else "DANGER01"; + } const s = store.get(eff) orelse return; // Draw as an atlas sprite when the host supports it; else tessellate. // Symbols never participate in declutter (S-52 icon-allow-overlap — they diff --git a/src/scene/replay.zig b/src/scene/replay.zig index c0bd67f1..05ab05ce 100644 --- a/src/scene/replay.zig +++ b/src/scene/replay.zig @@ -81,6 +81,34 @@ fn metaFromProps(props: []const mvt.Prop) rs.FeatureMeta { /// Replay one decoded tile's layers as Surface calls (between the caller's /// begin/endScene). Layer names route exactly as TileSurface emitted them. pub fn replayTile(a: Allocator, surf: rs.Surface, layers: []const mvt.DecodedLayer) !void { + // Pre-scan the tile's depth-contour ladder (DEPCN valdco + DEPARE drval1) + // so a render surface can snap the mariner's safety contour to the next + // DEEPER contour that actually exists (S-52) — and bold exactly that line. + { + var ladder: [64]f64 = undefined; + var n: usize = 0; + outer: for (layers) |layer| { + const areas = std.mem.startsWith(u8, layer.name, "areas"); + const lines = std.mem.startsWith(u8, layer.name, "lines"); + if (!areas and !lines) continue; + for (layer.features) |f| { + const v = propF64(f.properties, if (areas) "drval1" else "valdco") orelse continue; + var seen = false; + for (ladder[0..n]) |lv| { + if (lv == v) { + seen = true; + break; + } + } + if (!seen) { + ladder[n] = v; + n += 1; + if (n == ladder.len) break :outer; // ladder is tiny in practice + } + } + } + surf.setContourLadder(ladder[0..n]); + } for (layers) |layer| { const is_areas = std.mem.startsWith(u8, layer.name, "areas"); const is_patterns = std.mem.startsWith(u8, layer.name, "area_patterns"); diff --git a/src/scene/scene.zig b/src/scene/scene.zig index 39f491a9..51196d87 100644 --- a/src/scene/scene.zig +++ b/src/scene/scene.zig @@ -2212,10 +2212,14 @@ pub const ViewTiles = struct { self.tx = self.tx0; self.ty += 1; } - if (cur_ty < 0 or cur_ty >= self.max_t or cur_tx < 0 or cur_tx >= self.max_t) continue; + // y clamps at the mercator poles; x WRAPS — longitude is cyclic, so a + // view straddling the antimeridian fetches the far side's tiles. The + // canvas origin keeps the UNwrapped position: the tile draws at its + // continuous spot in the view, whichever world copy it came from. + if (cur_ty < 0 or cur_ty >= self.max_t) continue; return .{ .z = self.z, - .x = @intCast(cur_tx), + .x = @intCast(@mod(cur_tx, self.max_t)), .y = @intCast(cur_ty), .origin_x = @floatCast(@as(f64, @floatFromInt(cur_tx)) * self.pt - self.left), .origin_y = @floatCast(@as(f64, @floatFromInt(cur_ty)) * self.pt - self.top), diff --git a/src/sprite/sprite.zig b/src/sprite/sprite.zig index 39883500..42a55905 100644 --- a/src/sprite/sprite.zig +++ b/src/sprite/sprite.zig @@ -501,6 +501,13 @@ const MlnCell = struct { name: []const u8, w: u32, h: u32, ratio: f64, rgba: []c /// (tile57_bake_sprite_mln) MUST pass the SAME ratio, or the normalized UVs the /// scene emits will not index the texture the host uploaded. pub fn spriteMln(a: std.mem.Allocator, symbols: []const SvgSrc, fills: []const AreaFillSrc, css_data: []const u8, soundings: []const []const u8, ratio: f64) !Atlas { + return spriteMlnOpts(a, symbols, fills, css_data, soundings, ratio, true); +} + +/// `want_pixels = false`: layout only (cells + dims, empty png) — for the +/// in-process GPU-scene atlas, which never reads the pixels and must not pay +/// the compositing + PNG compression of a full device-density bake. +pub fn spriteMlnOpts(a: std.mem.Allocator, symbols: []const SvgSrc, fills: []const AreaFillSrc, css_data: []const u8, soundings: []const []const u8, ratio: f64, want_pixels: bool) !Atlas { var arena_state = std.heap.ArenaAllocator.init(a); defer arena_state.deinit(); const ar = arena_state.allocator(); @@ -564,7 +571,7 @@ pub fn spriteMln(a: std.mem.Allocator, symbols: []const SvgSrc, fills: []const A try cells.append(ar, .{ .name = stack, .w = t.w, .h = t.h, .ratio = ratio, .rgba = t.rgba }); } - return packMln(a, ar, cells.items, atlas_w); + return packMlnOpts(a, ar, cells.items, atlas_w, want_pixels); } // Composite a comma-joined glyph list (e.g. "SOUNDSC3,SOUNDS12,SOUNDS54") into @@ -647,6 +654,15 @@ fn lessMlnByHeight(_: void, a: MlnCell, b: MlnCell) bool { // Shelf-pack MlnCells and emit the MapLibre sprite JSON {x,y,width,height, // pixelRatio} + atlas PNG. `cells_in` must already be in id order (stable ties). fn packMln(a: std.mem.Allocator, ar: std.mem.Allocator, cells_in: []MlnCell, width: u32) !Atlas { + return packMlnOpts(a, ar, cells_in, width, true); +} + +// `want_pixels = false` computes ONLY the layout (cell rects + dimensions): +// no pixel compositing and — crucially — no PNG encode. The in-process +// GPU-scene atlas consumer reads nothing but the layout, yet paid the full +// zlib compress of a device-density atlas (~2/3 of the render path's cycles +// in a field profile) every time the shared atlases (re)built. +fn packMlnOpts(a: std.mem.Allocator, ar: std.mem.Allocator, cells_in: []MlnCell, width: u32, want_pixels: bool) !Atlas { std.sort.insertion(MlnCell, cells_in, {}, lessMlnByHeight); const Placed = struct { x: u32, y: u32, w: u32, h: u32, ratio: f64 }; var placed = std.StringHashMap(Placed).init(ar); @@ -666,23 +682,25 @@ fn packMln(a: std.mem.Allocator, ar: std.mem.Allocator, cells_in: []MlnCell, wid } const height = pen_y + row_h + pad; - const rgba = try ar.alloc(u8, @as(usize, width) * height * 4); - @memset(rgba, 0); - for (cells_in) |c| { - const p = placed.get(c.name).?; - var row: u32 = 0; - while (row < c.h) : (row += 1) { - const src_off = @as(usize, row) * c.w * 4; - const dst_off = (@as(usize, p.y + row) * width + p.x) * 4; - @memcpy(rgba[dst_off .. dst_off + c.w * 4], c.rgba[src_off .. src_off + c.w * 4]); + var png: []u8 = &.{}; + if (want_pixels) { + const rgba = try ar.alloc(u8, @as(usize, width) * height * 4); + @memset(rgba, 0); + for (cells_in) |c| { + const p = placed.get(c.name).?; + var row: u32 = 0; + while (row < c.h) : (row += 1) { + const src_off = @as(usize, row) * c.w * 4; + const dst_off = (@as(usize, p.y + row) * width + p.x) * 4; + @memcpy(rgba[dst_off .. dst_off + c.w * 4], c.rgba[src_off .. src_off + c.w * 4]); + } } + var png_len: c_int = 0; + const png_ptr = tg_png_encode(rgba.ptr, @intCast(width), @intCast(height), &png_len) orelse return error.PngEncode; + defer tg_svg_free(png_ptr); + png = try a.dupe(u8, png_ptr[0..@intCast(png_len)]); } - var png_len: c_int = 0; - const png_ptr = tg_png_encode(rgba.ptr, @intCast(width), @intCast(height), &png_len) orelse return error.PngEncode; - defer tg_svg_free(png_ptr); - const png = try a.dupe(u8, png_ptr[0..@intCast(png_len)]); - // MapLibre sprite JSON: names sorted, {x,y,width,height,pixelRatio}. var names = std.ArrayList([]const u8).empty; var it = placed.keyIterator(); diff --git a/src/style/mariner.zig b/src/style/mariner.zig index 08d8dc5c..e0377404 100644 --- a/src/style/mariner.zig +++ b/src/style/mariner.zig @@ -295,13 +295,17 @@ pub fn seabedTokenExpr(b: B, m: *const Settings) !Value { b.s("DEPIT"), }); } + // shallow <= safety <= deep, per S-52 — see resolve.seabedToken (the + // engine twin of this expression; keep the two in step). + const eff_deep = @max(m.deep_contour, m.safety_contour); + const eff_shallow = @min(m.shallow_contour, m.safety_contour); return b.arr(&.{ b.s("case"), - try band(b, d1, d2, m.deep_contour), + try band(b, d1, d2, eff_deep), b.s("DEPDW"), try band(b, d1, d2, m.safety_contour), b.s("DEPMD"), - try band(b, d1, d2, m.shallow_contour), + try band(b, d1, d2, eff_shallow), b.s("DEPMS"), try band(b, d1, d2, 0.0), b.s("DEPVS"), diff --git a/src/tile57.zig b/src/tile57.zig index 71a78f46..441892ea 100644 --- a/src/tile57.zig +++ b/src/tile57.zig @@ -37,6 +37,11 @@ pub const Chart = chart.Chart; pub const Format = chart.Format; pub const ChartInput = chart.ChartInput; pub const Progress = chart.Progress; +/// A draw-ready GPU scene: geometry already triangulated, in paint order, and +/// split into one-pipeline ranges. Built by `Chart.renderGpuScene` (one chart) or +/// `compose.renderGpuScene` (a whole library); free with `GpuScene.deinit`. The +/// Zig side of the C ABI's `tile57_gpu_scene`. +pub const GpuScene = chart.GpuScene; /// Streaming ENC_ROOT open (read a chart's bytes on demand, low memory): see /// Chart.openChartsStreaming. pub const ChartMeta = chart.ChartMeta; @@ -80,6 +85,10 @@ pub const compose = struct { pub const renderView = chart.renderComposeView; /// The composed world-space surface stream (the GPU vector twin). pub const renderSurfaceView = chart.renderComposeSurfaceView; + /// The composed draw-ready GPU scene (the GPU twin of `renderSurfaceView`): a + /// whole chart library into one `GpuScene`, seams stitched across cells. The + /// Zig side of the C ABI's `tile57_compose_gpu_scene`. + pub const renderGpuScene = chart.renderComposeGpuScene; /// The composed view-level, globally-decluttered TEXT-only pass (draws no /// geometry — the host draws that from its per-tile cache). pub const renderLabels = chart.renderComposeLabels; diff --git a/src/tiles/pmtiles.zig b/src/tiles/pmtiles.zig index 9b8915d1..2afc4c3b 100644 --- a/src/tiles/pmtiles.zig +++ b/src/tiles/pmtiles.zig @@ -281,7 +281,12 @@ pub fn deserializeDir(a: Allocator, buf: []const u8) ![]Entry { pub const Reader = struct { bytes: []const u8, header: Header, - root: []Entry, + // Decoded lazily on the first tile probe: a composed library opens thousands + // of archives, most never touched in a session — an eager root decode cost + // ~80MB and seconds of open time across a full ENC library. Until first use + // the arena has no chunks at all. + root: []Entry = &.{}, + root_done: bool = false, arena: std.heap.ArenaAllocator, // Deserialized leaf directories by leaf offset. A compositor probes a reader once // per (tile, pass); re-deserializing the same leaf into the arena on EVERY probe @@ -291,11 +296,26 @@ pub const Reader = struct { pub fn init(gpa: Allocator, bytes: []const u8) !Reader { const header = try Header.parse(bytes); - var arena = std.heap.ArenaAllocator.init(gpa); - const a = arena.allocator(); - const root_raw = try maybeDecompress(a, bytes[@intCast(header.root_dir_offset)..][0..@intCast(header.root_dir_length)], header.internal_compression); - const root = try deserializeDir(a, root_raw); - return .{ .bytes = bytes, .header = header, .root = root, .arena = arena }; + return .{ .bytes = bytes, .header = header, .arena = std.heap.ArenaAllocator.init(gpa) }; + } + + // Decompress with the arena's CHILD allocator and free after decode, so the + // arena retains only the Entry slices — an arena'd gzip output would sit as + // dead weight in every touched reader for the life of the process. + fn decodeDir(r: *Reader, off: usize, len: usize) ![]Entry { + const scratch = r.arena.child_allocator; + const comp = r.header.internal_compression; + const raw = try maybeDecompress(scratch, r.bytes[off..][0..len], comp); + defer if (comp != .none) scratch.free(@constCast(raw)); + return deserializeDir(r.arena.allocator(), raw); + } + + fn ensureRoot(r: *Reader) ![]Entry { + if (!r.root_done) { + r.root = try r.decodeDir(@intCast(r.header.root_dir_offset), @intCast(r.header.root_dir_length)); + r.root_done = true; + } + return r.root; } pub fn deinit(r: *Reader) void { @@ -313,7 +333,7 @@ pub const Reader = struct { /// Return the raw (still tile-compressed) bytes for a tile, or null if absent. pub fn getCompressed(r: *Reader, z: u8, x: u32, y: u32) !?[]const u8 { const tid = zxyToTileId(z, x, y); - var dir = r.root; + var dir = try r.ensureRoot(); var depth: u8 = 0; while (depth < 4) : (depth += 1) { const idx = findEntry(dir, tid) orelse return null; @@ -324,8 +344,7 @@ pub const Reader = struct { const gop = try r.leaves.getOrPut(a, e.offset); if (!gop.found_existing) { errdefer _ = r.leaves.remove(e.offset); - const raw = try maybeDecompress(a, r.bytes[@intCast(r.header.leaf_dir_offset + e.offset)..][0..e.length], r.header.internal_compression); - gop.value_ptr.* = try deserializeDir(a, raw); + gop.value_ptr.* = try r.decodeDir(@intCast(r.header.leaf_dir_offset + e.offset), e.length); } dir = gop.value_ptr.*; continue; @@ -518,8 +537,10 @@ pub const StreamWriter = struct { .max_lon_e7 = opts.max_lon_e7, .max_lat_e7 = opts.max_lat_e7, .center_zoom = opts.center_zoom orelse min_z, - .center_lon_e7 = @divTrunc(opts.min_lon_e7 + opts.max_lon_e7, 2), - .center_lat_e7 = @divTrunc(opts.min_lat_e7 + opts.max_lat_e7, 2), + // Midpoints in i64: two e7 longitudes near the antimeridian sum + // past i32 (±1.8e9 each) — overflowed the bake for such cells. + .center_lon_e7 = @intCast(@divTrunc(@as(i64, opts.min_lon_e7) + opts.max_lon_e7, 2)), + .center_lat_e7 = @intCast(@divTrunc(@as(i64, opts.min_lat_e7) + opts.max_lat_e7, 2)), }; var hbuf: [HEADER_LEN]u8 = undefined; header.serialize(&hbuf); diff --git a/tools/compose_tile.zig b/tools/compose_tile.zig index e9fabad5..a9a96ad3 100644 --- a/tools/compose_tile.zig +++ b/tools/compose_tile.zig @@ -16,11 +16,11 @@ const common = @import("common.zig"); const Flags = common.Flags; const usageErr = common.usageErr; -// Monotonic nanoseconds (std.time has no Timer in this toolchain). -fn nowNs() u64 { - var ts: std.os.linux.timespec = undefined; - _ = std.os.linux.clock_gettime(std.os.linux.CLOCK.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec)); +// Monotonic nanoseconds via the std.Io clock (cross-platform — the POSIX +// clock_gettime binding doesn't exist under the Windows calling convention, and +// std.time has no Timer in this toolchain). `.awake` is CLOCK_MONOTONIC. +fn nowNs(io: std.Io) u64 { + return @intCast(std.Io.Clock.awake.now(io).nanoseconds); } pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { @@ -107,7 +107,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } // Open the resident source (mmap archives + partition once) — the amortised cost. - const open_t0 = nowNs(); + const open_t0 = nowNs(io); const src = (compose.ComposeSource.openFiles(io, a, paths.items, load_bytes) catch |err| { std.debug.print("error: open compose source failed ({s})\n", .{@errorName(err)}); return; @@ -116,7 +116,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { return; }; defer src.deinit(); - const open_ms = @as(f64, @floatFromInt(nowNs() - open_t0)) / 1e6; + const open_ms = @as(f64, @floatFromInt(nowNs(io) - open_t0)) / 1e6; std.debug.print("opened {d} cell(s), partition {s}, in {d:.1} ms (serve z {d}..{d})\n", .{ src.readers.len, if (load_bytes != null) "loaded" else "built", open_ms, src.minz, src.loop_max }); // Artifact sweep: compose every in-bounds tile at the scan zooms and report @@ -163,10 +163,10 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } // Serve the requested tile. - const serve_t0 = nowNs(); + const serve_t0 = nowNs(io); const res = try src.tile(a, z, tx, ty); const tile = res.tile; - const serve_ms = @as(f64, @floatFromInt(nowNs() - serve_t0)) / 1e6; + const serve_ms = @as(f64, @floatFromInt(nowNs(io) - serve_t0)) / 1e6; if (tile) |t| { std.debug.print("served z{d}/{d}/{d}: {d} bytes (raw MLT, owned={}) in {d:.3} ms\n", .{ z, tx, ty, t.len, res.owned, serve_ms }); if (out) |op| std.Io.Dir.cwd().writeFile(io, .{ .sub_path = op, .data = t }) catch |err| @@ -179,7 +179,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { const half = bench / 2; var served: usize = 0; var bytes: usize = 0; - const bench_t0 = nowNs(); + const bench_t0 = nowNs(io); var dx: u32 = 0; while (dx < bench) : (dx += 1) { var dy: u32 = 0; @@ -194,7 +194,7 @@ pub fn run(io: std.Io, a: std.mem.Allocator, args: []const [:0]const u8) !void { } } } - const total_ms = @as(f64, @floatFromInt(nowNs() - bench_t0)) / 1e6; + const total_ms = @as(f64, @floatFromInt(nowNs(io) - bench_t0)) / 1e6; const n: f64 = @floatFromInt(bench * bench); std.debug.print("bench: {d}/{d} tiles owned, queried {d} in {d:.1} ms = {d:.3} ms/tile ({d} bytes total)\n", .{ served, @as(u32, bench * bench), @as(u32, bench * bench), total_ms, total_ms / n, bytes }); }