From d7193e3b9ee5cbc0ddce403b310410d460e9b79b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 21:00:01 +0400 Subject: [PATCH] fix(globe): the CPU pick radius follows scaleByDistance Review of Task 4: the CPU picks (Navara hover, drill, the stale-answer check) took a sprite or point at its full sizePx however far it was, so a set shrunk with scaleByDistance was picked well off its drawn edge. The radius now scales by the set's scaleByDistance at the instance's camera distance, linear between near and far as the Navara shader draws it. --- scripts/format-scope.json | 3 ++- src/globe/cpuPicks.js | 32 ++++++++++++++++------ src/globe/cpuPicks.test.mjs | 53 +++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 src/globe/cpuPicks.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index caf8c504d..b9ba0a87b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1094,5 +1094,6 @@ "src/globe/surfaceDetail.test.mjs", "scripts/qa-surface-heights.mjs", "src/globe/navara/followGestures.js", - "src/globe/navara/followGestures.test.mjs" + "src/globe/navara/followGestures.test.mjs", + "src/globe/cpuPicks.test.mjs" ] diff --git a/src/globe/cpuPicks.js b/src/globe/cpuPicks.js index b575d82b2..bc37dd434 100644 --- a/src/globe/cpuPicks.js +++ b/src/globe/cpuPicks.js @@ -42,13 +42,28 @@ const segmentT = (p, a, b) => { : 0; }; -function moverRadius(options, scale) { - const size = +// scaleByDistance [nearM, nearScale, farM, farScale] at `distance`, linear +// between near and far as the Navara mover shader draws it. +function distanceScale(scaleByDistance, distance) { + if (!scaleByDistance) return 1; + const [near, nearScale, far, farScale] = scaleByDistance; + const t = Math.min( + 1, + Math.max(0, (distance - near) / Math.max(far - near, 1e-6)), + ); + return nearScale + (farScale - nearScale) * t; +} + +function moverRadius(options, scale, distance) { + const style = options.kind === 'sprite' - ? options.sprite.sizePx + ? options.sprite : options.kind === 'point' - ? options.point.sizePx - : Math.max(options.model.minimumPixelSize, 24); + ? options.point + : null; + const size = style + ? style.sizePx * distanceScale(style.scaleByDistance, distance) + : Math.max(options.model.minimumPixelSize, 24); return Math.max((size * scale) / 2, PICK_RADIUS_PX) + PICK_RADIUS_PX; } @@ -92,9 +107,10 @@ export function createCpuPicker({ projector, camera, movers, lines }) { const screen = projector.worldToWindow(scratch, scratchScreen); if (!screen || !projector.horizonVisible(scratch)) continue; const distance = Math.hypot(screen.x - at.x, screen.y - at.y); - if (distance > moverRadius(options, scales[index])) continue; + const depth = depthOf(scratch); + if (distance > moverRadius(options, scales[index], depth)) continue; hits.push({ - depth: depthOf(scratch), + depth, result: { id: pickIds[index], owner: options.id, instance: index }, }); } @@ -159,7 +175,7 @@ export function createCpuPicker({ projector, camera, movers, lines }) { if (!screen) return false; return ( Math.hypot(screen.x - at.x, screen.y - at.y) <= - moverRadius(options, store.scales[instance]) * slack + moverRadius(options, store.scales[instance], depthOf(scratch)) * slack ); }; return drill; diff --git a/src/globe/cpuPicks.test.mjs b/src/globe/cpuPicks.test.mjs new file mode 100644 index 000000000..a95030f4f --- /dev/null +++ b/src/globe/cpuPicks.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../geo/index.js'; +import { createCpuPicker } from './cpuPicks.js'; + +// One sprite 10 km from the camera, drawn at window (100, 100); a click +// `offset` px to its right. +function pickAt(offset, scaleByDistance) { + const position = geo.cartesian3.fromDegrees(0, 0, 0); + const camera = { + livePosition: geo.cartesian3.fromDegrees(0, 0, 10_000), + }; + const drill = createCpuPicker({ + projector: { + worldToWindow: (p, result = geo.cartesian2.create()) => + Object.assign(result, { x: 100, y: 100 }), + horizonVisible: () => true, + }, + camera, + movers: () => [ + { + options: { + id: 'set', + kind: 'sprite', + pickable: true, + sprite: { sizePx: 40, scaleByDistance }, + }, + store: { + count: 1, + positions: [position.x, position.y, position.z], + shows: [1], + scales: [1], + pickIds: ['a'], + }, + }, + ], + lines: () => [], + }); + return drill({ x: 100 + offset, y: 100 }).length; +} + +test('the CPU pick radius follows scaleByDistance', () => { + // 40 px sprite: picked within 20 + 3 px. + assert.equal(pickAt(22), 1); + // Shrunk to a quarter at 10 km (near 1 km ×1, far 10 km ×0.25): 5 + 3 px. + const shrink = [1_000, 1, 10_000, 0.25]; + assert.equal(pickAt(22, shrink), 0); + assert.equal(pickAt(7, shrink), 1); + // Halfway between near and far: linear, as the Navara shader draws it. + const half = [0, 1, 20_000, 0.5]; + assert.equal(pickAt(17, half), 1, '15 + 3 px'); + assert.equal(pickAt(19, half), 0); +});