From de0ffbe4f9b35e5ac61b519cb9f815bfba85d615 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 3 Aug 2026 07:34:58 -0400 Subject: [PATCH 1/2] pick: answer on a mark that overhangs its tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A symbol is drawn around its anchor and its mark overhangs the tile edge, but the feature lives in the tile that holds the anchor. The query replayed only the tile under the point, so a pick on the overhanging part of a buoy answered the water instead of the buoy — the mariner's click was on the mark they could plainly see. The pick now replays every neighbour whose edge stands within a mark's reach of the point (48 reference px, in tile units), the query point re-expressed in each neighbour's frame, on both the single-chart and the composed path. A dedupe keeps the once-per-feature contract: a feature near an edge is baked into both tiles' buffers and would answer twice. Emit before recording, so a full table cannot eat a hit. --- src/chart.zig | 168 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 137 insertions(+), 31 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index 8e942aa4..fd313426 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -1833,25 +1833,33 @@ pub fn composeQueryPoint(src: *compose_mod.ComposeSource, lon: f64, lat: f64, zo // 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 dedupe = Chart.PickDedupe{ .inner = cb, .alloc = a }; + const inner_cb = dedupe.cb(); var qs = render.query.QuerySurface{ .qx = @floatFromInt(local.x), .qy = @floatFromInt(local.y), .radius = 6.0 * upp, // 6 px, whatever the tile is stretched to .view_zoom = zoom, // raw view zoom for the SCAMIN cull - .cb = cb, + .cb = &inner_cb, .store = if (store) |st| st.asStore() else null, .units_per_px = upp, }; const surf = qs.asSurface(); try surf.beginScene(z); - const res = src.tileContent(a, z, tx, ty) catch return; - switch (res.content) { - .layers => |layers| scene.replayTile(a, surf, layers) catch return, - .bytes => |bytes| { - const layers = mlt.decode(a, bytes) catch return; - scene.replayTile(a, surf, layers) catch return; - }, - .none => return, + var slots: [4]Chart.QueryTile = undefined; + const tiles = Chart.pickQueryTiles(@floatFromInt(local.x), @floatFromInt(local.y), tx, ty, z, Chart.pickReach(upp), &slots); + for (tiles) |qt| { + qs.qx = qt.qx; + qs.qy = qt.qy; + const res = src.tileContent(a, z, qt.tx, qt.ty) catch continue; + switch (res.content) { + .layers => |layers| scene.replayTile(a, surf, layers) catch continue, + .bytes => |bytes| { + const layers = mlt.decode(a, bytes) catch continue; + scene.replayTile(a, surf, layers) catch continue; + }, + .none => continue, + } } _ = surf.endScene(a) catch {}; } @@ -2762,6 +2770,96 @@ pub const Chart = struct { /// Cursor object-query: replay the finest tile covering (lon,lat) through a /// QuerySurface and report each feature the point falls in (class + S-57 /// attribute JSON + source cell) via `cb`. Used for the S-52 §10.8 pick. + +/// The tiles a pick must replay: the one under the point, and every +/// neighbour whose edge stands within `reach` tile units of it. A symbol is +/// drawn AROUND its anchor and its mark overhangs its tile's edge, but the +/// feature lives in the tile that holds the anchor — a pick on the overhang +/// answered nothing. Each entry carries the query point expressed in that +/// tile's own frame (outside [0,extent] there, which the point tests take). +const QueryTile = struct { tx: u32, ty: u32, qx: f64, qy: f64 }; + +fn pickQueryTiles(local_x: f64, local_y: f64, tx: u32, ty: u32, z: u8, reach: f64, out: *[4]QueryTile) []QueryTile { + const ext: f64 = @floatFromInt(tile.EXTENT); + const n: u32 = @as(u32, 1) << @intCast(z); + var count: usize = 0; + out[count] = .{ .tx = tx, .ty = ty, .qx = local_x, .qy = local_y }; + count += 1; + const west = local_x < reach and tx > 0; + const east = ext - local_x < reach and tx + 1 < n; + const north = local_y < reach and ty > 0; + const south = ext - local_y < reach and ty + 1 < n; + if (west) { + out[count] = .{ .tx = tx - 1, .ty = ty, .qx = local_x + ext, .qy = local_y }; + count += 1; + } + if (east) { + out[count] = .{ .tx = tx + 1, .ty = ty, .qx = local_x - ext, .qy = local_y }; + count += 1; + } + if (north) { + out[count] = .{ .tx = tx, .ty = ty - 1, .qx = local_x, .qy = local_y + ext }; + count += 1; + } + if (south) { + out[count] = .{ .tx = tx, .ty = ty + 1, .qx = local_x, .qy = local_y - ext }; + count += 1; + } + // At a corner both edges are near, and the diagonal neighbour's symbol + // can reach the point too. reach stays far below a half tile, so at most + // one diagonal joins and the four slots hold. + if (west and north) { + out[count] = .{ .tx = tx - 1, .ty = ty - 1, .qx = local_x + ext, .qy = local_y + ext }; + count += 1; + } else if (west and south) { + out[count] = .{ .tx = tx - 1, .ty = ty + 1, .qx = local_x + ext, .qy = local_y - ext }; + count += 1; + } else if (east and north) { + out[count] = .{ .tx = tx + 1, .ty = ty - 1, .qx = local_x - ext, .qy = local_y + ext }; + count += 1; + } else if (east and south) { + out[count] = .{ .tx = tx + 1, .ty = ty + 1, .qx = local_x - ext, .qy = local_y - ext }; + count += 1; + } + return out[0..count]; +} + +/// How far past a tile's edge a drawn mark can reach, in tile units: the +/// biggest S-52 point symbols stand under 40 reference px tall. +fn pickReach(units_per_px: f64) f64 { + return 48.0 * units_per_px; +} + +/// A query across several tiles answers once per feature, as the single-tile +/// query did: a feature near an edge is baked into both tiles' buffers and +/// would answer twice. Emit first, then record — a full table must not eat +/// a hit. +const PickDedupe = struct { + inner: *const render.query.QueryCb, + alloc: std.mem.Allocator, + seen: std.ArrayList([3][]const u8) = .empty, + + fn feature(ctx: ?*anyopaque, cls: [*]const u8, cls_len: usize, attrs: [*]const u8, attrs_len: usize, cell: [*]const u8, cell_len: usize) callconv(.c) void { + const self: *PickDedupe = @ptrCast(@alignCast(ctx orelse return)); + const c = cls[0..cls_len]; + const s = attrs[0..attrs_len]; + const ch = cell[0..cell_len]; + for (self.seen.items) |e| { + if (std.mem.eql(u8, e[0], c) and std.mem.eql(u8, e[1], s) and + std.mem.eql(u8, e[2], ch)) return; + } + self.inner.feature(self.inner.ctx, cls, cls_len, attrs, attrs_len, cell, cell_len); + const dc = self.alloc.dupe(u8, c) catch return; + const ds = self.alloc.dupe(u8, s) catch return; + const dch = self.alloc.dupe(u8, ch) catch return; + self.seen.append(self.alloc, .{ dc, ds, dch }) catch {}; + } + + fn cb(self: *PickDedupe) render.query.QueryCb { + return .{ .ctx = self, .feature = feature }; + } +}; + pub fn queryPoint(self: *Chart, lon: f64, lat: f64, zoom: f64, cb: *const render.query.QueryCb) !void { const t = @import("tiles").tile; var arena = std.heap.ArenaAllocator.init(gpa); @@ -2784,38 +2882,46 @@ pub const Chart = struct { // 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 dedupe = PickDedupe{ .inner = cb, .alloc = a }; + const inner_cb = dedupe.cb(); var qs = render.query.QuerySurface{ .qx = @floatFromInt(local.x), .qy = @floatFromInt(local.y), .radius = 6.0 * upp, // 6 px, whatever the tile is stretched to .view_zoom = zoom, // raw view zoom for the SCAMIN cull - .cb = cb, + .cb = &inner_cb, .store = if (store) |st| st.asStore() else null, .units_per_px = upp, }; const surf = qs.asSurface(); try surf.beginScene(z); - switch (self.backend) { - .reader => |*rd| { - const is_mlt = rd.header.tile_type == .mlt; - const bytes = (rd.getTile(a, z, tx, ty) catch return) orelse return; - if (bytes.len == 0) return; - const layers = if (is_mlt) - @import("tiles").mlt.decode(a, bytes) catch return - else - @import("tiles").mvt.decode(a, bytes) catch return; - scene.replayTile(a, surf, layers) catch return; - }, - .cell => |*cb2| { - const one = [_]scene.CellRef{.{ - .cell = &cb2.cell, - .portrayal = cb2.portrayal, - .portrayal_plain = cb2.portrayal_plain, - .portrayal_simplified = cb2.portrayal_simplified, - }}; - scene.appendTile(surf, a, &one, z, tx, ty, self.pick_attrs) catch return; - }, - .cells => return, + var slots: [4]QueryTile = undefined; + const tiles = pickQueryTiles(@floatFromInt(local.x), @floatFromInt(local.y), tx, ty, z, pickReach(upp), &slots); + for (tiles) |qt| { + qs.qx = qt.qx; + qs.qy = qt.qy; + switch (self.backend) { + .reader => |*rd| { + const is_mlt = rd.header.tile_type == .mlt; + const bytes = (rd.getTile(a, z, qt.tx, qt.ty) catch continue) orelse continue; + if (bytes.len == 0) continue; + const layers = if (is_mlt) + @import("tiles").mlt.decode(a, bytes) catch continue + else + @import("tiles").mvt.decode(a, bytes) catch continue; + scene.replayTile(a, surf, layers) catch continue; + }, + .cell => |*cb2| { + const one = [_]scene.CellRef{.{ + .cell = &cb2.cell, + .portrayal = cb2.portrayal, + .portrayal_plain = cb2.portrayal_plain, + .portrayal_simplified = cb2.portrayal_simplified, + }}; + scene.appendTile(surf, a, &one, z, qt.tx, qt.ty, self.pick_attrs) catch continue; + }, + .cells => return, + } } _ = surf.endScene(a) catch {}; } From f2bc5413ef00de3cd053481c50712b21eb7c8ae3 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 3 Aug 2026 07:57:02 -0400 Subject: [PATCH 2/2] pick: run zig fmt over the edge-tile query --- src/chart.zig | 171 +++++++++++++++++++++++++------------------------- 1 file changed, 85 insertions(+), 86 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index fd313426..38bfb5ab 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -2770,95 +2770,94 @@ pub const Chart = struct { /// Cursor object-query: replay the finest tile covering (lon,lat) through a /// QuerySurface and report each feature the point falls in (class + S-57 /// attribute JSON + source cell) via `cb`. Used for the S-52 §10.8 pick. - -/// The tiles a pick must replay: the one under the point, and every -/// neighbour whose edge stands within `reach` tile units of it. A symbol is -/// drawn AROUND its anchor and its mark overhangs its tile's edge, but the -/// feature lives in the tile that holds the anchor — a pick on the overhang -/// answered nothing. Each entry carries the query point expressed in that -/// tile's own frame (outside [0,extent] there, which the point tests take). -const QueryTile = struct { tx: u32, ty: u32, qx: f64, qy: f64 }; - -fn pickQueryTiles(local_x: f64, local_y: f64, tx: u32, ty: u32, z: u8, reach: f64, out: *[4]QueryTile) []QueryTile { - const ext: f64 = @floatFromInt(tile.EXTENT); - const n: u32 = @as(u32, 1) << @intCast(z); - var count: usize = 0; - out[count] = .{ .tx = tx, .ty = ty, .qx = local_x, .qy = local_y }; - count += 1; - const west = local_x < reach and tx > 0; - const east = ext - local_x < reach and tx + 1 < n; - const north = local_y < reach and ty > 0; - const south = ext - local_y < reach and ty + 1 < n; - if (west) { - out[count] = .{ .tx = tx - 1, .ty = ty, .qx = local_x + ext, .qy = local_y }; - count += 1; - } - if (east) { - out[count] = .{ .tx = tx + 1, .ty = ty, .qx = local_x - ext, .qy = local_y }; - count += 1; - } - if (north) { - out[count] = .{ .tx = tx, .ty = ty - 1, .qx = local_x, .qy = local_y + ext }; - count += 1; - } - if (south) { - out[count] = .{ .tx = tx, .ty = ty + 1, .qx = local_x, .qy = local_y - ext }; - count += 1; - } - // At a corner both edges are near, and the diagonal neighbour's symbol - // can reach the point too. reach stays far below a half tile, so at most - // one diagonal joins and the four slots hold. - if (west and north) { - out[count] = .{ .tx = tx - 1, .ty = ty - 1, .qx = local_x + ext, .qy = local_y + ext }; - count += 1; - } else if (west and south) { - out[count] = .{ .tx = tx - 1, .ty = ty + 1, .qx = local_x + ext, .qy = local_y - ext }; - count += 1; - } else if (east and north) { - out[count] = .{ .tx = tx + 1, .ty = ty - 1, .qx = local_x - ext, .qy = local_y + ext }; - count += 1; - } else if (east and south) { - out[count] = .{ .tx = tx + 1, .ty = ty + 1, .qx = local_x - ext, .qy = local_y - ext }; + /// The tiles a pick must replay: the one under the point, and every + /// neighbour whose edge stands within `reach` tile units of it. A symbol is + /// drawn AROUND its anchor and its mark overhangs its tile's edge, but the + /// feature lives in the tile that holds the anchor — a pick on the overhang + /// answered nothing. Each entry carries the query point expressed in that + /// tile's own frame (outside [0,extent] there, which the point tests take). + const QueryTile = struct { tx: u32, ty: u32, qx: f64, qy: f64 }; + + fn pickQueryTiles(local_x: f64, local_y: f64, tx: u32, ty: u32, z: u8, reach: f64, out: *[4]QueryTile) []QueryTile { + const ext: f64 = @floatFromInt(tile.EXTENT); + const n: u32 = @as(u32, 1) << @intCast(z); + var count: usize = 0; + out[count] = .{ .tx = tx, .ty = ty, .qx = local_x, .qy = local_y }; count += 1; - } - return out[0..count]; -} - -/// How far past a tile's edge a drawn mark can reach, in tile units: the -/// biggest S-52 point symbols stand under 40 reference px tall. -fn pickReach(units_per_px: f64) f64 { - return 48.0 * units_per_px; -} - -/// A query across several tiles answers once per feature, as the single-tile -/// query did: a feature near an edge is baked into both tiles' buffers and -/// would answer twice. Emit first, then record — a full table must not eat -/// a hit. -const PickDedupe = struct { - inner: *const render.query.QueryCb, - alloc: std.mem.Allocator, - seen: std.ArrayList([3][]const u8) = .empty, - - fn feature(ctx: ?*anyopaque, cls: [*]const u8, cls_len: usize, attrs: [*]const u8, attrs_len: usize, cell: [*]const u8, cell_len: usize) callconv(.c) void { - const self: *PickDedupe = @ptrCast(@alignCast(ctx orelse return)); - const c = cls[0..cls_len]; - const s = attrs[0..attrs_len]; - const ch = cell[0..cell_len]; - for (self.seen.items) |e| { - if (std.mem.eql(u8, e[0], c) and std.mem.eql(u8, e[1], s) and - std.mem.eql(u8, e[2], ch)) return; + const west = local_x < reach and tx > 0; + const east = ext - local_x < reach and tx + 1 < n; + const north = local_y < reach and ty > 0; + const south = ext - local_y < reach and ty + 1 < n; + if (west) { + out[count] = .{ .tx = tx - 1, .ty = ty, .qx = local_x + ext, .qy = local_y }; + count += 1; + } + if (east) { + out[count] = .{ .tx = tx + 1, .ty = ty, .qx = local_x - ext, .qy = local_y }; + count += 1; + } + if (north) { + out[count] = .{ .tx = tx, .ty = ty - 1, .qx = local_x, .qy = local_y + ext }; + count += 1; + } + if (south) { + out[count] = .{ .tx = tx, .ty = ty + 1, .qx = local_x, .qy = local_y - ext }; + count += 1; + } + // At a corner both edges are near, and the diagonal neighbour's symbol + // can reach the point too. reach stays far below a half tile, so at most + // one diagonal joins and the four slots hold. + if (west and north) { + out[count] = .{ .tx = tx - 1, .ty = ty - 1, .qx = local_x + ext, .qy = local_y + ext }; + count += 1; + } else if (west and south) { + out[count] = .{ .tx = tx - 1, .ty = ty + 1, .qx = local_x + ext, .qy = local_y - ext }; + count += 1; + } else if (east and north) { + out[count] = .{ .tx = tx + 1, .ty = ty - 1, .qx = local_x - ext, .qy = local_y + ext }; + count += 1; + } else if (east and south) { + out[count] = .{ .tx = tx + 1, .ty = ty + 1, .qx = local_x - ext, .qy = local_y - ext }; + count += 1; + } + return out[0..count]; + } + + /// How far past a tile's edge a drawn mark can reach, in tile units: the + /// biggest S-52 point symbols stand under 40 reference px tall. + fn pickReach(units_per_px: f64) f64 { + return 48.0 * units_per_px; + } + + /// A query across several tiles answers once per feature, as the single-tile + /// query did: a feature near an edge is baked into both tiles' buffers and + /// would answer twice. Emit first, then record — a full table must not eat + /// a hit. + const PickDedupe = struct { + inner: *const render.query.QueryCb, + alloc: std.mem.Allocator, + seen: std.ArrayList([3][]const u8) = .empty, + + fn feature(ctx: ?*anyopaque, cls: [*]const u8, cls_len: usize, attrs: [*]const u8, attrs_len: usize, cell: [*]const u8, cell_len: usize) callconv(.c) void { + const self: *PickDedupe = @ptrCast(@alignCast(ctx orelse return)); + const c = cls[0..cls_len]; + const s = attrs[0..attrs_len]; + const ch = cell[0..cell_len]; + for (self.seen.items) |e| { + if (std.mem.eql(u8, e[0], c) and std.mem.eql(u8, e[1], s) and + std.mem.eql(u8, e[2], ch)) return; + } + self.inner.feature(self.inner.ctx, cls, cls_len, attrs, attrs_len, cell, cell_len); + const dc = self.alloc.dupe(u8, c) catch return; + const ds = self.alloc.dupe(u8, s) catch return; + const dch = self.alloc.dupe(u8, ch) catch return; + self.seen.append(self.alloc, .{ dc, ds, dch }) catch {}; } - self.inner.feature(self.inner.ctx, cls, cls_len, attrs, attrs_len, cell, cell_len); - const dc = self.alloc.dupe(u8, c) catch return; - const ds = self.alloc.dupe(u8, s) catch return; - const dch = self.alloc.dupe(u8, ch) catch return; - self.seen.append(self.alloc, .{ dc, ds, dch }) catch {}; - } - fn cb(self: *PickDedupe) render.query.QueryCb { - return .{ .ctx = self, .feature = feature }; - } -}; + fn cb(self: *PickDedupe) render.query.QueryCb { + return .{ .ctx = self, .feature = feature }; + } + }; pub fn queryPoint(self: *Chart, lon: f64, lat: f64, zoom: f64, cb: *const render.query.QueryCb) !void { const t = @import("tiles").tile;