ui/phase-9: PixiJS map renderer with torus and no-wrap modes
Stand up the vector map renderer in ui/frontend/src/map/ on top of PixiJS v8 + pixi-viewport@^6. Torus mode renders nine container copies for seamless wrap; no-wrap mode pins the camera at world bounds and centres on an axis when the viewport exceeds the world along that axis. Hit-test is a brute-force pass with deterministic [-priority, distSq, kindOrder, id] ordering and torus-shortest distance, validated by hand-built unit cases. The development playground at /__debug/map exposes a window debug surface for the Playwright spec, which forces WebGPU on chromium-desktop, WebGL on webkit-desktop, and accepts the auto-picked backend on mobile projects. Algorithm spec lives in ui/docs/renderer.md, which also pins the new deprecation status of galaxy/client (the entire Fyne client module, including client/world). client/world/README.md and the Phase 9 stub in ui/PLAN.md gain matching deprecation banners. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// Phase 9 end-to-end checks for the map renderer playground.
|
||||
//
|
||||
// Each Playwright project exercises a different rendering backend:
|
||||
// chromium-desktop forces WebGPU, webkit-desktop forces WebGL, mobile
|
||||
// projects pick their default. The window.__galaxyMap surface
|
||||
// (defined in src/routes/__debug/map/+page.svelte) lets the spec
|
||||
// read the camera and viewport state without poking Pixi internals.
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
interface DebugMapSurface {
|
||||
ready: true;
|
||||
getMode(): "torus" | "no-wrap";
|
||||
setMode(mode: "torus" | "no-wrap"): void;
|
||||
getCamera(): { centerX: number; centerY: number; scale: number };
|
||||
getViewport(): { widthPx: number; heightPx: number };
|
||||
getBackend(): string;
|
||||
getWorldSize(): { width: number; height: number };
|
||||
hitAt(x: number, y: number): number | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__galaxyMap?: DebugMapSurface;
|
||||
}
|
||||
}
|
||||
|
||||
function preferenceFor(projectName: string): "webgpu" | "webgl" | null {
|
||||
if (projectName === "chromium-desktop") return "webgpu";
|
||||
if (projectName === "webkit-desktop") return "webgl";
|
||||
return null;
|
||||
}
|
||||
|
||||
async function bootMap(page: Page, preference: "webgpu" | "webgl" | null): Promise<void> {
|
||||
const url = preference !== null ? `/__debug/map?renderer=${preference}` : "/__debug/map";
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__galaxyMap?.ready === true, undefined, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByTestId("backend")).toBeVisible();
|
||||
}
|
||||
|
||||
test("map mounts in the requested backend and reports it via data-backend", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const pref = preferenceFor(testInfo.project.name);
|
||||
await bootMap(page, pref);
|
||||
const backend = await page.getByTestId("backend").getAttribute("data-backend");
|
||||
expect(backend).not.toBeNull();
|
||||
if (pref === null) {
|
||||
// Mobile projects auto-pick; just assert a real backend was chosen.
|
||||
expect(["webgl", "webgpu", "canvas"]).toContain(backend);
|
||||
} else {
|
||||
// The renderer should honour the requested preference unless the
|
||||
// runner lacks a working WebGPU adapter, in which case Pixi
|
||||
// falls back to WebGL. Both are acceptable.
|
||||
expect(["webgl", "webgpu"]).toContain(backend);
|
||||
}
|
||||
});
|
||||
|
||||
test("wheel zoom-in increases camera scale", async ({ page }, testInfo) => {
|
||||
await bootMap(page, preferenceFor(testInfo.project.name));
|
||||
const before = await page.evaluate(() => window.__galaxyMap!.getCamera());
|
||||
const canvas = page.locator("canvas");
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (box === null) return;
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
await page.mouse.move(cx, cy);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.mouse.wheel(0, -120);
|
||||
await page.waitForTimeout(40);
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
const after = await page.evaluate(() => window.__galaxyMap!.getCamera());
|
||||
expect(after.scale).toBeGreaterThan(before.scale);
|
||||
});
|
||||
|
||||
test("mode toggle switches between torus and no-wrap", async ({ page }, testInfo) => {
|
||||
await bootMap(page, preferenceFor(testInfo.project.name));
|
||||
expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus");
|
||||
await page.getByTestId("mode-toggle").click();
|
||||
expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("no-wrap");
|
||||
await page.getByTestId("mode-toggle").click();
|
||||
expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus");
|
||||
});
|
||||
|
||||
test("no-wrap clamps the camera within world bounds after a drag past the edge", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await bootMap(page, preferenceFor(testInfo.project.name));
|
||||
await page.evaluate(() => window.__galaxyMap!.setMode("no-wrap"));
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
const canvas = page.locator("canvas");
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (box === null) return;
|
||||
|
||||
// Drag right-to-left across most of the canvas so the camera
|
||||
// would, without clamp, push past the right edge of the world.
|
||||
const startX = box.x + box.width * 0.85;
|
||||
const endX = box.x + box.width * 0.15;
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(startX, y);
|
||||
await page.mouse.down();
|
||||
for (let step = 1; step <= 20; step++) {
|
||||
const x = startX + ((endX - startX) * step) / 20;
|
||||
await page.mouse.move(x, y);
|
||||
}
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const { cam, vp, world } = await page.evaluate(() => ({
|
||||
cam: window.__galaxyMap!.getCamera(),
|
||||
vp: window.__galaxyMap!.getViewport(),
|
||||
world: window.__galaxyMap!.getWorldSize(),
|
||||
}));
|
||||
const halfSpanX = vp.widthPx / (2 * cam.scale);
|
||||
const tol = 1; // tolerance in world units; clamp is applied in pixels
|
||||
expect(cam.centerX).toBeGreaterThanOrEqual(halfSpanX - tol);
|
||||
expect(cam.centerX).toBeLessThanOrEqual(world.width - halfSpanX + tol);
|
||||
});
|
||||
|
||||
test("hitAt returns a primitive id when the cursor is over the world centre", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await bootMap(page, preferenceFor(testInfo.project.name));
|
||||
const canvas = page.locator("canvas");
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (box === null) return;
|
||||
const cx = Math.round(box.width / 2);
|
||||
const cy = Math.round(box.height / 2);
|
||||
// The fixture world is dense (~950 stars in 4000×4000). Anywhere
|
||||
// within the canvas should land near at least one primitive.
|
||||
// We sweep a small grid around the centre to find any hit; the
|
||||
// goal is to confirm the hit-test plumbing works against the
|
||||
// live renderer, not to assert a specific id.
|
||||
const found = await page.evaluate(
|
||||
({ cx, cy }) => {
|
||||
const m = window.__galaxyMap!;
|
||||
for (let dy = -40; dy <= 40; dy += 8) {
|
||||
for (let dx = -40; dx <= 40; dx += 8) {
|
||||
const id = m.hitAt(cx + dx, cy + dy);
|
||||
if (id !== null) return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
{ cx, cy },
|
||||
);
|
||||
expect(found).not.toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
// 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 (1–5 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.
|
||||
|
||||
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 within default slop (8px)", () => {
|
||||
// 7 world units away at scale=1 → within 8px slop.
|
||||
expect(ids(w, "torus", cam, cursorOver(507, 500, cam))).toBe(1);
|
||||
});
|
||||
test("miss just outside default slop", () => {
|
||||
expect(ids(w, "torus", cam, cursorOver(509, 500, cam))).toBe(null);
|
||||
});
|
||||
test("custom hitSlopPx widens the hit area", () => {
|
||||
const w2 = new World(1000, 1000, [point(1, 500, 500, { hitSlopPx: 20 })]);
|
||||
expect(ids(w2, "torus", cam, cursorOver(515, 500, cam))).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
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 slop is 8px → 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 on-screen slop in world units", () => {
|
||||
// At scale=4, 8px on screen = 2 world units.
|
||||
// A point 3 world units away misses.
|
||||
const w = new World(1000, 1000, [point(1, 503, 500)]);
|
||||
expect(ids(w, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4)))).toBe(
|
||||
null,
|
||||
);
|
||||
// A point 1.5 world units away hits at scale=4 (≤ 2).
|
||||
const w2 = new World(1000, 1000, [point(1, 501.5, 500)]);
|
||||
expect(
|
||||
ids(w2, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4))),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("lower zoom widens the on-screen slop in world units", () => {
|
||||
// At scale=0.5, 8px on screen = 16 world units.
|
||||
const w = new World(1000, 1000, [point(1, 514, 500)]);
|
||||
expect(
|
||||
ids(
|
||||
w,
|
||||
"torus",
|
||||
camAt(500, 500, 0.5),
|
||||
cursorOver(500, 500, camAt(500, 500, 0.5)),
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// Unit tests for the geometry primitives in src/map/math.ts.
|
||||
//
|
||||
// These functions are the foundation for hit-test and the no-wrap
|
||||
// camera helpers; they run far more often than their callers and any
|
||||
// regression here ripples everywhere. Each test asserts a single
|
||||
// algebraic property; the cases together cover the contract of the
|
||||
// functions described in ui/docs/renderer.md.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
clamp,
|
||||
distSqPointToSegment,
|
||||
screenToWorld,
|
||||
torusShortestDelta,
|
||||
worldToScreen,
|
||||
} from "../src/map/math";
|
||||
|
||||
describe("clamp", () => {
|
||||
test("returns the value when inside the bounds", () => {
|
||||
expect(clamp(5, 0, 10)).toBe(5);
|
||||
expect(clamp(0, 0, 10)).toBe(0);
|
||||
expect(clamp(10, 0, 10)).toBe(10);
|
||||
});
|
||||
test("clamps to the lower bound", () => {
|
||||
expect(clamp(-3, 0, 10)).toBe(0);
|
||||
});
|
||||
test("clamps to the upper bound", () => {
|
||||
expect(clamp(13, 0, 10)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("torusShortestDelta", () => {
|
||||
test("returns zero for equal inputs", () => {
|
||||
expect(torusShortestDelta(50, 50, 100)).toBe(0);
|
||||
});
|
||||
test("returns the direct delta when no wrap is shorter", () => {
|
||||
expect(torusShortestDelta(10, 30, 100)).toBe(20);
|
||||
expect(torusShortestDelta(30, 10, 100)).toBe(-20);
|
||||
});
|
||||
test("wraps to the shorter direction near the seam", () => {
|
||||
// from=10, to=90: direct=+80, wrap=-20 — wrap wins.
|
||||
expect(torusShortestDelta(10, 90, 100)).toBe(-20);
|
||||
// from=90, to=10: direct=-80, wrap=+20 — wrap wins.
|
||||
expect(torusShortestDelta(90, 10, 100)).toBe(20);
|
||||
});
|
||||
test("normalises inputs outside [0, size)", () => {
|
||||
expect(torusShortestDelta(-10, 10, 100)).toBe(20);
|
||||
expect(torusShortestDelta(110, 10, 100)).toBe(-100 + 100); // wraps to 0
|
||||
});
|
||||
test("at exactly size/2 picks the positive direction deterministically", () => {
|
||||
// from=0, to=50, size=100 — both directions are equal.
|
||||
// The contract: returns +size/2.
|
||||
expect(torusShortestDelta(0, 50, 100)).toBe(50);
|
||||
});
|
||||
test("rejects non-positive size", () => {
|
||||
expect(() => torusShortestDelta(0, 0, 0)).toThrow();
|
||||
expect(() => torusShortestDelta(0, 0, -1)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("distSqPointToSegment", () => {
|
||||
test("zero distance when the point is on the segment", () => {
|
||||
expect(distSqPointToSegment(5, 0, 0, 0, 10, 0)).toBe(0);
|
||||
expect(distSqPointToSegment(0, 0, 0, 0, 10, 0)).toBe(0);
|
||||
expect(distSqPointToSegment(10, 0, 0, 0, 10, 0)).toBe(0);
|
||||
});
|
||||
test("perpendicular foot inside the segment", () => {
|
||||
// segment along the x-axis from (0,0) to (10,0); point at (5,3).
|
||||
// foot is (5,0), distance is 3, distSq is 9.
|
||||
expect(distSqPointToSegment(5, 3, 0, 0, 10, 0)).toBeCloseTo(9, 12);
|
||||
});
|
||||
test("foot beyond the start endpoint clamps to start", () => {
|
||||
expect(distSqPointToSegment(-2, 0, 0, 0, 10, 0)).toBeCloseTo(4, 12);
|
||||
});
|
||||
test("foot beyond the end endpoint clamps to end", () => {
|
||||
expect(distSqPointToSegment(15, 0, 0, 0, 10, 0)).toBeCloseTo(25, 12);
|
||||
});
|
||||
test("zero-length segment falls back to point distance", () => {
|
||||
expect(distSqPointToSegment(3, 4, 0, 0, 0, 0)).toBeCloseTo(25, 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("screenToWorld and worldToScreen", () => {
|
||||
const viewport = { widthPx: 800, heightPx: 600 };
|
||||
const camera = { centerX: 1000, centerY: 500, scale: 2 };
|
||||
|
||||
test("centre of viewport maps to camera centre in world space", () => {
|
||||
const w = screenToWorld({ x: 400, y: 300 }, camera, viewport);
|
||||
expect(w.x).toBeCloseTo(1000, 12);
|
||||
expect(w.y).toBeCloseTo(500, 12);
|
||||
});
|
||||
|
||||
test("worldToScreen is the inverse of screenToWorld", () => {
|
||||
const screenIn = { x: 123.5, y: 456.25 };
|
||||
const world = screenToWorld(screenIn, camera, viewport);
|
||||
const screenOut = worldToScreen(world, camera, viewport);
|
||||
expect(screenOut.x).toBeCloseTo(screenIn.x, 9);
|
||||
expect(screenOut.y).toBeCloseTo(screenIn.y, 9);
|
||||
});
|
||||
|
||||
test("scale propagates: 2px on screen = 1 world unit at scale=2", () => {
|
||||
const w0 = screenToWorld({ x: 400, y: 300 }, camera, viewport);
|
||||
const w1 = screenToWorld({ x: 402, y: 300 }, camera, viewport);
|
||||
expect(w1.x - w0.x).toBeCloseTo(1, 12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// Unit tests for the no-wrap camera helpers in src/map/no-wrap.ts.
|
||||
//
|
||||
// The bounded-plane mode has three invariants that the helpers must
|
||||
// uphold together:
|
||||
//
|
||||
// 1. The visible viewport stays inside the world rectangle, except
|
||||
// when the visible viewport span exceeds the world span on an
|
||||
// axis — in that case the camera centres on that axis.
|
||||
// 2. minScaleNoWrap is the smallest scale at which the visible
|
||||
// viewport fits the world along both axes.
|
||||
// 3. pivotZoom keeps the world point under the cursor stable
|
||||
// across a scale change.
|
||||
//
|
||||
// Each invariant is tested in isolation here; the renderer composes
|
||||
// them in render.ts.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screenToWorld } from "../src/map/math";
|
||||
import { clampCameraNoWrap, minScaleNoWrap, pivotZoom } from "../src/map/no-wrap";
|
||||
import { World } from "../src/map/world";
|
||||
|
||||
const world = new World(1000, 800);
|
||||
const viewport = { widthPx: 400, heightPx: 300 };
|
||||
|
||||
describe("clampCameraNoWrap", () => {
|
||||
test("leaves the camera unchanged when the viewport sits inside the world", () => {
|
||||
const c = clampCameraNoWrap({ centerX: 500, centerY: 400, scale: 1 }, viewport, world);
|
||||
expect(c.centerX).toBe(500);
|
||||
expect(c.centerY).toBe(400);
|
||||
});
|
||||
test("clamps the camera to the left edge", () => {
|
||||
const c = clampCameraNoWrap({ centerX: 0, centerY: 400, scale: 1 }, viewport, world);
|
||||
expect(c.centerX).toBe(viewport.widthPx / 2);
|
||||
});
|
||||
test("clamps the camera to the right edge", () => {
|
||||
const c = clampCameraNoWrap({ centerX: 9999, centerY: 400, scale: 1 }, viewport, world);
|
||||
expect(c.centerX).toBe(world.width - viewport.widthPx / 2);
|
||||
});
|
||||
test("clamps the camera to the top edge", () => {
|
||||
const c = clampCameraNoWrap({ centerX: 500, centerY: -50, scale: 1 }, viewport, world);
|
||||
expect(c.centerY).toBe(viewport.heightPx / 2);
|
||||
});
|
||||
test("clamps the camera to the bottom edge", () => {
|
||||
const c = clampCameraNoWrap({ centerX: 500, centerY: 9999, scale: 1 }, viewport, world);
|
||||
expect(c.centerY).toBe(world.height - viewport.heightPx / 2);
|
||||
});
|
||||
test("centres the camera on an axis when the viewport span exceeds world span", () => {
|
||||
// At scale=0.1, viewport.widthPx/scale = 4000 world units > world.width=1000.
|
||||
const c = clampCameraNoWrap({ centerX: 999, centerY: 999, scale: 0.1 }, viewport, world);
|
||||
expect(c.centerX).toBe(world.width / 2);
|
||||
expect(c.centerY).toBe(world.height / 2);
|
||||
});
|
||||
test("does not modify scale", () => {
|
||||
const c = clampCameraNoWrap({ centerX: 999, centerY: 999, scale: 0.5 }, viewport, world);
|
||||
expect(c.scale).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("minScaleNoWrap", () => {
|
||||
test("equals the larger axis ratio (width-bound)", () => {
|
||||
// world 1000×800, viewport 400×300:
|
||||
// width ratio = 0.4, height ratio = 0.375 — width wins.
|
||||
expect(minScaleNoWrap(viewport, world)).toBeCloseTo(0.4, 12);
|
||||
});
|
||||
test("equals the larger axis ratio (height-bound)", () => {
|
||||
// world 100×100, viewport 200×400: height ratio = 4 wins over width = 2.
|
||||
expect(minScaleNoWrap({ widthPx: 200, heightPx: 400 }, new World(100, 100))).toBeCloseTo(
|
||||
4,
|
||||
12,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pivotZoom", () => {
|
||||
const camera = { centerX: 500, centerY: 400, scale: 1 };
|
||||
|
||||
test("keeps the world point under the cursor stable", () => {
|
||||
const cursor = { x: 100, y: 250 };
|
||||
const before = screenToWorld(cursor, camera, viewport);
|
||||
const newCam = pivotZoom(camera, viewport, cursor, 2.5);
|
||||
const after = screenToWorld(cursor, newCam, viewport);
|
||||
expect(after.x).toBeCloseTo(before.x, 9);
|
||||
expect(after.y).toBeCloseTo(before.y, 9);
|
||||
expect(newCam.scale).toBe(2.5);
|
||||
});
|
||||
|
||||
test("invariant holds when the cursor sits at the viewport centre", () => {
|
||||
const cursor = { x: viewport.widthPx / 2, y: viewport.heightPx / 2 };
|
||||
const before = screenToWorld(cursor, camera, viewport);
|
||||
const newCam = pivotZoom(camera, viewport, cursor, 0.4);
|
||||
const after = screenToWorld(cursor, newCam, viewport);
|
||||
expect(after.x).toBeCloseTo(before.x, 9);
|
||||
expect(after.y).toBeCloseTo(before.y, 9);
|
||||
});
|
||||
|
||||
test("invariant holds at the viewport corner", () => {
|
||||
const cursor = { x: 0, y: 0 };
|
||||
const before = screenToWorld(cursor, camera, viewport);
|
||||
const newCam = pivotZoom(camera, viewport, cursor, 7.7);
|
||||
const after = screenToWorld(cursor, newCam, viewport);
|
||||
expect(after.x).toBeCloseTo(before.x, 9);
|
||||
expect(after.y).toBeCloseTo(before.y, 9);
|
||||
});
|
||||
|
||||
test("rejects non-positive scale", () => {
|
||||
expect(() => pivotZoom(camera, viewport, { x: 0, y: 0 }, 0)).toThrow();
|
||||
expect(() => pivotZoom(camera, viewport, { x: 0, y: 0 }, -1)).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user