diff --git a/bindings/go/compose.go b/bindings/go/compose.go index 1405c709..92d5ea2b 100644 --- a/bindings/go/compose.go +++ b/bindings/go/compose.go @@ -173,6 +173,18 @@ func (c *ComposeSource) Meta() ComposeMeta { } } +// Skipped returns the charts handed to the open that embed no usable coverage. +// They own no ground and are absent from every composed tile, so this is what +// tells a complete quilt from one with holes in it. +func (c *ComposeSource) Skipped() uint32 { + c.mu.Lock() + defer c.mu.Unlock() + if c.ptr == nil { + return 0 + } + return uint32(C.tile57_compose_skipped(c.ptr)) +} + // Close releases the compositor, then any charts [OpenCompose] opened for it. // Borrowed charts (from [OpenComposeCharts]) stay open. Idempotent. diff --git a/docs/docs/api/compose.md b/docs/docs/api/compose.md index f6c81446..5b594b20 100644 --- a/docs/docs/api/compose.md +++ b/docs/docs/api/compose.md @@ -83,6 +83,11 @@ tile57_status tile57_compose_gpu_scene(tile57_compose *c, double lon, double lat /* Fill *out with the compositor's zoom range + union coverage bounds. */ void tile57_compose_get_meta(tile57_compose *c, tile57_compose_meta *out); +/* Charts handed to the open that embed no usable coverage: they own no ground + * and are absent from every composed tile, so a host reads this to tell a + * complete quilt from one with holes in it. 0 for a NULL handle. */ +uint32_t tile57_compose_skipped(tile57_compose *c); + /* 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, diff --git a/include/tile57.h b/include/tile57.h index 10fbc1de..4384037f 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -537,6 +537,14 @@ typedef struct { uint8_t tile_type; /* tile57_tile_type */ int32_t native_scale; bool is_raster; /* tiles are images, not vector tiles */ + uint32_t skipped_cells; /* cells handed to the open that produced no chart. + * The open succeeds while one parses, so this tells a + * chart set with a gap in it from a complete one. + * Set on a chart opened from ENC source. The opens + * this header exports are archive-backed, where it + * stays 0; the engine names each dropped cell on + * stderr as it goes. Appended for ABI-append-safety; a + * zeroed struct reads 0. */ } tile57_info; void tile57_chart_get_info(tile57_chart *chart, tile57_info *out); @@ -1635,6 +1643,15 @@ tile57_status tile57_compose_query(tile57_compose *c, double lon, double lat, do /* Fill *out with the compositor's zoom range + union coverage bounds. */ void tile57_compose_get_meta(tile57_compose *c, tile57_compose_meta *out); +/* Charts handed to the open that embed no usable coverage: they own no ground + * and are absent from every composed tile, so a host reads this to tell a + * complete quilt from one with holes in it. 0 for a NULL handle. + * + * A call rather than a tile57_compose_meta field: that struct ends on a double + * with no padding left, so any field added to it moves the bounds and grows the + * struct a host already allocates. */ +uint32_t tile57_compose_skipped(tile57_compose *c); + /* The deepest zoom the chart covering (lon,lat) can serve (its native window + * overscale fill-up). A host caps its per-view zoom-in here so it never magnifies * past that chart into nodata — unlike compose_meta.max_zoom, which is the diff --git a/src/capi.zig b/src/capi.zig index 22c2b36e..1942858b 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -884,6 +884,10 @@ const CInfo = extern struct { tile_type: u8, // the archive's stored encoding (TILE57_TILE_TYPE_*) native_scale: i32, // embedded compilation scale (1:N); 0 = derive from zoom band is_raster: bool, // the archive stores pictures, not vector tiles + // Cells handed to the open that produced no chart. The open succeeds while + // one parses, so this is what tells a host the set has a gap. Appended for + // ABI-append-safety; a zeroed struct reads 0. + skipped_cells: u32, }; // tile57_tile_type values (keep in sync with tile57.h). @@ -909,6 +913,7 @@ export fn tile57_chart_get_info(src: ?*Chart, out: ?*CInfo) callconv(.c) void { // nothing above tells it apart from a vector chart. Its tiles are images. // tile_type cannot carry this: TILE57_TILE_TYPE_MLT is 2, and 2 is PNG in // the PMTiles header. + o.skipped_cells = s.skipped_cells; o.is_raster = switch (s.tileType()) { .png, .jpeg, .webp, .avif => true, else => false, @@ -1627,6 +1632,10 @@ export fn tile57_compose_open( error.MixedChartKinds => failWith(err, .unsupported, mixed_kinds), else => fail(err, e), }) orelse return failWith(err, .unsupported, "no chart carries per-cell coverage"); + // What the open kept, subtracted from what it was handed. Counting the + // charts with no decoded coverage missed the ones openBorrowed drops later + // for an empty coverage ring, so charts + skipped came to less than n. + src.skipped = @intCast(n - src.readers.len); sidecar.refresh(io, src); o.* = src; return OK; @@ -1725,6 +1734,7 @@ export fn tile57_compose_rasters( error.MixedChartKinds => failWith(err, .unsupported, mixed_kinds), else => fail(err, e), }) orelse return failWith(err, .unsupported, "no raster chart carries a compilation scale and coverage"); + src.skipped = @intCast(n - src.readers.len); o.* = src; return OK; } @@ -1958,6 +1968,12 @@ export fn tile57_compose_get_meta(handle: ?*compose.ComposeSource, out: ?*CCompo }; } +/// Charts handed to the open that embed no usable coverage. See tile57.h. +export fn tile57_compose_skipped(handle: ?*compose.ComposeSource) callconv(.c) u32 { + const src = handle orelse return 0; + return src.skipped; +} + /// The deepest zoom the chart covering (lon,lat) can serve — the host caps its /// per-view zoom-in here so it never magnifies past that chart into nodata. Falls /// back to the library max where the point covers no cell. See tile57.h. diff --git a/src/chart.zig b/src/chart.zig index 61cae134..0e09ab53 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -388,13 +388,23 @@ fn cdup(bytes: []const u8) ?[*]u8 { // Peek `relpath`'s bbox+scale; on success append an index-aligned meta + a gpa-owned // copy of the path. Cells that don't read / have no coverage bbox are skipped (both // lists), keeping meta[i] and paths[i] aligned with the streaming cell index. -fn addPathCell(io: std.Io, dir: std.Io.Dir, relpath: []const u8, metas: *std.ArrayList(ChartMeta), paths: *std.ArrayList([]u8)) !void { - const bytes = dir.readFileAlloc(io, relpath, gpa, .limited(MAX_CELL_BYTES)) catch return; +fn addPathCell(io: std.Io, dir: std.Io.Dir, relpath: []const u8, metas: *std.ArrayList(ChartMeta), paths: *std.ArrayList([]u8)) !bool { + const bytes = dir.readFileAlloc(io, relpath, gpa, .limited(MAX_CELL_BYTES)) catch { + std.debug.print("CHART LOST {s}: cell did not read\n", .{relpath}); + return false; + }; defer gpa.free(bytes); - const m = peekAnyMeta(bytes) orelse return; - const bb = m.bounds orelse return; + const m = peekAnyMeta(bytes) orelse { + std.debug.print("CHART LOST {s}: cell did not parse\n", .{relpath}); + return false; + }; + const bb = m.bounds orelse { + std.debug.print("CHART LOST {s}: cell has no extent\n", .{relpath}); + return false; + }; try metas.append(gpa, .{ .west = bb[0], .south = bb[1], .east = bb[2], .north = bb[3], .cscl = m.cscl }); try paths.append(gpa, try gpa.dupe(u8, relpath)); + return true; } // Internal ChartReadFn for a path-backed chart: read cell `index`'s base .000 + its @@ -1155,7 +1165,13 @@ fn bakeFileWorker(ctx: *BakeFileCtx) void { const i = ctx.next.fetchAdd(1, .monotonic); if (i >= ctx.in_paths.len) return; bakeOneToFile(ctx, i); - if (ctx.label) |lb| lb(ctx.progress_ctx, @intCast(i)); // name the chart just finished + // The label names a chart that was written. It fired for every cell + // before, so a host printed a finished chart for one that failed. + if (ctx.ok[i]) { + if (ctx.label) |lb| lb(ctx.progress_ctx, @intCast(i)); + } else { + std.debug.print("CHART LOST {s}: bake produced no archive\n", .{ctx.in_paths[i]}); + } const d = ctx.done.fetchAdd(1, .monotonic) + 1; // attempted count (smooth progress) if (ctx.progress) |cb| { if (!cb(ctx.progress_ctx, d, @intCast(ctx.in_paths.len))) { @@ -2389,6 +2405,10 @@ pub const Chart = struct { /// partition sidecar the bake wrote next to the archives, so a host never /// has to know that file exists. source_path: ?[]u8 = null, + /// Cells handed to the open that produced no chart. The open succeeds while + /// one cell parses, so a host reads this to tell a chart set with a gap in + /// it from a complete one. + skipped_cells: u32 = 0, cache: std.AutoHashMap(u64, []u8), // tile key -> MVT bytes (owned) cache_max: usize = 8192, // Emit the per-feature pick-report attrs (s57/cell) on live-generated tiles. @@ -2459,8 +2479,12 @@ pub const Chart = struct { bake_enc.parallelFor(gpa, cells_in.len, &ow, OpenWork.run); var valid: usize = 0; - for (ok) |k| { - if (k) valid += 1; + for (ok, cells_in) |k, in| { + if (k) { + valid += 1; + } else { + std.debug.print("CHART LOST {s}: cell did not parse\n", .{in.name}); + } } if (valid == 0) return error.InvalidCell; // cells provided, but none parsed @@ -2481,6 +2505,7 @@ pub const Chart = struct { }; src.* = .{ .backend = .{ .cells = .{ .cells = cells, .rules_dir = dir_copy } }, + .skipped_cells = @intCast(cells_in.len - valid), .cache = std.AutoHashMap(u64, []u8).init(gpa), .pick_attrs = pick_attrs, }; @@ -2546,16 +2571,21 @@ pub const Chart = struct { for (paths.items) |p| gpa.free(p); paths.deinit(gpa); } + // Cells the walk found and could not use. The open succeeds on the + // rest, so this is what tells a host the set has a gap. + var skipped: u32 = 0; if (single_file) { - try addPathCell(io, dir, std.fs.path.basename(path), &metas, &paths); + if (!try addPathCell(io, dir, std.fs.path.basename(path), &metas, &paths)) skipped += 1; } else if (dir.readFileAlloc(io, "CATALOG.031", gpa, .limited(MAX_CELL_BYTES))) |cbytes| { defer gpa.free(cbytes); var carena = std.heap.ArenaAllocator.init(gpa); defer carena.deinit(); if (s57.parseCatalog(carena.allocator(), cbytes)) |entries| { for (entries) |e| { - if (e.is_cell) try addPathCell(io, dir, e.path, &metas, &paths); + if (e.is_cell) { + if (!try addPathCell(io, dir, e.path, &metas, &paths)) skipped += 1; + } } } } else |_| { @@ -2564,7 +2594,7 @@ pub const Chart = struct { while (try walker.next(io)) |entry| { if (entry.kind != .file) continue; if (!std.mem.endsWith(u8, entry.path, ".000")) continue; - try addPathCell(io, dir, entry.path, &metas, &paths); + if (!try addPathCell(io, dir, entry.path, &metas, &paths)) skipped += 1; } } if (metas.items.len == 0) return error.OpenFailed; @@ -2573,6 +2603,7 @@ pub const Chart = struct { // Chart owns the PathCtx (Io + Dir + paths) via ls.path_ctx, freed in deinit. const src = try openChartsStreaming(metas.items, pathRead, null, rules_dir, pick_attrs); errdefer src.deinit(); + src.skipped_cells = skipped; const ctx = try gpa.create(PathCtx); errdefer gpa.destroy(ctx); ctx.* = .{ .threaded = threaded, .io = io, .dir = dir, .paths = try paths.toOwnedSlice(gpa) }; @@ -4184,7 +4215,17 @@ pub fn bakeArchive( @memset(pas, null); var bw = BakeWork{ .sources = sources.items, .outs = outs, .arenas = pas, .rules_dir = dir, .build_geo = bake_enc.cacheGeoForBand(band) }; bake_enc.parallelFor(gpa, sources.items.len, &bw, BakeWork.run); - loaded += idxs.len; + // Count the cells that produced a backend. `idxs.len` counted the ones + // attempted, so progress reached the total whether they loaded or not. + for (outs, idxs) |o, ci| { + if (o != null) { + loaded += 1; + } else { + // The bake keeps going. This line is the report: a cell absent + // from the archive with no word for it reads as empty ocean. + std.debug.print("CHART LOST {s}: parse produced no cell\n", .{cells_in[ci].name}); + } + } if (progress) |cb| if (has_work) cb(user, 0, loaded, cells_in.len, band_ord - 1, band_count, @tagName(band).ptr); var backs = std.ArrayList(bake_enc.Backend).empty; diff --git a/src/compose/compose.zig b/src/compose/compose.zig index 1da74760..a86a5d71 100644 --- a/src/compose/compose.zig +++ b/src/compose/compose.zig @@ -668,6 +668,10 @@ pub const ComposeSource = struct { /// is common, and everything above it — clip a feature or stack a picture, /// portray or not — is not. Set at open and never changed. kind: Kind = .vector, + /// Charts handed to the open that embed no usable coverage. They own no + /// ground, so they are absent from every composed tile, and the open + /// succeeds without them. + skipped: u32 = 0, // A files-open owns its readers + mmaps (deinit closes them); a charts-open // borrows them from the charts, which must outlive this source. owns_archives: bool = true,