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
9 changes: 5 additions & 4 deletions docs/docs/api/render.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,11 @@ void tile57_chart_close(tile57_chart *chart);

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.
are inside, a line within a small radius, or a symbol whose drawn mark covers the
point. A buoy's or beacon's mark stands above its charted position, and the pick
follows what is drawn. 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
Expand Down
6 changes: 4 additions & 2 deletions include/tile57.h
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,10 @@ tile57_status tile57_chart_tile(tile57_chart *chart, uint8_t z, uint32_t x, uint
uint8_t **out, size_t *out_len, tile57_error *err);

/* Cursor object-query (S-52 §10.8 pick): feature() is invoked once per feature
* the point (lon,lat) falls in — area point-in-polygon, line/point within a
* small radius — with the S-57 object-class acronym, the attribute JSON
* the point (lon,lat) falls in — an area you are inside, a line within a small
* radius, or a symbol whose drawn mark covers the point. A buoy's or beacon's
* mark stands above its charted position, and the pick follows what is drawn.
* Each call carries the S-57 object-class acronym, the attribute JSON
* (acronym -> value), and the source chart name. Pointers are valid only for
* the duration of the call. */
typedef struct {
Expand Down
20 changes: 18 additions & 2 deletions src/chart.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1827,12 +1827,20 @@ pub fn composeQueryPoint(src: *compose_mod.ComposeSource, lon: f64, lat: f64, zo
const tx: u32 = @intFromFloat(@floor(world[0] * n));
const ty: u32 = @intFromFloat(@floor(world[1] * n));
const local = tile.project(lon, lat, z, tx, ty, tile.EXTENT);
// Symbol geometry, so the pick answers on the mark a symbol draws and not on
// its anchor alone. The store is per-palette but the geometry is not — a
// palette supplies only fill and stroke colours — so the day store serves
// every pick. A store failure degrades to the anchor radius.
const store: ?*sprite.CatalogStore = sharedStore(.day) catch null;
const upp = render.query.unitsPerPx(tile.EXTENT, zc, zoom);
var qs = render.query.QuerySurface{
.qx = @floatFromInt(local.x),
.qy = @floatFromInt(local.y),
.radius = 96.0, // ~6 px at native tile scale
.radius = 6.0 * upp, // 6 px, whatever the tile is stretched to
.view_zoom = zoom, // raw view zoom for the SCAMIN cull
.cb = cb,
.store = if (store) |st| st.asStore() else null,
.units_per_px = upp,
};
const surf = qs.asSurface();
try surf.beginScene(z);
Expand Down Expand Up @@ -2770,12 +2778,20 @@ pub const Chart = struct {
const tx: u32 = @intFromFloat(@floor(world[0] * n));
const ty: u32 = @intFromFloat(@floor(world[1] * n));
const local = t.project(lon, lat, z, tx, ty, t.EXTENT);
// Symbol geometry, so the pick answers on the mark a symbol draws and not
// on its anchor alone. Symbol geometry is palette-independent — a palette
// supplies only fill and stroke colours — so the day store serves every
// pick. A store failure degrades to the anchor radius.
const store: ?*sprite.CatalogStore = self.viewStoreFor(.day) catch null;
const upp = render.query.unitsPerPx(t.EXTENT, zc, zoom);
var qs = render.query.QuerySurface{
.qx = @floatFromInt(local.x),
.qy = @floatFromInt(local.y),
.radius = 96.0, // ~6 px at native tile scale
.radius = 6.0 * upp, // 6 px, whatever the tile is stretched to
.view_zoom = zoom, // raw view zoom for the SCAMIN cull
.cb = cb,
.store = if (store) |st| st.asStore() else null,
.units_per_px = upp,
};
const surf = qs.asSurface();
try surf.beginScene(z);
Expand Down
148 changes: 142 additions & 6 deletions src/render/query.zig
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
//! QuerySurface: a Surface backend for cursor object-query (S-52 §10.8 pick).
//! Given a point in a tile's local coordinates, it replays that tile and records
//! which features the point falls in — area point-in-polygon, line/point within
//! a small radius — reporting each hit feature's S-57 class + attribute JSON +
//! source cell through a C callback. The engine hands the class/s57_json/cell on
//! the FeatureMeta contract, so no S-57 decode is needed here.
//! which features the point falls in — an area you are inside, a line within a
//! small radius, or a symbol whose drawn mark covers the point. It reports each
//! hit feature's S-57 class + attribute JSON + source cell through a C callback.
//! The engine hands the class/s57_json/cell on the FeatureMeta contract, so no
//! S-57 decode is needed here.
const std = @import("std");
const rs = @import("surface.zig");
const resolve = @import("resolve.zig");
const sym = @import("symbols.zig");

/// C callback: one call per feature the query point falls in. Pointers are valid
/// only for the duration of the call.
Expand All @@ -15,12 +17,34 @@ pub const QueryCb = extern struct {
feature: *const fn (?*anyopaque, cls: [*]const u8, cls_len: usize, s57: [*]const u8, s57_len: usize, cell: [*]const u8, cell_len: usize) callconv(.c) void,
};

/// Tile units per reference pixel for a tile of level `tile_z` shown at
/// `view_zoom`.
///
/// A tile is `extent` units wide and covers 256 reference px at its own level,
/// so the ratio is extent/256 there. The pick replays the tile at a ROUNDED and
/// CLAMPED zoom, so the view is usually not at that level: every zoom level
/// above it doubles the px the tile covers and halves the units per px. Without
/// this the box for a symbol is 2^(view_zoom - tile_z) times too large, without
/// bound once the view runs past the archive's deepest zoom.
pub fn unitsPerPx(extent: i32, tile_z: f64, view_zoom: f64) f64 {
return @as(f64, @floatFromInt(extent)) / 256.0 / std.math.exp2(view_zoom - tile_z);
}

pub const QuerySurface = struct {
qx: f64,
qy: f64,
radius: f64, // near-hit radius for line/point features (tile units)
view_zoom: f64, // the view zoom, for the SCAMIN visibility cull
cb: *const QueryCb,
/// Catalogue symbol geometry, so a pick answers on the mark that is DRAWN.
/// Null falls back to the anchor radius alone.
store: ?sym.SymbolStore = null,
/// Tile units per reference pixel AT THE VIEW ZOOM. A symbol's drawn size is
/// fixed in reference px and the pick works in tile units, so the box test
/// needs the ratio. It is not a constant: the query replays the tile at a
/// rounded, clamped zoom, and a tile displayed above its own level is
/// stretched. Callers set it with unitsPerPx.
units_per_px: f64 = 16.0,
cur: rs.FeatureMeta = .{},
hit: bool = false,
visible: bool = false, // current feature passes SCAMIN at view_zoom
Expand Down Expand Up @@ -113,6 +137,32 @@ pub const QuerySurface = struct {
const dy = @as(f64, @floatFromInt(at.y)) - self.qy;
return dx * dx + dy * dy <= self.radius * self.radius;
}
/// A symbol is drawn AROUND its anchor, not on it: a buoy or a beacon puts
/// nearly all of its mark above the charted position, and the mariner clicks
/// the topmark that is visible. Test the drawn box.
///
/// The transform is pixel.zig pushSymbol's: local = (c - pivot) * scale*100
/// in reference px, then a rotation by rot_deg. Here it runs in tile units
/// (units_per_px carries the conversion) and inverted, so the query point is
/// compared in the symbol's own upright frame. The device scale is 1: the
/// query API takes no Settings, so a mariner's physical-size multiplier is
/// not visible here and the box only ever under-covers.
///
/// False when the store is absent, the name is a catalogue gap, or the
/// symbol has no geometry — the caller still has the anchor radius.
fn inSymbolExtent(self: *QuerySurface, name: rs.SymbolName, at: rs.TilePoint, rot_deg: f64, scale: f64) bool {
const store = self.store orelse return false;
const s = store.get(name) orelse return false;
const b = sym.bounds(s, scale * 100.0 * self.units_per_px) orelse return false;
const dx = self.qx - @as(f64, @floatFromInt(at.x));
const dy = self.qy - @as(f64, @floatFromInt(at.y));
const rad = -rot_deg * std.math.pi / 180.0; // into the symbol's frame
const c = @cos(rad);
const s_r = @sin(rad);
const lx = dx * c - dy * s_r;
const ly = dx * s_r + dy * c;
return lx >= b[0] and lx <= b[2] and ly >= b[1] and ly <= b[3];
}

fn fillArea(ctx: *anyopaque, _: rs.ColorToken, rings: []const []const rs.TilePoint, _: ?rs.DepthRange) anyerror!void {
const self = sp(ctx);
Expand All @@ -132,10 +182,19 @@ pub const QuerySurface = struct {
const self = sp(ctx);
if (self.nearLines(lines)) self.hit = true;
}
fn drawSymbol(ctx: *anyopaque, _: rs.SymbolName, at: rs.TilePoint, _: f64, _: f64, _: bool, _: rs.SymbolPlacement, _: ?f64) anyerror!void {
/// The drawn box UNIONED with the anchor radius: the radius stays a floor,
/// so a small symbol picks exactly as it did before and this test only adds
/// hits. rot_north does not enter it, for the same reason it does not enter
/// pushSymbol: the scene is north-up.
fn drawSymbol(ctx: *anyopaque, name: rs.SymbolName, at: rs.TilePoint, rot_deg: f64, scale: f64, _: bool, _: rs.SymbolPlacement, _: ?f64) anyerror!void {
const self = sp(ctx);
if (self.nearPoint(at)) self.hit = true;
if (self.nearPoint(at) or self.inSymbolExtent(name, at, rot_deg, scale)) self.hit = true;
}
/// A sounding and a label keep the anchor test. A sounding's digits are laid
/// out AROUND the anchor by their baked pivots and stay inside the radius,
/// and reproducing either extent needs state the query path does not carry
/// (an allocator and the mariner's depth unit for a sounding, the font face
/// and shaping for a label).
fn drawSounding(ctx: *anyopaque, _: f64, _: bool, _: bool, at: rs.TilePoint) anyerror!void {
const self = sp(ctx);
if (self.nearPoint(at)) self.hit = true;
Expand All @@ -146,6 +205,83 @@ pub const QuerySurface = struct {
}
};

test "a stretched tile carries fewer tile units per pixel" {
// At the tile's own level, 4096 units span the 256-px native pitch.
try std.testing.expectEqual(@as(f64, 16), unitsPerPx(4096, 16, 16));
// The query rounds and clamps the tile level, so the view sits above it far
// more often than not. Every level above halves the units per pixel.
try std.testing.expectEqual(@as(f64, 8), unitsPerPx(4096, 16, 17));
try std.testing.expectEqual(@as(f64, 4), unitsPerPx(4096, 16, 18));
try std.testing.expectEqual(@as(f64, 2), unitsPerPx(4096, 16, 19));
// Rounding puts the view half a level below the tile just as often.
try std.testing.expectEqual(@as(f64, 32), unitsPerPx(4096, 16, 15));
}

test "a symbol answers a pick on the mark it draws, above the anchor" {
const cv = @import("canvas.zig");
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 beacon-shaped symbol: 2 mm wide, 20 mm tall, all of it ABOVE the pivot.
const Fake = struct {
mark: sym.Symbol,
const vt = sym.SymbolStore.VTable{ .get = get, .getPattern = getPattern };
fn getPattern(_: *anyopaque, _: []const u8, _: f32) ?*const cv.Pattern {
return null;
}
fn get(ctx: *anyopaque, _: []const u8) ?*const sym.Symbol {
const self: *@This() = @ptrCast(@alignCast(ctx));
return &self.mark;
}
};
const ring = [_]cv.Point{ .{ .x = -1, .y = -20 }, .{ .x = 1, .y = -20 }, .{ .x = 1, .y = 0 }, .{ .x = -1, .y = 0 } };
const contours = [_][]const cv.Point{&ring};
var fake = Fake{ .mark = .{
.paths = &.{.{ .fill = .{ .r = 0, .g = 0, .b = 0 }, .contours = &contours }},
.pivot = .{ .x = 0, .y = 0 },
} };
const store = sym.SymbolStore{ .ptr = &fake, .vtable = &Fake.vt };

// scale 0.01 x 100 x 16 tile units per px = k 16, so the drawn box spans
// x -16..16 and y -320..0 tile units about the anchor. Radius 96.
const meta = rs.FeatureMeta{ .class = "BCNLAT" };
const anchor = rs.TilePoint{ .x = 2048, .y = 2048 };
const Case = struct { dx: f64, dy: f64, rot: f64, store: bool, want: usize };
for ([_]Case{
// The mariner clicks the topmark, 200 units above the anchor. Without a
// store that is the old anchor-radius pick, and it misses.
.{ .dx = 0, .dy = -200, .rot = 0, .store = false, .want = 0 },
.{ .dx = 0, .dy = -200, .rot = 0, .store = true, .want = 1 },
.{ .dx = 0, .dy = -400, .rot = 0, .store = true, .want = 0 }, // past the mark
.{ .dx = 30, .dy = -200, .rot = 0, .store = true, .want = 0 }, // a box, not a fat radius
.{ .dx = 0, .dy = 50, .rot = 0, .store = true, .want = 1 }, // the radius floor holds
// Rotated a half turn, the mark hangs BELOW the anchor.
.{ .dx = 0, .dy = -200, .rot = 180, .store = true, .want = 0 },
.{ .dx = 0, .dy = 200, .rot = 180, .store = true, .want = 1 },
}) |c| {
Seen.n = 0;
var qs = QuerySurface{
.qx = @as(f64, @floatFromInt(anchor.x)) + c.dx,
.qy = @as(f64, @floatFromInt(anchor.y)) + c.dy,
.radius = 96,
.view_zoom = 16,
.cb = &cb,
.store = if (c.store) store else null,
.units_per_px = 16,
};
const surf = qs.asSurface();
try surf.beginFeature(&meta);
try surf.drawSymbol("BCNGEN03", anchor, c.rot, 0.01, false, .point, null);
try surf.endFeature();
try std.testing.expectEqual(c.want, Seen.n);
}
}

test "a note area answers a pick inside it, and only inside it" {
const Seen = struct {
var n: usize = 0;
Expand Down
60 changes: 60 additions & 0 deletions src/render/symbols.zig
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,40 @@ pub fn halfExtent(s: *const Symbol, k: f64) [2]f32 {
return .{ @floatCast(hw), @floatCast(hh) };
}

/// The PAINTED box of a symbol's outline about its pivot, scaled by `k` (the
/// same `scale * 100 * dev` a mark is drawn at): { min_x, min_y, max_x, max_y }.
/// Null when the symbol carries no geometry.
///
/// This box is ASYMMETRIC, unlike halfExtent. An aid to navigation draws nearly
/// all of its mark ABOVE the pivot, so the cursor pick tests this box and not a
/// radius around the anchor. The stroke half-width counts: the mariner sees the
/// stroked mark, and a symbol built from open strokes alone encloses no area.
/// Keep this separate from halfExtent — halfExtent sizes the sprite quads on the
/// callback and GPU paths, so a change there moves rendered output.
pub fn bounds(s: *const Symbol, k: f64) ?[4]f64 {
var b = [4]f64{ 0, 0, 0, 0 };
var any = false;
for (s.paths) |p| {
// Stroke width is in the same user units as the geometry (pushSymbol
// strokes at `st.width * k`), so it scales by the same k.
const pad: f64 = if (p.stroke) |st| @as(f64, st.width) / 2 * k else 0;
for (p.contours) |contour| for (contour) |c| {
const x = (@as(f64, c.x) - @as(f64, s.pivot.x)) * k;
const y = (@as(f64, c.y) - @as(f64, s.pivot.y)) * k;
if (!any) {
b = .{ x - pad, y - pad, x + pad, y + pad };
any = true;
} else {
b[0] = @min(b[0], x - pad);
b[1] = @min(b[1], y - pad);
b[2] = @max(b[2], x + pad);
b[3] = @max(b[3], y + pad);
}
};
}
return if (any) b else null;
}

/// The lookup seam: name -> parsed symbol / rendered pattern cell (null =
/// unknown; the caller decides the fallback). Implementations own caching and
/// the returned memory.
Expand Down Expand Up @@ -109,6 +143,32 @@ pub fn flattenCubics(a: std.mem.Allocator, pts: []const f32, closed: bool) ![]cv
return out.toOwnedSlice(a);
}

test "bounds: asymmetric about the pivot, stroke included, null when empty" {
// A 2x4 box whose pivot sits on its BOTTOM edge — the shape of an aid to
// navigation, which draws its mark above the charted position.
const ring = [_]cv.Point{ .{ .x = 0, .y = 0 }, .{ .x = 2, .y = 0 }, .{ .x = 2, .y = 4 }, .{ .x = 0, .y = 4 } };
const contours = [_][]const cv.Point{&ring};
const filled = Symbol{
.paths = &.{.{ .fill = .{ .r = 0, .g = 0, .b = 0 }, .contours = &contours }},
.pivot = .{ .x = 1, .y = 4 },
};
// k = 2: x -1..1 mm -> -2..2, y -4..0 mm -> -8..0. The box is NOT mirrored
// the way halfExtent is: it reaches above the pivot and not below.
const b = bounds(&filled, 2).?;
try std.testing.expectEqual([4]f64{ -2, -8, 2, 0 }, b);

// A stroke of width 1 pads every side by half a width, scaled the same way.
const stroked = Symbol{
.paths = &.{.{ .stroke = .{ .color = .{ .r = 0, .g = 0, .b = 0 }, .width = 1 }, .contours = &contours }},
.pivot = .{ .x = 1, .y = 4 },
};
try std.testing.expectEqual([4]f64{ -3, -9, 3, 1 }, bounds(&stroked, 2).?);

// No geometry: no box.
const empty = Symbol{ .paths = &.{}, .pivot = .{ .x = 0, .y = 0 } };
try std.testing.expectEqual(@as(?[4]f64, null), bounds(&empty, 2));
}

test "flattenCubics: line-as-cubic stays straight, closed ring closes" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
Expand Down
Loading