Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
2accec3
Merge pull request #33 from beetlebugorg/feat/label-tiers
beetlebugorg Jul 22, 2026
798c074
scene: wrap view tiles across the antimeridian
beetlebugorg Jul 22, 2026
b25038e
build: iOS cross-compilation for libtile57.a
beetlebugorg Jul 23, 2026
125dc35
bake: emit shared atlas sidecars (_assets/) from bake_tree
beetlebugorg Jul 23, 2026
138e4e2
Revert "bake: emit shared atlas sidecars (_assets/) from bake_tree"
beetlebugorg Jul 23, 2026
03d120f
tools: compose-tile ran no further than nowNs() on macOS
beetlebugorg Jul 23, 2026
a583932
compose: TILE57_COMPOSE_DEBUG traces per-tile owner decisions
beetlebugorg Jul 23, 2026
d4644f7
gpu scene: a failed tile is a visible hole — never skip one silently
beetlebugorg Jul 23, 2026
d25f7e6
gpu scene: count empty tiles per build (fresh vs cached)
beetlebugorg Jul 23, 2026
3f0422e
gpu scene: never cache an empty tile
beetlebugorg Jul 23, 2026
ef0b8ae
compose: collapse same-(name,date) twin archives deterministically
beetlebugorg Jul 23, 2026
e187ff3
compose: print the composition-set facts at open
beetlebugorg Jul 23, 2026
55db674
build: vendored C opts out of UBSan (-fno-sanitize=undefined)
beetlebugorg Jul 23, 2026
7ebfc70
runtime engine tattoo: tile57_warmup logs the linked engine's commit
beetlebugorg Jul 23, 2026
ad924a4
gpu scene: byte-bound the tile geometry cache; self-heal failed builds
beetlebugorg Jul 23, 2026
3ca6122
diagnose owned-but-empty tiles by name, automatically
beetlebugorg Jul 23, 2026
314b981
explainEmpty covers the unowned case: charted ground owned by nobody
beetlebugorg Jul 23, 2026
702fc64
compose: say which partition served — sidecar or fresh build
beetlebugorg Jul 24, 2026
1340d10
compose: take the OS's math library out of tile-ownership decisions
beetlebugorg Jul 24, 2026
5c8365c
compose: cross-band overscale fallback — no void where any chart exists
beetlebugorg Jul 24, 2026
1303e5c
compose: complete cross-band fill (partial tiles), antimeridian split…
beetlebugorg Jul 24, 2026
f6cb7ca
scene: a served tile that fails decode/replay/compose must SCREAM
beetlebugorg Jul 24, 2026
9b503f2
compose: bound cross-band fill operands to the tile; per-tile OOM sel…
beetlebugorg Jul 24, 2026
6180d35
fix use-after-free: per-tile OOM valve must not free the walk's own t…
beetlebugorg Jul 24, 2026
420d422
compose: tile-bounded serving end to end; sidecar algorithm gate; bak…
beetlebugorg Jul 24, 2026
ea5e572
bake profiles itself: per-cell wall times, slowest cells printed per run
beetlebugorg Jul 24, 2026
ad35442
atlas failure must not latch; per-contributor decode arenas; measurem…
beetlebugorg Jul 24, 2026
a135fc5
compose: bound the fill chain's peak (ping-pong compaction) + measure…
beetlebugorg Jul 24, 2026
529fc32
tile57_trim_caches: the memory-warning valve a host can pull
beetlebugorg Jul 24, 2026
9ba45d5
gpu atlases: layout-only sprite pack — no pixels, no PNG, in the rend…
beetlebugorg Jul 24, 2026
270029d
allocate for a long-lived host: c_allocator, lazy dirs, no arena-stra…
beetlebugorg Jul 24, 2026
9e97a1a
per-vertex colour: whole paint bands become single draws
beetlebugorg Jul 24, 2026
0038f4e
export the GPU-scene ABI layout so hosts can gate on it at open
beetlebugorg Jul 24, 2026
6aec26d
paint-order depth on every vertex and quad: the overdraw contract
beetlebugorg Jul 24, 2026
0c920c5
the safety contour works: live shading, next-deeper snap, bold line
beetlebugorg Jul 24, 2026
4358a49
fetchable as a zig package: catalogue via lazy dep, real .paths
beetlebugorg Jul 25, 2026
08d7b6e
docs: split C/Zig API into per-surface pages; expose GPU scene in Zig
beetlebugorg Jul 25, 2026
db5d128
fix(windows): use the std.Io monotonic clock in compose-tile, not libc
beetlebugorg Jul 25, 2026
dd549c3
fix(test): link libc for the compose test binaries
beetlebugorg Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
48 changes: 8 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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
Expand Down
104 changes: 86 additions & 18 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" },
});
}

Expand Down Expand Up @@ -52,28 +87,33 @@ 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 <unistd.h>/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,
});
}

// Attach the vendored SVG rasterizer (nanosvg) + PNG encoder (stb_image_write)
// 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 +
Expand Down Expand Up @@ -352,18 +397,18 @@ 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
// emits colortables / sprites / patterns / style.json from these with no on-disk
// 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
Expand Down Expand Up @@ -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
});
Expand All @@ -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) —
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading