sbuffkin_hexmaker/tests/hexGeometry.test.ts
isaprettycoolguy@protonmail.com 4cd0550a9a fix: render road/river chains in parallel + skip zoom-bake on bg-image maps
Path chains that share a route are stroked in path-type order, so a later
chain on the same hexes used to paint over the earlier one (hexmaker#30).
Add computeLaneOffsets() + offsetPolyline() (hexGeometry.ts): chains sharing
a segment are grouped via union-find and given symmetric perpendicular lane
offsets PER DISTINCT TYPE — same-type chains merge into one stroke, different
types fan out side by side. Chains with no shared segment keep offset 0, so
the common single-path case is pixel-identical. Wired into both the on-screen
overlay (renderPathOverlay) and the PNG exporter (mapPngRenderer) so they match.

Also fixes a macOS-only bg-image zoom drift: bakeZoom grows the em-based hex
grid via font-size while the px-based bg image is grown through a separate
multiply; device-pixel snapping on fractional/Retina DPR rounds the two paths
differently, so the grid slipped off the image on zoom settle. scheduleZoomBake
now skips baking whenever a bg image is present, keeping zoom as a single
composited viewport scale() that can't drift on any DPR.

Adds ISSUES.md (local mirror of open upstream issues for planning).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 06:56:46 -04:00

262 lines
8.8 KiB
TypeScript
Raw Permalink 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.

import { describe, it } from "node:test";
import expect from "expect";
import {
hexSize,
hexCenter,
hexPolygonPoints,
gridBoundingBox,
offsetPolyline,
computeLaneOffsets,
} from "../src/hex-map/hexGeometry";
const SQRT3 = Math.sqrt(3);
const close = (a: number, b: number, eps = 1e-6) => Math.abs(a - b) <= eps;
describe("hexSize", () => {
it("flat-top hex is 2R wide and √3·R tall", () => {
const { w, h } = hexSize(10, "flat");
expect(w).toBeCloseTo(20);
expect(h).toBeCloseTo(10 * SQRT3);
});
it("pointy-top hex is √3·R wide and 2R tall", () => {
const { w, h } = hexSize(10, "pointy");
expect(w).toBeCloseTo(10 * SQRT3);
expect(h).toBeCloseTo(20);
});
});
describe("hexCenter — flat-top", () => {
it("places (0,0) at (R, h/2)", () => {
const c = hexCenter(0, 0, "flat", 10);
expect(c.cx).toBeCloseTo(10);
expect(c.cy).toBeCloseTo((10 * SQRT3) / 2);
});
it("advances col by 1.5R", () => {
const a = hexCenter(0, 0, "flat", 10);
const b = hexCenter(1, 0, "flat", 10);
expect(b.cx - a.cx).toBeCloseTo(15);
});
it("advances row by √3·R for non-shifted columns", () => {
const a = hexCenter(0, 0, "flat", 10);
const b = hexCenter(0, 1, "flat", 10);
expect(b.cy - a.cy).toBeCloseTo(10 * SQRT3);
});
it("shifts odd columns DOWN by h/2 under odd-stagger", () => {
const even = hexCenter(0, 0, "flat", 10);
const odd = hexCenter(1, 0, "flat", 10, "odd");
expect(odd.cy - even.cy).toBeCloseTo((10 * SQRT3) / 2);
});
it("shifts even columns DOWN under even-stagger", () => {
const even = hexCenter(0, 0, "flat", 10, "even");
const odd = hexCenter(1, 0, "flat", 10, "even");
// Now even cols are shifted: col 0 (even) is shifted by h/2, col 1 (odd) isn't
expect(even.cy).toBeCloseTo((10 * SQRT3) / 2 + (10 * SQRT3) / 2);
expect(odd.cy).toBeCloseTo((10 * SQRT3) / 2);
});
});
describe("hexCenter — pointy-top", () => {
it("places (0,0) at (w/2, R)", () => {
const c = hexCenter(0, 0, "pointy", 10);
expect(c.cx).toBeCloseTo((10 * SQRT3) / 2);
expect(c.cy).toBeCloseTo(10);
});
it("advances col by √3·R", () => {
const a = hexCenter(0, 0, "pointy", 10);
const b = hexCenter(1, 0, "pointy", 10);
expect(b.cx - a.cx).toBeCloseTo(10 * SQRT3);
});
it("advances row by 1.5R", () => {
const a = hexCenter(0, 0, "pointy", 10);
const b = hexCenter(0, 1, "pointy", 10);
expect(b.cy - a.cy).toBeCloseTo(15);
});
it("shifts odd rows RIGHT by w/2 under odd-stagger", () => {
const even = hexCenter(0, 0, "pointy", 10);
const odd = hexCenter(0, 1, "pointy", 10, "odd");
expect(odd.cx - even.cx).toBeCloseTo((10 * SQRT3) / 2);
});
});
describe("hexPolygonPoints", () => {
it("returns six vertices", () => {
expect(hexPolygonPoints(0, 0, "flat", 10)).toHaveLength(6);
});
it("flat-top: first vertex is to the right of centre", () => {
const pts = hexPolygonPoints(0, 0, "flat", 10);
expect(pts[0].x).toBeCloseTo(10);
expect(pts[0].y).toBeCloseTo(0);
});
it("pointy-top: first vertex is upper-right of centre", () => {
const pts = hexPolygonPoints(0, 0, "pointy", 10);
expect(pts[0].x).toBeCloseTo((10 * SQRT3) / 2);
expect(pts[0].y).toBeCloseTo(5);
});
it("all six vertices lie on a circle of radius R around the centre", () => {
const pts = hexPolygonPoints(100, 50, "flat", 25);
for (const p of pts) {
const dx = p.x - 100;
const dy = p.y - 50;
expect(close(Math.hypot(dx, dy), 25)).toBe(true);
}
});
it("opposite vertices (i and i+3) are diametrically opposite", () => {
const pts = hexPolygonPoints(0, 0, "flat", 10);
for (let i = 0; i < 3; i++) {
expect(pts[i].x + pts[i + 3].x).toBeCloseTo(0);
expect(pts[i].y + pts[i + 3].y).toBeCloseTo(0);
}
});
});
describe("gridBoundingBox", () => {
it("a single flat-top hex grid is at least (2R wide, √3·R tall)", () => {
const { width, height } = gridBoundingBox(1, 1, "flat", 10);
expect(width).toBeGreaterThanOrEqual(20);
expect(height).toBeGreaterThanOrEqual(Math.ceil(10 * SQRT3));
});
it("scales sensibly for a 10×10 grid", () => {
const { width, height } = gridBoundingBox(10, 10, "flat", 20);
// Width should be roughly 2R + 9 * 1.5R = 2*20 + 9*30 = 310
expect(width).toBeGreaterThan(300);
expect(width).toBeLessThan(330);
// Height should be roughly 10 * (√3·R) + h/2 stagger = 10 * 34.64 + 17.3 ≈ 364
expect(height).toBeGreaterThan(330);
expect(height).toBeLessThan(400);
});
});
describe("offsetPolyline", () => {
it("returns the input unchanged when offset is 0", () => {
const pts = [
{ cx: 0, cy: 0 },
{ cx: 10, cy: 0 },
];
expect(offsetPolyline(pts, 0)).toBe(pts);
});
it("returns the input unchanged for fewer than 2 points", () => {
const pts = [{ cx: 5, cy: 5 }];
expect(offsetPolyline(pts, 4)).toBe(pts);
});
it("shifts a horizontal line perpendicular (downward in screen coords)", () => {
// Tangent +x → perpendicular (-ty, tx) = (0, +1) → shifts +y.
const out = offsetPolyline(
[
{ cx: 0, cy: 0 },
{ cx: 10, cy: 0 },
{ cx: 20, cy: 0 },
],
3,
);
out.forEach((p) => expect(close(p.cy, 3)).toBe(true));
expect(close(out[0].cx, 0)).toBe(true);
expect(close(out[2].cx, 20)).toBe(true);
});
it("puts opposite-direction chains on opposite sides for matching offsets", () => {
const forward = [
{ cx: 0, cy: 0 },
{ cx: 10, cy: 0 },
];
const backward = [
{ cx: 10, cy: 0 },
{ cx: 0, cy: 0 },
];
// Same geometric route, stored reversed. With the SAME offset value the
// canonicalised perpendicular must land them on the same physical side.
const a = offsetPolyline(forward, 3);
const b = offsetPolyline(backward, 3);
a.forEach((p) => expect(close(p.cy, 3)).toBe(true));
b.forEach((p) => expect(close(p.cy, 3)).toBe(true));
});
});
describe("computeLaneOffsets", () => {
it("returns all-zero for fewer than 2 chains", () => {
expect(computeLaneOffsets([])).toEqual([]);
expect(
computeLaneOffsets([
{ hexes: ["0_0", "1_0"], width: 4, typeName: "river" },
]),
).toEqual([0]);
});
it("leaves non-overlapping chains at offset 0", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0"], width: 4, typeName: "road" },
{ hexes: ["5_5", "6_5"], width: 4, typeName: "river" },
]);
expect(out).toEqual([0, 0]);
});
it("splits two DIFFERENT-type chains sharing a segment into symmetric lanes", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "road" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
]);
// 2 distinct types, gap = maxWidth(4) + 2 = 6, mid = 0.5 → offsets -3, +3.
expect(out).toEqual([-3, 3]);
});
it("MERGES same-type chains on the same route (no offset)", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
]);
// Single distinct type in the group → both stay centred so they overlap.
expect(out).toEqual([0, 0]);
});
it("merges same-type but still offsets a different type sharing the route", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "road" },
]);
// 2 distinct types (river lane 0, road lane 1), mid = 0.5, gap = 6.
// Both rivers share the river lane → -3; the road → +3.
expect(out).toEqual([-3, -3, 3]);
});
it("treats reversed hex order as the same route (shared segment)", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "road" },
{ hexes: ["2_0", "1_0", "0_0"], width: 4, typeName: "river" },
]);
expect(out).toEqual([-3, 3]);
});
it("does not split chains that merely cross at a single hex", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_1"], width: 4, typeName: "road" },
{ hexes: ["1_1", "2_2"], width: 4, typeName: "river" },
]);
// They share hex 1_1 but no common segment → no offset.
expect(out).toEqual([0, 0]);
});
it("fans three distinct types sharing a route into three centred lanes", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "road" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "trail" },
]);
// 3 distinct types, gap = 6, mid = 1 → lanes -6, 0, +6.
expect(out).toEqual([-6, 0, 6]);
});
});