Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@ __pycache__/
# Fetched zig packages, materialized per-project (the S-101 catalogue lands
# here when the submodule is not initialized).
zig-pkg/

# Local work in progress.
scratchpad/
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ Tiles are made one way — bake each cell to its own PMTiles, then compose on
demand — the structure `tile57 bake ENC_ROOT -o out/` writes:

```c
// out/ holds tiles/<CELL>.pmtiles (one per cell) + partition.tpart
const char *paths[] = { "out/tiles/US5MD1MC.pmtiles" };
// out/ holds <CELL>/<CELL>.pmtiles (one directory per cell, with the files it
// references) + partition.tpart
const char *paths[] = { "out/US5MD1MC/US5MD1MC.pmtiles" };
tile57_compose_source *src = tile57_compose_open(paths, 1, "out/partition.tpart");

uint8_t *tile; size_t n;
Expand All @@ -129,8 +130,8 @@ Any `.000` — native S-101 or S-57 — works everywhere; the format is auto-det

```sh
zig build # builds zig-out/bin/tile57
tile57 bake CELL.000 -o out/ # one cell -> out/tiles/<CELL>.pmtiles + partition.tpart
tile57 bake ENC_ROOT -o out/ # whole catalogue -> per-cell tiles/ + partition.tpart
tile57 bake CELL.000 -o out/ # one cell -> out/<CELL>/<CELL>.pmtiles + partition.tpart
tile57 bake ENC_ROOT -o out/ # whole catalogue -> one directory per cell + partition.tpart
tile57 assets -o assets/ # colortables + linestyles + sprite + patterns
tile57 png ENC_ROOT --view -76.48,38.974,15 --size 1600x1200 -o chart.png
tile57 pdf ENC_ROOT --view -76.48,38.974,15 --size 1600x1200 -o chart.pdf
Expand Down
31 changes: 31 additions & 0 deletions include/tile57.h
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,37 @@ tile57_status tile57_compose_labels(tile57_compose *c, double lon, double lat, d
const tile57_mariner *m,
const tile57_surface_cb *surface, tile57_error *err);

/* ---- auxiliary files -------------------------------------------------------
*
* A feature can point at a text file or a picture instead of carrying it:
* TXTDSC and NTXTDS name a text file, PICREP names a picture, and S-101 puts the
* same thing in a `fileReference`. `tile57 bake` writes those files beside the
* chart they belong to, in the shape an exchange set uses:
*
* out/US5GU3TC/US5GU3TC.pmtiles
* out/US5GU3TC/US299TCA.TXT
* out/US5GU3TC/index.json
*
* Read them through these calls rather than off the disk, so the layout can
* change without breaking a client. */

typedef struct tile57_aux tile57_aux;

/* Open the auxiliary files of a chart directory. *out is NULL with TILE57_OK
* when the chart references nothing. Close with tile57_aux_close. */
tile57_status tile57_aux_open(const char *dir, tile57_aux **out, tile57_error *err);

/* The bytes and the MIME type of one referenced file, by the name the feature
* carries. The match ignores case and any directory part. *bytes is NULL and
* *len is 0 when the chart has no such file. The bytes belong to the handle and
* stay valid until tile57_aux_close; *mime is a static string. */
tile57_status tile57_aux_get(tile57_aux *a, const char *name,
const uint8_t **bytes, size_t *len,
const char **mime, tile57_error *err);

/* Release the handle and every file read through it. */
void tile57_aux_close(tile57_aux *a);

/* The composed cursor pick (S-52 §10.8, across chart boundaries): tile57_chart_query across
* the whole composed set. */
tile57_status tile57_compose_query(tile57_compose *c, double lon, double lat, double zoom,
Expand Down
198 changes: 198 additions & 0 deletions src/auxfiles.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
//! Auxiliary files: the external resources an ENC feature points at by name
//! instead of carrying inline. TXTDSC and NTXTDS name a text file; PICREP names
//! a picture. They ship in the exchange set beside the .000 cells, and a baked
//! archive carries only the NAME, so a pick report cannot read them.
//!
//! The bake keeps the ENC_ROOT shape: one directory per chart, holding the
//! archive and the files that chart references.
//!
//! tiles/US5MD1MC/US5MD1MC.pmtiles
//! tiles/US5MD1MC/US348MDE.TXT
//! tiles/US5MD1MC/index.json
//!
//! A chart directory is therefore self-contained: copy it and the report still
//! reads its caution note. The files are loose, so they work offline from any
//! static host, an SD card or a file:// URL, with no archive to unpack.
//!
//! A reference is keyed by the UPPER-CASED BASENAME. S-57 stores the value
//! upper-cased, and exchange sets differ in case across platforms, so a
//! case-sensitive lookup misses on the wrong filesystem.

const std = @import("std");

pub const index_name = "index.json";
pub const version = 1;

/// The lookup key for a reference: the bare basename, upper-cased.
pub fn key(alloc: std.mem.Allocator, name: []const u8) ![]u8 {
const base = std.fs.path.basename(name);
const out = try alloc.alloc(u8, base.len);
for (base, 0..) |c, i| out[i] = std.ascii.toUpper(c);
return out;
}

/// The MIME type a client needs to render the stored file.
pub fn mime(name: []const u8) []const u8 {
const ext = std.fs.path.extension(name);
var buf: [8]u8 = undefined;
if (ext.len == 0 or ext.len > buf.len) return "application/octet-stream";
const lower = std.ascii.lowerString(buf[0..ext.len], ext);
if (std.mem.eql(u8, lower, ".txt")) return "text/plain";
if (std.mem.eql(u8, lower, ".png")) return "image/png";
if (std.mem.eql(u8, lower, ".jpg") or std.mem.eql(u8, lower, ".jpeg")) return "image/jpeg";
if (std.mem.eql(u8, lower, ".tif") or std.mem.eql(u8, lower, ".tiff")) return "image/tiff";
return "application/octet-stream";
}

/// True for a file that is aux CONTENT. The catalogue and the readmes are
/// exchange-set plumbing, not feature data.
pub fn isContent(name: []const u8) bool {
const base = std.fs.path.basename(name);
var upper: [64]u8 = undefined;
if (base.len <= upper.len) {
const u = std.ascii.upperString(upper[0..base.len], base);
if (std.mem.startsWith(u8, u, "README")) return false;
if (std.mem.startsWith(u8, u, "CATALOG")) return false;
}
const ext = std.fs.path.extension(base);
var buf: [8]u8 = undefined;
if (ext.len == 0 or ext.len > buf.len) return false;
const lower = std.ascii.lowerString(buf[0..ext.len], ext);
inline for (.{ ".txt", ".tif", ".tiff", ".jpg", ".jpeg", ".png" }) |e| {
if (std.mem.eql(u8, lower, e)) return true;
}
return false;
}

/// One file to write.
pub const File = struct {
owner: []const u8 = "", // the chart that references it (its ENC_ROOT directory)
name: []const u8, // the name a feature references it by
bytes: []const u8,
};

/// Write `files` beside the chart in `dir`, with the manifest. Returns the
/// number written; an empty list writes nothing.
///
/// A picture is stored as it arrives. The Go predecessor transcoded TIFF to PNG
/// for the browser, which cannot decode TIFF; every native client can, so the
/// engine keeps the original bytes and states the type in the manifest.
pub fn writeDir(io: std.Io, alloc: std.mem.Allocator, dir: []const u8, files: []const File) !usize {
if (files.len == 0) return 0;
try std.Io.Dir.cwd().createDirPath(io, dir);

var manifest = std.ArrayList(u8).empty;
defer manifest.deinit(alloc);
try manifest.print(alloc, "{{\n \"version\": {d},\n \"files\": {{\n", .{version});

var written: usize = 0;
for (files) |f| {
const k = try key(alloc, f.name);
defer alloc.free(k);
const stored = std.fs.path.basename(f.name);
const path = try std.fs.path.join(alloc, &.{ dir, stored });
defer alloc.free(path);
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = f.bytes });
if (written > 0) try manifest.appendSlice(alloc, ",\n");
try manifest.print(alloc, " \"{s}\": {{ \"stored\": \"{s}\", \"type\": \"{s}\" }}", .{ k, stored, mime(stored) });
written += 1;
}
try manifest.appendSlice(alloc, "\n }\n}\n");

const index_path = try std.fs.path.join(alloc, &.{ dir, index_name });
defer alloc.free(index_path);
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = index_path, .data = manifest.items });
return written;
}

/// A read handle over an aux directory: the manifest in memory, the files read
/// on demand and cached.
pub const Reader = struct {
alloc: std.mem.Allocator,
dir: []u8,
entries: std.StringHashMapUnmanaged(Entry) = .empty,
cache: std.StringHashMapUnmanaged([]u8) = .empty,

pub const Entry = struct { stored: []u8, mime: []u8 };

/// Open a chart directory and read its manifest. Returns null when there is
/// none, which is what a chart with no referenced files leaves behind.
pub fn open(io: std.Io, alloc: std.mem.Allocator, dir: []const u8) !?Reader {
const index_path = try std.fs.path.join(alloc, &.{ dir, index_name });
defer alloc.free(index_path);
const bytes = std.Io.Dir.cwd().readFileAlloc(io, index_path, alloc, .unlimited) catch return null;
defer alloc.free(bytes);

var self = Reader{ .alloc = alloc, .dir = try alloc.dupe(u8, dir) };
errdefer self.deinit();

const parsed = std.json.parseFromSlice(std.json.Value, alloc, bytes, .{}) catch return null;
defer parsed.deinit();
const files = parsed.value.object.get("files") orelse return self;
var it = files.object.iterator();
while (it.next()) |kv| {
const stored = kv.value_ptr.object.get("stored") orelse continue;
const typ = kv.value_ptr.object.get("type") orelse continue;
try self.entries.put(alloc, try alloc.dupe(u8, kv.key_ptr.*), .{
.stored = try alloc.dupe(u8, stored.string),
.mime = try alloc.dupe(u8, typ.string),
});
}
return self;
}

/// The bytes and the type for a reference, or null when the directory has no
/// such file. The bytes stay valid until deinit.
pub fn get(self: *Reader, io: std.Io, name: []const u8) !?struct { bytes: []const u8, mime: []const u8 } {
const k = try key(self.alloc, name);
defer self.alloc.free(k);
const entry = self.entries.get(k) orelse return null;
if (self.cache.get(k)) |bytes| return .{ .bytes = bytes, .mime = entry.mime };

const path = try std.fs.path.join(self.alloc, &.{ self.dir, entry.stored });
defer self.alloc.free(path);
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, self.alloc, .unlimited) catch return null;
try self.cache.put(self.alloc, try self.alloc.dupe(u8, k), bytes);
return .{ .bytes = bytes, .mime = entry.mime };
}

pub fn deinit(self: *Reader) void {
var it = self.entries.iterator();
while (it.next()) |kv| {
self.alloc.free(kv.key_ptr.*);
self.alloc.free(kv.value_ptr.stored);
self.alloc.free(kv.value_ptr.mime);
}
self.entries.deinit(self.alloc);
var ci = self.cache.iterator();
while (ci.next()) |kv| {
self.alloc.free(kv.key_ptr.*);
self.alloc.free(kv.value_ptr.*);
}
self.cache.deinit(self.alloc);
self.alloc.free(self.dir);
}
};

test "key upper-cases the basename" {
const a = std.testing.allocator;
const k = try key(a, "ENC_ROOT/US5MD1MC/us348mde.txt");
defer a.free(k);
try std.testing.expectEqualStrings("US348MDE.TXT", k);
}

test "isContent takes text and pictures, not the catalogue" {
try std.testing.expect(isContent("US348MDE.TXT"));
try std.testing.expect(isContent("pic.TIF"));
try std.testing.expect(isContent("a/b/photo.jpeg"));
try std.testing.expect(!isContent("CATALOG.031"));
try std.testing.expect(!isContent("README.TXT"));
try std.testing.expect(!isContent("US5MD1MC.000"));
}

test "mime states what a client must decode" {
try std.testing.expectEqualStrings("text/plain", mime("A.TXT"));
try std.testing.expectEqualStrings("image/tiff", mime("A.tif"));
try std.testing.expectEqualStrings("image/jpeg", mime("A.JPG"));
try std.testing.expectEqualStrings("application/octet-stream", mime("A.bin"));
}
1 change: 1 addition & 0 deletions src/bake_root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub const s101_instructions = root.s101_instructions;
pub const s101_adapter = root.s101_adapter;
pub const catalogue = root.catalogue;
pub const bake_enc = root.bake_enc;
pub const auxfiles = root.auxfiles;
pub const geometry = @import("geometry"); // integer geometry: boolean, plane, partition

pub const portray = @import("portray");
53 changes: 53 additions & 0 deletions src/capi.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

const std = @import("std");
const chart = @import("chart.zig");
const auxfiles = @import("engine").auxfiles; // via the named module: engine owns the file
const s57 = @import("s57");
const bundle = @import("bundle"); // portrayal-asset emitters + the partition debug bake
const compose = @import("compose"); // the runtime tile compositor (tile57_compose_*)
Expand Down Expand Up @@ -875,6 +876,58 @@ export fn tile57_chart_close(handle: ?*Chart) callconv(.c) void {
if (handle) |s| s.deinit();
}

// ---- auxiliary files (the text and pictures a cell points at) --------------

/// Open the auxiliary files of a chart directory. The handle owns the manifest
/// and every file it has read. See tile57.h.
export fn tile57_aux_open(dir: ?[*:0]const u8, out: ?*?*auxfiles.Reader, err: ?*CError) callconv(.c) c_int {
const o = out orelse return failWith(err, .badarg, "out must not be null");
o.* = null;
const d = spanOpt(dir) orelse return failWith(err, .badarg, "dir must not be null");
const opened = auxfiles.Reader.open(sharedIo(), gpa, d) catch |e| return failCtx(err, e, d);
const r = opened orelse return OK; // no manifest: the chart references nothing
const handle = gpa.create(auxfiles.Reader) catch return failWith(err, .nomem, "out of memory");
handle.* = r;
o.* = handle;
return OK;
}

/// The bytes and the MIME type for a referenced file, by the name the feature
/// carries (TXTDSC, PICREP, or an S-101 fileReference). The bytes stay valid
/// until tile57_aux_close. NULL/0 when the chart has no such file. See tile57.h.
export fn tile57_aux_get(handle: ?*auxfiles.Reader, name: ?[*:0]const u8, bytes: ?*?[*]const u8, len: ?*usize, mime: ?*?[*:0]const u8, err: ?*CError) callconv(.c) c_int {
const h = handle orelse return failWith(err, .badarg, "aux must not be null");
const b = bytes orelse return failWith(err, .badarg, "bytes must not be null");
const n = len orelse return failWith(err, .badarg, "len must not be null");
b.* = null;
n.* = 0;
if (mime) |m| m.* = null;
const nm = spanOpt(name) orelse return failWith(err, .badarg, "name must not be null");
const found = (h.get(sharedIo(), nm) catch |e| return fail(err, e)) orelse return OK;
b.* = found.bytes.ptr;
n.* = found.bytes.len;
if (mime) |m| m.* = mimeZ(found.mime);
return OK;
}

/// A static NUL-terminated string for a MIME type the manifest holds, so the
/// caller gets a C string without owning it.
fn mimeZ(m: []const u8) [*:0]const u8 {
if (std.mem.eql(u8, m, "text/plain")) return "text/plain";
if (std.mem.eql(u8, m, "image/png")) return "image/png";
if (std.mem.eql(u8, m, "image/jpeg")) return "image/jpeg";
if (std.mem.eql(u8, m, "image/tiff")) return "image/tiff";
return "application/octet-stream";
}

/// Release the auxiliary files of a chart, and every file read through it.
export fn tile57_aux_close(handle: ?*auxfiles.Reader) callconv(.c) void {
if (handle) |h| {
h.deinit();
gpa.destroy(h);
}
}

// ===========================================================================
// 5. Compose — the runtime compositor over open charts (see tile57.h)
// ===========================================================================
Expand Down
34 changes: 34 additions & 0 deletions src/render/query.zig
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pub const QuerySurface = struct {
.drawText = drawText,
.endFeature = endFeature,
.endScene = endScene,
.pick_area = pickArea,
};

pub fn asSurface(self: *QuerySurface) rs.Surface {
Expand Down Expand Up @@ -121,6 +122,12 @@ pub const QuerySurface = struct {
const self = sp(ctx);
if (self.pointInRings(rings)) self.hit = true;
}
/// An area the chart does not fill: a note area answers a pick anywhere
/// inside it, not only under its INFORM01 marker.
fn pickArea(ctx: *anyopaque, rings: []const []const rs.TilePoint) anyerror!void {
const self = sp(ctx);
if (self.pointInRings(rings)) self.hit = true;
}
fn strokeLine(ctx: *anyopaque, _: rs.ColorToken, _: f64, _: rs.Dash, lines: []const []const rs.TilePoint, _: ?f64) anyerror!void {
const self = sp(ctx);
if (self.nearLines(lines)) self.hit = true;
Expand All @@ -138,3 +145,30 @@ pub const QuerySurface = struct {
if (self.nearPoint(at)) self.hit = true;
}
};

test "a note area answers a pick inside it, and only inside it" {
const Seen = struct {
var n: usize = 0;
fn feature(_: ?*anyopaque, _: [*]const u8, _: usize, _: [*]const u8, _: usize, _: [*]const u8, _: usize) callconv(.c) void {
n += 1;
}
};
const cb = QueryCb{ .ctx = null, .feature = Seen.feature };
// A square from (100,100) to (900,900) — the note area, which draws no fill.
const ring = [_]rs.TilePoint{
.{ .x = 100, .y = 100 }, .{ .x = 900, .y = 100 },
.{ .x = 900, .y = 900 }, .{ .x = 100, .y = 900 },
};
const rings = [_][]const rs.TilePoint{&ring};
const meta = rs.FeatureMeta{ .class = "M_NPUB" };

for ([_][2]f64{ .{ 500, 500 }, .{ 2000, 500 } }, [_]usize{ 1, 0 }) |at, want| {
Seen.n = 0;
var qs = QuerySurface{ .qx = at[0], .qy = at[1], .radius = 96, .view_zoom = 12, .cb = &cb };
const surf = qs.asSurface();
try surf.beginFeature(&meta);
try surf.pickArea(&rings);
try surf.endFeature();
try std.testing.expectEqual(want, Seen.n);
}
}
Loading
Loading