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:
Ilia Denisov
2026-05-08 14:06:23 +02:00
parent 9d2504c42d
commit db415f8aa4
17 changed files with 2064 additions and 41 deletions
+91
View File
@@ -0,0 +1,91 @@
// Geometry primitives used by the map renderer.
//
// All distances are in world units (TS numbers, float64). Functions
// in this file are pure and side-effect-free; tests exercise them
// directly.
import type { Camera, Viewport } from "./world";
// clamp returns v constrained to [lo, hi]. If lo > hi the function
// returns lo (callers are expected to keep the bounds well-formed).
export function clamp(v: number, lo: number, hi: number): number {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
// torusShortestDelta returns the signed delta from a to b on a circle
// of circumference `size`, picking the direction with the smaller
// absolute distance. Result lies in (-size/2, size/2].
//
// At exactly size/2 the function returns +size/2 (positive direction);
// the lower bound is exclusive so a delta of -size/2 wraps to +size/2.
// This deterministic tie-break keeps the function self-consistent
// regardless of input order. The `+0` at the end normalises -0 (which
// JavaScript produces for some modulo cases) to +0.
export function torusShortestDelta(a: number, b: number, size: number): number {
if (!(size > 0)) {
throw new Error(`torusShortestDelta: size must be positive, got ${size}`);
}
let d = (b - a) % size;
if (d > size / 2) d -= size;
else if (d <= -size / 2) d += size;
return d + 0;
}
// distSqPointToSegment returns the squared distance from point (px,py)
// to the segment (ax,ay)(bx,by). For zero-length segments it falls
// back to point-to-point distance.
export function distSqPointToSegment(
px: number,
py: number,
ax: number,
ay: number,
bx: number,
by: number,
): number {
const dx = bx - ax;
const dy = by - ay;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) {
const ex = px - ax;
const ey = py - ay;
return ex * ex + ey * ey;
}
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
if (t < 0) t = 0;
else if (t > 1) t = 1;
const fx = ax + t * dx;
const fy = ay + t * dy;
const ex = px - fx;
const ey = py - fy;
return ex * ex + ey * ey;
}
// screenToWorld converts cursor pixel coordinates (relative to the
// viewport top-left) to world coordinates under the given camera.
export function screenToWorld(
cursorPx: { x: number; y: number },
camera: Camera,
viewport: Viewport,
): { x: number; y: number } {
const offX = cursorPx.x - viewport.widthPx / 2;
const offY = cursorPx.y - viewport.heightPx / 2;
return {
x: camera.centerX + offX / camera.scale,
y: camera.centerY + offY / camera.scale,
};
}
// worldToScreen converts a world-space point to viewport pixel
// coordinates under the given camera.
export function worldToScreen(
world: { x: number; y: number },
camera: Camera,
viewport: Viewport,
): { x: number; y: number } {
return {
x: viewport.widthPx / 2 + (world.x - camera.centerX) * camera.scale,
y: viewport.heightPx / 2 + (world.y - camera.centerY) * camera.scale,
};
}