diff --git a/docs/docs/api/render.md b/docs/docs/api/render.md index 07f28624..9277af3d 100644 --- a/docs/docs/api/render.md +++ b/docs/docs/api/render.md @@ -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 diff --git a/include/tile57.h b/include/tile57.h index 612c66d4..4b78eeb6 100644 --- a/include/tile57.h +++ b/include/tile57.h @@ -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 { diff --git a/src/chart.zig b/src/chart.zig index 7c2e4c47..8e942aa4 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -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); @@ -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); diff --git a/src/render/query.zig b/src/render/query.zig index 38413387..f0e820a9 100644 --- a/src/render/query.zig +++ b/src/render/query.zig @@ -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. @@ -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 @@ -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); @@ -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; @@ -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; diff --git a/src/render/symbols.zig b/src/render/symbols.zig index 19c1bc97..c93b2930 100644 --- a/src/render/symbols.zig +++ b/src/render/symbols.zig @@ -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. @@ -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();