mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 06:14:01 +00:00
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>
This commit is contained in:
parent
ee6ba76274
commit
4cd0550a9a
5 changed files with 461 additions and 43 deletions
133
ISSUES.md
Normal file
133
ISSUES.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Upstream Issues Tracker
|
||||
|
||||
Local planning mirror of open issues on the GitHub repo
|
||||
(`sbuffkin/hexmaker` — this repo's `origin`). Snapshot taken **2026-06-29**.
|
||||
|
||||
This file is a planning aid, not the source of truth — GitHub is. Update the
|
||||
**Status** line as work lands. duckmage is ahead of the public issue tracker, so
|
||||
several requests are already shipped here.
|
||||
|
||||
Status legend: 🔴 not started · 🟡 partial / infra exists · 🟢 done in duckmage · 🔬 research-only
|
||||
|
||||
---
|
||||
|
||||
## #30 — Draw road and river parallel 🟢 (2026-06-29)
|
||||
> If a road and river follow the same path, they are drawn side by side rather
|
||||
> than the latter covering the earlier one.
|
||||
|
||||
- **Done.** `computeLaneOffsets()` + `offsetPolyline()` in
|
||||
`src/hex-map/hexGeometry.ts`; wired into `renderPathOverlay()`
|
||||
(`src/hex-map/HexMapView.ts`) and the PNG exporter (`src/export/mapPngRenderer.ts`).
|
||||
- Chains sharing a *segment* (adjacent-hex pair) are grouped via union-find and
|
||||
given symmetric perpendicular lane offsets (gap = widest stroke + 2px). Chains
|
||||
with no shared segment keep offset 0 → output pixel-identical to before.
|
||||
Perpendicular is direction-canonicalised so reversed chains still split apart.
|
||||
- Tests in `tests/hexGeometry.test.ts` (offset math, segment grouping,
|
||||
single-hex-crossing exclusion, reversed-order routes).
|
||||
- Verified: type-check + build + 361 tests + eslint all green.
|
||||
|
||||
## #29 — Hex flower generation 🔴
|
||||
> Dynamically generate a map / "hex flower" so you can roll the "next hex".
|
||||
|
||||
- Procedural-generation feature. No infra yet. Larger design effort; the reporter
|
||||
themselves notes a richer palette may serve the same need.
|
||||
- **Plan (deferred):** scope as an optional generator command. Revisit after the
|
||||
smaller items clear.
|
||||
|
||||
## #27 — Settlement/ruin icons + auto-icon on town create 🟡
|
||||
> Where are the screenshot icons? + auto-place an icon (e.g. a black dot) when a
|
||||
> town is created.
|
||||
|
||||
- Two parts: (a) **docs/help** — point users at the bundled icon set
|
||||
(`src/bundledIcons.ts`, plugin `icons/`); (b) **feature** — auto-stamp an icon
|
||||
override on a hex when a linked town/settlement is added. No auto-icon wiring
|
||||
found in `src/`.
|
||||
- **Plan:** (a) is a quick `src/hex-map/help.md` / README note. (b) optional
|
||||
setting "auto-icon on settlement link" with a default dot icon.
|
||||
|
||||
## #24 — Tag-based structure (tags instead of hardcoded folders) 🔴
|
||||
> Allow tags (e.g. `#Town`, `#Settlement`) as a content source instead of fixed
|
||||
> folders, so existing vaults don't have to be reorganised.
|
||||
|
||||
- Today all link sources are folder-scoped (`townsFolder`, etc.). No tag-source
|
||||
path in `src/`.
|
||||
- **Plan (larger):** add an optional per-section "source = folder | tag" mode.
|
||||
Touches every link picker + enumeration site. Significant; design note first.
|
||||
|
||||
## #23 — Image-based backgrounds 🟢 (zoom-drift fix 2026-06-29, needs Mac verify)
|
||||
> An image layer under the hex map that zooms/pans with the grid.
|
||||
|
||||
- Implemented: bg image layer (`.duckmage-bg-image-layer`), `MapModal`,
|
||||
`newMapFields.ts` background fields, renders in export via `mapPngRenderer.ts`.
|
||||
- **Mac zoom-drift bug (user-reported, not locally reproducible):** hex grid
|
||||
shifts off the bg image when zooming. Root cause in `bakeZoom()`
|
||||
(`HexMapView.ts`): grid is `em`/font-size driven, bg is `px`/transform driven;
|
||||
baking grows them through two separate arithmetic paths that only match under
|
||||
exact arithmetic. Device-pixel snapping of the em layout on fractional/Retina
|
||||
DPR (macOS) rounds differently than the smoothly-scaled bg → visible jump on
|
||||
zoom settle. No `navigator.platform`/`devicePixelRatio`/CSS-`zoom` code is
|
||||
involved — it's pure sub-pixel rounding divergence.
|
||||
- **Fix:** `scheduleZoomBake()` now skips baking whenever a bg image is present
|
||||
(mirrors calibration-mode behaviour) — zoom stays a single composited viewport
|
||||
`scale()`, so grid + bg scale as one unit and can't drift on any DPR.
|
||||
- **Action:** verify on a Mac/Retina display that the grid no longer slips on
|
||||
zoom; then close upstream.
|
||||
- **Perf — opening a bg-image map is sluggish (under investigation):**
|
||||
Benchmarked the render path headlessly (`dev/bg-hex-recalc-bench.{html,mjs}`,
|
||||
chult's 63×61 = 3843 hexes). Findings:
|
||||
- Building the 3843 hex DOM nodes ≈ **27 ms/render**, present with **or
|
||||
without** a bg image — this is the dominant main-thread cost and is not
|
||||
bg-specific.
|
||||
- The old `:not([style*="background-color"])` empty-hex selector added
|
||||
**~4–6 ms/render**; switched to a `.duckmage-hex-painted` class selector
|
||||
(`setHexColor` helper) → overhead gone. ✅ landed.
|
||||
- `renderGrid()`'s `viewportEl.empty()` recreated the `<img>` every render,
|
||||
forcing image re-decode/re-raster. Now caches & re-attaches the layer
|
||||
(`bgLayerEl`/`bgLayerSrc`), skipped during calibration. ✅ landed.
|
||||
- Earlier paint hypothesis was **wrong** — the user clarified it stalls on
|
||||
*initial open*, not pan, and it's a regression.
|
||||
- **ROOT CAUSE FOUND (regression) — `renderCoordLabelsLayer` layout thrash:**
|
||||
introduced in commit 10475fd (coord label layer). It read each hex's
|
||||
`offsetWidth/Left/Top` and *then created the label DOM inside the same loop* —
|
||||
every read after a write forces a full synchronous layout → **O(n²)**. At
|
||||
chult's 3843 hexes that's a **~21s** main-thread stall on open (the bg only
|
||||
amplifies ~1.1×; the real driver is hex count — chult is just the biggest map
|
||||
AND has a bg). Benchmarked with `dev/coord-label-thrash-bench.{html,mjs}`:
|
||||
thrash 20770ms → batched 105ms (**~198×**).
|
||||
- **Fix (landed):** split read phase from write phase — read all hex geometry
|
||||
into an array first, then create all labels. Matches the read-then-write
|
||||
pattern already used by `renderPathOverlay`/`renderFactionOverlay`.
|
||||
- **Guard (landed):** CLAUDE.md convention forbidding interleaved layout-read /
|
||||
DOM-write in per-hex loops + the committed benchmark as the reproducer.
|
||||
|
||||
## #20 — Mobile support 🔬
|
||||
> Research feasibility of mobile.
|
||||
|
||||
- Research tracking issue. No commitment.
|
||||
- **Plan:** audit pointer/hover assumptions and modal sizing; record findings.
|
||||
|
||||
## #12 — Exports and imports 🟡
|
||||
> Map PNG/JPG export; random-table PDF export; palette/icon export as zip.
|
||||
|
||||
- Export subsystem exists: `src/export/` (PDF, PNG, HTML, md serializer; exporters
|
||||
for map+table, random table, single hex/note, workflow).
|
||||
- **Gaps:** palette/icon **export-as-zip** and any **import** path not evident.
|
||||
- **Plan:** confirm coverage vs the three asks; scope palette/icon bundle
|
||||
export + import separately.
|
||||
|
||||
## #10 — Space palette (+ more default palettes) 🟡
|
||||
> Add a Space default palette; world/country palette maybe better as map-image link.
|
||||
|
||||
- Palette infra supports multiple named presets (`Limited`, `Expanded` in
|
||||
`src/constants.ts`); no `Space` preset yet.
|
||||
- **Plan:** add a `SPACE_TERRAIN_PALETTE` preset + register it in
|
||||
`DEFAULT_SETTINGS.terrainPalettes`. Small, additive.
|
||||
|
||||
---
|
||||
|
||||
### Suggested order (low-hanging first)
|
||||
1. **#30** road/river parallel — small, self-contained. (in progress)
|
||||
2. **#10** Space palette — additive preset.
|
||||
3. **#27a** icon help docs — doc-only.
|
||||
4. **#23 / #12** verify-and-close / gap-scope existing features.
|
||||
5. **#27b, #24, #29, #20** — larger design work, deferred.
|
||||
|
|
@ -20,6 +20,8 @@ import {
|
|||
buildMeanderPts,
|
||||
smoothPath,
|
||||
sharpPath,
|
||||
offsetPolyline,
|
||||
computeLaneOffsets,
|
||||
} from "../hex-map/hexGeometry";
|
||||
import { ensureExportFolder } from "./exportFolder";
|
||||
import {
|
||||
|
|
@ -356,30 +358,42 @@ export async function renderMapToPngBlob(
|
|||
);
|
||||
}
|
||||
|
||||
// Paths
|
||||
// Paths — flatten in draw order, then offset chains that share a route into
|
||||
// parallel lanes so a road and a river on the same hexes render side by side
|
||||
// (issue #30), matching HexMapView's on-screen behaviour.
|
||||
if (showPaths) {
|
||||
for (const pt of plugin.settings.pathTypes) {
|
||||
const renderables = plugin.settings.pathTypes.flatMap((pt) =>
|
||||
map.pathChains
|
||||
.filter((c) => c.typeName === pt.name)
|
||||
.map((chain) => ({ chain, pt })),
|
||||
);
|
||||
const laneOffset = computeLaneOffsets(
|
||||
renderables.map((r) => ({
|
||||
hexes: r.chain.hexes,
|
||||
width: r.pt.width,
|
||||
typeName: r.pt.name,
|
||||
})),
|
||||
);
|
||||
renderables.forEach(({ chain, pt }, idx) => {
|
||||
const dash = dashFor(pt.lineStyle);
|
||||
const chains = map.pathChains.filter((c) => c.typeName === pt.name);
|
||||
for (const chain of chains) {
|
||||
let pts;
|
||||
let smooth: boolean;
|
||||
if (pt.routing === "edge") {
|
||||
pts = buildEdgePts(chain.hexes, shifted, isFlat, R);
|
||||
smooth = false;
|
||||
} else if (pt.routing === "meander") {
|
||||
pts = buildMeanderPts(chain.hexes, shifted);
|
||||
smooth = true;
|
||||
} else {
|
||||
pts = chain.hexes
|
||||
.map((k) => shifted.get(k))
|
||||
.filter((p): p is { cx: number; cy: number } => !!p);
|
||||
smooth = true;
|
||||
}
|
||||
if (pts.length < 2) continue;
|
||||
drawPath(ctx, pts, pt.color, pt.width, dash, smooth);
|
||||
let pts;
|
||||
let smooth: boolean;
|
||||
if (pt.routing === "edge") {
|
||||
pts = buildEdgePts(chain.hexes, shifted, isFlat, R);
|
||||
smooth = false;
|
||||
} else if (pt.routing === "meander") {
|
||||
pts = buildMeanderPts(chain.hexes, shifted);
|
||||
smooth = true;
|
||||
} else {
|
||||
pts = chain.hexes
|
||||
.map((k) => shifted.get(k))
|
||||
.filter((p): p is { cx: number; cy: number } => !!p);
|
||||
smooth = true;
|
||||
}
|
||||
}
|
||||
if (pts.length < 2) return;
|
||||
pts = offsetPolyline(pts, laneOffset[idx]);
|
||||
drawPath(ctx, pts, pt.color, pt.width, dash, smooth);
|
||||
});
|
||||
}
|
||||
|
||||
return canvas.convertToBlob({ type: "image/png" });
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import {
|
|||
hexNeighbors,
|
||||
smoothPath,
|
||||
sharpPath,
|
||||
offsetPolyline,
|
||||
computeLaneOffsets,
|
||||
buildMeanderPts,
|
||||
buildEdgePts,
|
||||
} from "./hexGeometry";
|
||||
|
|
@ -2568,6 +2570,17 @@ export class HexMapView extends ItemView {
|
|||
// (instead of baking it into font-size) so the SVG outline overlay,
|
||||
// the hex grid, and the bg image all scale together as a single unit.
|
||||
if (this.bgCalibrating) return;
|
||||
// Same reasoning whenever a background image is present, but permanently:
|
||||
// baking grows the em-based hex grid via font-size while the px-based bg
|
||||
// image is grown through a separate `* F` multiply (see bakeZoom). Those
|
||||
// two paths only match under exact arithmetic — device-pixel snapping of
|
||||
// the em layout on fractional/Retina DPR (e.g. macOS) rounds differently
|
||||
// than the smoothly-scaled bg transform, so the grid visibly jumps off the
|
||||
// image the instant zoom settles. Keeping zoom as a single composited
|
||||
// viewport scale() makes grid + bg scale as one unit → no drift on any DPR.
|
||||
// Trade-off: hexes upscale via the compositor (slightly soft) at high zoom
|
||||
// over a bg, which is acceptable and matches calibration-mode behaviour.
|
||||
if (this.getActiveMap().backgroundImage) return;
|
||||
if (this.zoomSettleTimer !== null) window.clearTimeout(this.zoomSettleTimer);
|
||||
// 80ms ≈ 5 frames of quiet — short enough that coords un-blur quickly
|
||||
// after a scroll stops, long enough that a continuous wheel stream
|
||||
|
|
@ -4261,30 +4274,46 @@ export class HexMapView extends ItemView {
|
|||
const isFlat = this.plugin.settings.hexOrientation === "flat";
|
||||
const hexRadius = isFlat ? hexW / 2 : hexH / 2;
|
||||
|
||||
// Draw all path types in definition order
|
||||
for (const pt of this.plugin.settings.pathTypes) {
|
||||
const typeChains = region.pathChains.filter(
|
||||
(c) => c.typeName === pt.name,
|
||||
);
|
||||
// Flatten every chain into a render list in path-type definition order,
|
||||
// pairing each chain with its resolved type. Stable order = draw order.
|
||||
const renderables = this.plugin.settings.pathTypes.flatMap((pt) =>
|
||||
region.pathChains
|
||||
.filter((c) => c.typeName === pt.name)
|
||||
.map((chain) => ({ chain, pt })),
|
||||
);
|
||||
|
||||
// Assign each chain a perpendicular "lane" offset so chains that share a
|
||||
// route (e.g. a road and a river on the same hexes) render side by side
|
||||
// instead of one stroke covering the other (issue #30). Chains that don't
|
||||
// share any segment with another keep offset 0 → pixel-identical output.
|
||||
const laneOffset = computeLaneOffsets(
|
||||
renderables.map((r) => ({
|
||||
hexes: r.chain.hexes,
|
||||
width: r.pt.width,
|
||||
typeName: r.pt.name,
|
||||
})),
|
||||
);
|
||||
|
||||
renderables.forEach(({ chain, pt }, idx) => {
|
||||
const dash = DASH_ARRAYS[pt.lineStyle] ?? "";
|
||||
for (const chain of typeChains) {
|
||||
let pts: { cx: number; cy: number }[];
|
||||
let smooth: boolean;
|
||||
if (pt.routing === "meander") {
|
||||
pts = buildMeanderPts(chain.hexes, centerMap);
|
||||
smooth = true;
|
||||
} else if (pt.routing === "edge") {
|
||||
pts = buildEdgePts(chain.hexes, centerMap, isFlat, hexRadius);
|
||||
smooth = false;
|
||||
} else {
|
||||
pts = chain.hexes
|
||||
.map((k) => centerMap.get(k))
|
||||
.filter((p): p is { cx: number; cy: number } => !!p);
|
||||
smooth = true;
|
||||
}
|
||||
if (pts.length >= 2) appendPath(pts, pt.color, pt.width, dash, smooth);
|
||||
let pts: { cx: number; cy: number }[];
|
||||
let smooth: boolean;
|
||||
if (pt.routing === "meander") {
|
||||
pts = buildMeanderPts(chain.hexes, centerMap);
|
||||
smooth = true;
|
||||
} else if (pt.routing === "edge") {
|
||||
pts = buildEdgePts(chain.hexes, centerMap, isFlat, hexRadius);
|
||||
smooth = false;
|
||||
} else {
|
||||
pts = chain.hexes
|
||||
.map((k) => centerMap.get(k))
|
||||
.filter((p): p is { cx: number; cy: number } => !!p);
|
||||
smooth = true;
|
||||
}
|
||||
}
|
||||
if (pts.length < 2) return;
|
||||
pts = offsetPolyline(pts, laneOffset[idx]);
|
||||
appendPath(pts, pt.color, pt.width, dash, smooth);
|
||||
});
|
||||
|
||||
// Small circle to mark the active endpoint (only visible in path mode)
|
||||
if (this.drawingMode === "path" && this.activePathEnd) {
|
||||
|
|
|
|||
|
|
@ -183,6 +183,124 @@ export function sharpPath(pts: Pt[]): string {
|
|||
return "M " + pts.map((p) => `${p.cx} ${p.cy}`).join(" L ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift every point of a polyline perpendicular to its local tangent by
|
||||
* `offset` pixels, producing a curve parallel to the original. Used to render
|
||||
* path chains that share the same hexes (e.g. a road and a river on one route)
|
||||
* side by side instead of one stroke covering the other.
|
||||
*
|
||||
* The perpendicular is canonicalised per geometric tangent (independent of the
|
||||
* direction the chain's points were stored in), so two chains tracing the same
|
||||
* route in opposite orders still separate onto opposite sides rather than
|
||||
* landing on top of each other. `offset === 0` returns the input unchanged so
|
||||
* non-overlapping paths render pixel-identically to before.
|
||||
*/
|
||||
export function offsetPolyline(pts: Pt[], offset: number): Pt[] {
|
||||
if (offset === 0 || pts.length < 2) return pts;
|
||||
return pts.map((p, i) => {
|
||||
const prev = pts[i === 0 ? 0 : i - 1];
|
||||
const next = pts[i === pts.length - 1 ? i : i + 1];
|
||||
let tx = next.cx - prev.cx;
|
||||
let ty = next.cy - prev.cy;
|
||||
const len = Math.hypot(tx, ty);
|
||||
if (len === 0) return p;
|
||||
tx /= len;
|
||||
ty /= len;
|
||||
// Canonicalise tangent direction so the perpendicular at a given point on
|
||||
// the route is the same no matter which way the chain runs.
|
||||
if (tx < 0 || (tx === 0 && ty < 0)) {
|
||||
tx = -tx;
|
||||
ty = -ty;
|
||||
}
|
||||
// Perpendicular = tangent rotated 90°: (-ty, tx).
|
||||
return { cx: p.cx + -ty * offset, cy: p.cy + tx * offset };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign each path chain a perpendicular "lane" offset (in pixels) so chains of
|
||||
* *different* types that share a route render side by side rather than one
|
||||
* stroke covering the other (issue #30).
|
||||
*
|
||||
* Two chains are considered to share a route when they share at least one
|
||||
* *segment* — an unordered pair of adjacent hexes — not merely a single hex, so
|
||||
* paths that only cross at one cell are left untouched. Chains linked through
|
||||
* shared segments are grouped (transitively, via union-find).
|
||||
*
|
||||
* Within a group, lanes are assigned **per distinct path type**, not per chain:
|
||||
* chains of the *same* type share a lane (offset) so overlaid same-type paths
|
||||
* (e.g. two rivers tracing one route) merge into a single stroke, while
|
||||
* different types fan out into separate lanes. A group containing only one type
|
||||
* gets no offset. Lanes are centred on zero: `(lane - (T-1)/2) * gap` for T
|
||||
* distinct types, where `gap` is the widest stroke in the group plus a small
|
||||
* margin. A chain sharing no segment with any other keeps offset 0, so the
|
||||
* common single-path case renders pixel-identically to before. `chains` order
|
||||
* is the draw order and decides lane order (earlier type → more negative).
|
||||
*/
|
||||
export function computeLaneOffsets(
|
||||
chains: { hexes: string[]; width: number; typeName: string }[],
|
||||
): number[] {
|
||||
const n = chains.length;
|
||||
const offsets = new Array<number>(n).fill(0);
|
||||
if (n < 2) return offsets;
|
||||
|
||||
// Canonical (direction-independent) segment keys for each chain.
|
||||
const segSets = chains.map((c) => {
|
||||
const s = new Set<string>();
|
||||
for (let i = 0; i < c.hexes.length - 1; i++) {
|
||||
const a = c.hexes[i];
|
||||
const b = c.hexes[i + 1];
|
||||
s.add(a < b ? `${a}|${b}` : `${b}|${a}`);
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
// Union-find: group chains that share ≥1 segment.
|
||||
const parent = Array.from({ length: n }, (_, i) => i);
|
||||
const find = (i: number): number =>
|
||||
parent[i] === i ? i : (parent[i] = find(parent[i]));
|
||||
const union = (a: number, b: number) => {
|
||||
parent[find(a)] = find(b);
|
||||
};
|
||||
const segOwner = new Map<string, number>();
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (const seg of segSets[i]) {
|
||||
const owner = segOwner.get(seg);
|
||||
if (owner === undefined) segOwner.set(seg, i);
|
||||
else union(owner, i);
|
||||
}
|
||||
}
|
||||
|
||||
// Bucket by group root, preserving draw order within each group.
|
||||
const groups = new Map<number, number[]>();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const root = find(i);
|
||||
const g = groups.get(root);
|
||||
if (g) g.push(i);
|
||||
else groups.set(root, [i]);
|
||||
}
|
||||
|
||||
for (const members of groups.values()) {
|
||||
// Distinct types in this group, in first-seen (draw) order.
|
||||
const laneOfType = new Map<string, number>();
|
||||
for (const i of members) {
|
||||
if (!laneOfType.has(chains[i].typeName)) {
|
||||
laneOfType.set(chains[i].typeName, laneOfType.size);
|
||||
}
|
||||
}
|
||||
if (laneOfType.size < 2) continue; // single type → merge, no offset
|
||||
const maxWidth = Math.max(...members.map((i) => chains[i].width));
|
||||
const gap = maxWidth + 2;
|
||||
const mid = (laneOfType.size - 1) / 2;
|
||||
for (const i of members) {
|
||||
const lane = laneOfType.get(chains[i].typeName) ?? 0;
|
||||
offsets[i] = (lane - mid) * gap;
|
||||
}
|
||||
}
|
||||
|
||||
return offsets;
|
||||
}
|
||||
|
||||
// ── Chain routing helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
hexCenter,
|
||||
hexPolygonPoints,
|
||||
gridBoundingBox,
|
||||
offsetPolyline,
|
||||
computeLaneOffsets,
|
||||
} from "../src/hex-map/hexGeometry";
|
||||
|
||||
const SQRT3 = Math.sqrt(3);
|
||||
|
|
@ -136,3 +138,125 @@ describe("gridBoundingBox", () => {
|
|||
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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue