Files
galaxy-game/ui/frontend/tests/map-hit-test.test.ts
T
Ilia Denisov 6996a79286
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m20s
perf(ui): F8-12 — pixel-space planet sizing + single-copy label/outline layers (#55)
* Planet size formula moves to pixel-space:
  `pointRadiusBasePx = 2 + 2 * cbrt(size / SIZE_NORMALIZER)`. The
  on-screen disc now reads ~4-7 px at the reference zoom regardless
  of how large the world rectangle is — the previous `world-units`
  formulation blew up on small maps and made Source-class planets
  swallow their neighbours.
* Labels + outlines live in the origin copy only. The 9× replication
  across torus copies was the dominant cost on a 100+ planet map
  (Pixi.Text creation + Graphics rebuilds on every zoom step); the
  origin-copy layout is what the camera-wrap listener guarantees
  the user actually sees.
* `setPlanetLabels` and `setPlanetOutlines` skip Pixi-object
  rebuilds when the input fingerprint is unchanged — toggle flips
  and selection changes now keep the existing Text / Graphics
  instances alive and only repaint the affected pieces.
* `renderer.md` updated to the new contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:39:19 +02:00

351 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Hand-built cases for the hit-test pass in src/map/hit-test.ts.
//
// Each describe block exercises one rule from the algorithm spec in
// ui/docs/renderer.md. Worlds are kept tiny (15 primitives) so the
// expected hit is obvious from the geometry; the camera is at scale=1
// in most cases so slop in pixels equals slop in world units.
//
// F8-12 / #28 made `pointRadiusPx` and `strokeWidthPx` honest screen-
// pixel sizes — the hit zone is `(pointRadiusPx + slopPx) / scale`
// world units, which equals `pointRadiusPx + slopPx` *pixels* on
// screen at any zoom. The default `pointRadiusPx`
// (`DEFAULT_POINT_RADIUS_PX`) is 3 and the default point slop
// (`DEFAULT_HIT_SLOP_PX.point`) is 4, so a default point is hit out
// to 7 *screen* pixels — equal to 7 world units at scale=1.
import { describe, expect, test } from "vitest";
import { hitTest } from "../src/map/hit-test";
import {
type Camera,
type Primitive,
type Viewport,
World,
type WrapMode,
} from "../src/map/world";
const VP: Viewport = { widthPx: 200, heightPx: 200 };
// Centre the camera over the world centre at scale=1 so screen px
// equals world units inside the visible region.
function camAt(centerX: number, centerY: number, scale = 1): Camera {
return { centerX, centerY, scale };
}
// Cursor at world point (wx, wy) under the given camera.
function cursorOver(wx: number, wy: number, cam: Camera, vp: Viewport = VP) {
return {
x: vp.widthPx / 2 + (wx - cam.centerX) * cam.scale,
y: vp.heightPx / 2 + (wy - cam.centerY) * cam.scale,
};
}
function point(
id: number,
x: number,
y: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "point",
id,
x,
y,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function circle(
id: number,
x: number,
y: number,
radius: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "circle",
id,
x,
y,
radius,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function line(
id: number,
x1: number,
y1: number,
x2: number,
y2: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "line",
id,
x1,
y1,
x2,
y2,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function ids(world: World, mode: WrapMode, cam: Camera, cursorPx: { x: number; y: number }) {
const h = hitTest(world, cam, VP, cursorPx, mode);
return h?.primitive.id ?? null;
}
describe("hitTest — point primitive", () => {
const cam = camAt(500, 500);
const w = new World(1000, 1000, [point(1, 500, 500)]);
test("direct hit at centre", () => {
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1);
});
test("hit on the visible disc edge (3 world units from centre)", () => {
// Default radius 3 → cursor 3 units away lands on the disc.
expect(ids(w, "torus", cam, cursorOver(503, 500, cam))).toBe(1);
});
test("hit just inside the default slop margin (within radius+slop)", () => {
// 7 world units away at scale=1 → equals radius (3) + slop (4).
expect(ids(w, "torus", cam, cursorOver(507, 500, cam))).toBe(1);
});
test("miss just outside radius+slop", () => {
// 9 world units away at scale=1 → radius+slop is 7.
expect(ids(w, "torus", cam, cursorOver(509, 500, cam))).toBe(null);
});
test("explicit pointRadiusPx widens the visible footprint", () => {
// pointRadiusPx 10 + default slop 4 → hit out to 14 world units.
const w2 = new World(1000, 1000, [
point(1, 500, 500, { style: { pointRadiusPx: 10 } }),
]);
expect(ids(w2, "torus", cam, cursorOver(513, 500, cam))).toBe(1);
expect(ids(w2, "torus", cam, cursorOver(515, 500, cam))).toBe(null);
});
test("custom hitSlopPx widens the slop margin", () => {
// pointRadiusPx defaults to 3; slop override is 20.
// Cursor 22 world units away → within 3+20.
const w2 = new World(1000, 1000, [point(1, 500, 500, { hitSlopPx: 20 })]);
expect(ids(w2, "torus", cam, cursorOver(522, 500, cam))).toBe(1);
expect(ids(w2, "torus", cam, cursorOver(524, 500, cam))).toBe(null);
});
});
describe("hitTest — torus wrap", () => {
test("point near the right edge is hit by cursor near the left edge", () => {
// World 100×100, point at x=98. Camera at left edge (x=2).
// Cursor at x=4 is 6 units from x=98 via the wrap; default
// point radius (3) + slop (4) = 7 → hit.
const cam = camAt(2, 50);
const w = new World(100, 100, [point(1, 98, 50)]);
expect(ids(w, "torus", cam, cursorOver(4, 50, cam))).toBe(1);
});
test("no-wrap mode does not match through the torus seam", () => {
const cam = camAt(2, 50);
const w = new World(100, 100, [point(1, 98, 50)]);
expect(ids(w, "no-wrap", cam, cursorOver(4, 50, cam))).toBe(null);
});
test("line spanning the torus seam is hit at the wrapped midpoint", () => {
// World 100×100, line from (95, 50) to (5, 50).
// Torus-shortest is the wrap segment of length 10.
// Cursor at x=0,y=50 is on the wrapped segment.
const cam = camAt(0, 50);
const w = new World(100, 100, [line(1, 95, 50, 5, 50)]);
expect(ids(w, "torus", cam, cursorOver(0, 50, cam))).toBe(1);
});
});
describe("hitTest — circle primitive", () => {
const cam = camAt(500, 500);
test("filled circle: cursor inside disc hits", () => {
const w = new World(1000, 1000, [
circle(1, 500, 500, 50, { style: { fillColor: 0xffffff, fillAlpha: 1 } }),
]);
expect(ids(w, "torus", cam, cursorOver(530, 500, cam))).toBe(1);
});
test("stroked-only circle: cursor inside disc but far from ring misses", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
expect(ids(w, "torus", cam, cursorOver(510, 500, cam))).toBe(null);
});
test("stroked-only circle: cursor on ring within slop hits", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
// Cursor at (548, 500): distance to centre is 48; ring at 50;
// gap is 2 < default slop 6 → hit.
expect(ids(w, "torus", cam, cursorOver(548, 500, cam))).toBe(1);
});
test("stroked-only circle: cursor far outside the ring misses", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
expect(ids(w, "torus", cam, cursorOver(580, 500, cam))).toBe(null);
});
});
describe("hitTest — line primitive", () => {
const cam = camAt(500, 500);
test("cursor on the segment hits", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1);
});
test("cursor near the segment within slop hits", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
// 4 world units away at scale=1 → within default slop 6.
expect(ids(w, "torus", cam, cursorOver(500, 504, cam))).toBe(1);
});
test("cursor near the segment outside slop misses", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 510, cam))).toBe(null);
});
test("cursor beyond endpoint clamps and slop applies", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
// 4 world units beyond x=520 along x; default slop 6.
expect(ids(w, "torus", cam, cursorOver(524, 500, cam))).toBe(1);
// 8 world units beyond x=520 → outside slop.
expect(ids(w, "torus", cam, cursorOver(528, 500, cam))).toBe(null);
});
});
describe("hitTest — ordering", () => {
const cam = camAt(500, 500);
test("higher priority wins over lower priority at equal distance", () => {
const w = new World(1000, 1000, [
point(1, 500, 500, { priority: 0 }),
point(2, 500, 500, { priority: 5 }),
]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("smaller distance wins at equal priority", () => {
const w = new World(1000, 1000, [point(1, 504, 500), point(2, 502, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("kind tie-break: point beats circle at exact distance and priority", () => {
const w = new World(1000, 1000, [
circle(1, 500, 500, 0.0001, { style: { fillColor: 0xffffff } }),
point(2, 500, 500),
]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("id tie-break: smaller id wins at exact tie", () => {
const w = new World(1000, 1000, [point(7, 500, 500), point(3, 500, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(3);
});
});
describe("hitTest — empty results and scale", () => {
const cam = camAt(500, 500);
test("returns null when nothing matches", () => {
const w = new World(1000, 1000, [point(1, 100, 100)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(null);
});
test("higher zoom shrinks the world-unit footprint of the default disc", () => {
// At scale=4, pointRadiusPx 3 = 0.75 world units; slop 4 = 1
// world unit. Threshold = 1.75 world units.
const cam4 = camAt(500, 500, 4);
const w = new World(1000, 1000, [point(1, 500, 500)]);
// 1.5 world units away → within 1.75 → hit.
expect(ids(w, "torus", cam4, cursorOver(501.5, 500, cam4))).toBe(1);
// 2 world units away → beyond 1.75 → null.
expect(ids(w, "torus", cam4, cursorOver(502, 500, cam4))).toBe(null);
});
test("lower zoom inflates the world-unit footprint of the default disc", () => {
// At scale=0.5, pointRadiusPx 3 = 6 world units; slop 4 = 8
// world units. Threshold = 14 world units.
const cam05 = camAt(500, 500, 0.5);
const w = new World(1000, 1000, [point(1, 500, 500)]);
// 13 world units away → within 14 → hit.
expect(ids(w, "torus", cam05, cursorOver(513, 500, cam05))).toBe(1);
// 16 world units away → beyond 14 → null.
expect(ids(w, "torus", cam05, cursorOver(516, 500, cam05))).toBe(null);
});
test("pointRadiusBasePx scales softly with zoom (F8-12 / #31)", () => {
// world 1000×1000, viewport 200×200 → scaleRef = 0.2. At
// scale=0.5 the on-screen pixel size is
// basePx * (scale/scaleRef)^α
// → 6 * (0.5/0.2)^0.33 ≈ 6 * 1.354 ≈ 8.13 px. In world units
// that becomes ≈ 16.27, plus slop 4/0.5 = 8 → threshold ≈ 24.27.
const cam05 = camAt(500, 500, 0.5);
const wBase = new World(1000, 1000, [
point(1, 500, 500, { style: { pointRadiusBasePx: 6 } }),
]);
expect(ids(wBase, "torus", cam05, cursorOver(520, 500, cam05))).toBe(1);
// Cursor 26 world units away exceeds the threshold (~24.27).
expect(ids(wBase, "torus", cam05, cursorOver(526, 500, cam05))).toBe(null);
});
});
describe("hitTest — Phase 29 hiddenIds parameter", () => {
const cam = camAt(500, 500);
test("a hidden primitive is skipped entirely", () => {
const w = new World(1000, 1000, [
point(1, 500, 500),
point(2, 500, 500, { priority: -1 }),
]);
// Without filtering, primitive 1 wins (higher priority).
expect(hitTest(w, cam, VP, cursorOver(500, 500, cam), "torus")?.primitive.id)
.toBe(1);
// With 1 hidden, the cursor falls through to primitive 2.
expect(
hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set([1]),
)?.primitive.id,
).toBe(2);
});
test("hiding every match returns null", () => {
const w = new World(1000, 1000, [point(1, 500, 500)]);
expect(
hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set([1]),
),
).toBeNull();
});
test("an empty hidden set is equivalent to omitting the parameter", () => {
const w = new World(1000, 1000, [point(1, 500, 500)]);
const a = hitTest(w, cam, VP, cursorOver(500, 500, cam), "torus");
const b = hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set(),
);
expect(a?.primitive.id).toBe(1);
expect(b?.primitive.id).toBe(1);
});
});