From d0888d60ce42180f937e2b6da93edf71e8f500bf Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 1 Aug 2026 15:32:21 -0400 Subject: [PATCH 1/2] pick: hit-test a symbol's drawn mark, not its anchor The cursor pick tested a 96 tile-unit radius around a symbol's anchor. A buoy or a beacon draws nearly all of its mark ABOVE the charted position, so the mariner clicked the topmark that is visible and the pick missed the aid. The report then fell through to the areas underneath. QuerySurface now also tests the symbol's drawn box. symbols.bounds gives the painted extent about the pivot, asymmetric and with the stroke half-width included; QuerySurface.inSymbolExtent applies pushSymbol's transform inverted, so the query point is compared in the symbol's own upright frame. The two callers in chart.zig hand the surface the day symbol store, which is enough because a palette supplies only fill and stroke colours and not geometry. Units: pushSymbol draws at k = scale * 100 * dev with dev carrying px_per_tile / 256, and tile units convert to canvas px at px_per_tile / EXTENT. The px_per_tile cancels, so a symbol's extent in tile units is scale * 100 * EXTENT / 256, exact at any zoom. That makes one reference pixel worth EXTENT / 256 = 16 tile units, which confirms the existing radius of 96 as the 6 px it claims to be. The box is UNIONED with that radius, so the radius stays a floor and this can only add hits. A null store, an unknown symbol name, or a symbol with no geometry all fall back to the radius alone. Areas and lines are untouched. A sounding and a label keep the anchor test. A sounding's digits sit around the anchor by their baked pivots and stay inside the radius, and reproducing either extent needs state the query path does not carry. Checked against US5MD1MC at the reported click, 7.95 points above Chesapeake Harbor Entrance Light 2: the pick reported 5 features and no aid before, and now reports BCNLAT and DAYMAR. The pick at the anchor is unchanged, feature for feature. --- docs/docs/api/render.md | 9 +-- include/tile57.h | 6 +- src/chart.zig | 14 +++++ src/render/query.zig | 122 ++++++++++++++++++++++++++++++++++++++-- src/render/symbols.zig | 60 ++++++++++++++++++++ 5 files changed, 199 insertions(+), 12 deletions(-) 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..a81da9e4 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -1827,12 +1827,19 @@ 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; var qs = render.query.QuerySurface{ .qx = @floatFromInt(local.x), .qy = @floatFromInt(local.y), .radius = 96.0, // ~6 px at native tile scale .view_zoom = zoom, // raw view zoom for the SCAMIN cull .cb = cb, + .store = if (store) |st| st.asStore() else null, + .units_per_px = @as(f64, @floatFromInt(tile.EXTENT)) / 256.0, }; const surf = qs.asSurface(); try surf.beginScene(z); @@ -2770,12 +2777,19 @@ 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; var qs = render.query.QuerySurface{ .qx = @floatFromInt(local.x), .qy = @floatFromInt(local.y), .radius = 96.0, // ~6 px at native tile scale .view_zoom = zoom, // raw view zoom for the SCAMIN cull .cb = cb, + .store = if (store) |st| st.asStore() else null, + .units_per_px = @as(f64, @floatFromInt(t.EXTENT)) / 256.0, }; const surf = qs.asSurface(); try surf.beginScene(z); diff --git a/src/render/query.zig b/src/render/query.zig index 38413387..4b76a52e 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. @@ -21,6 +23,14 @@ pub const QuerySurface = struct { 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: the tile extent over the 256-px native + /// tile pitch (4096/256 = 16). A symbol's drawn size is fixed in reference + /// px and the pick works in tile units, so the box test needs the ratio. + /// The caller sets it from the extent it projected the query point into. + units_per_px: f64 = 16.0, cur: rs.FeatureMeta = .{}, hit: bool = false, visible: bool = false, // current feature passes SCAMIN at view_zoom @@ -113,6 +123,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 +168,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 +191,71 @@ pub const QuerySurface = struct { } }; +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(); From 69a18b3f4983df6960d012f99d318f54dd32fa5d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sat, 1 Aug 2026 18:15:39 -0400 Subject: [PATCH 2/2] pick: size the pick against the tile the view actually shows The query replays the tile at a rounded, clamped zoom, so the view is rarely at that tile's own level. A tile shown above its level is stretched, and both terms of the pick were measured as if it were not. A symbol's drawn box used a fixed 4096/256 tile units per pixel. The box was therefore 2^(view_zoom - tile_z) times too large, without bound once the view runs past the archive's deepest zoom. The anchor radius held a fixed 96 tile units and grew the same way, against a comment that promised a constant on-screen distance. unitsPerPx returns the ratio at the view zoom. Both terms use it. At a tile's own level the radius is still 96 units, so a pick there does not change. Measured on US5MD1MC (archive z0..16), the deepest click above a beacon's anchor that still reports it: view zoom before after 16 12 px 12 px 17 24 px 12 px 18 48 px 12 px 19 96 px 12 px --- src/chart.zig | 10 ++++++---- src/render/query.zig | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/chart.zig b/src/chart.zig index a81da9e4..8e942aa4 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -1832,14 +1832,15 @@ pub fn composeQueryPoint(src: *compose_mod.ComposeSource, lon: f64, lat: f64, zo // 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 = @as(f64, @floatFromInt(tile.EXTENT)) / 256.0, + .units_per_px = upp, }; const surf = qs.asSurface(); try surf.beginScene(z); @@ -2782,14 +2783,15 @@ pub const Chart = struct { // 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 = @as(f64, @floatFromInt(t.EXTENT)) / 256.0, + .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 4b76a52e..f0e820a9 100644 --- a/src/render/query.zig +++ b/src/render/query.zig @@ -17,6 +17,19 @@ 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, @@ -26,10 +39,11 @@ pub const QuerySurface = struct { /// 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: the tile extent over the 256-px native - /// tile pitch (4096/256 = 16). A symbol's drawn size is fixed in reference - /// px and the pick works in tile units, so the box test needs the ratio. - /// The caller sets it from the extent it projected the query point into. + /// 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, @@ -191,6 +205,18 @@ 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 {