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>
92 lines
2.8 KiB
TypeScript
92 lines
2.8 KiB
TypeScript
// 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,
|
||
};
|
||
}
|