db415f8aa4
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>
107 lines
3.9 KiB
TypeScript
107 lines
3.9 KiB
TypeScript
// 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);
|
|
});
|
|
});
|