Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 61 additions & 21 deletions apps/mapgen-studio/src/features/viz/deckgl/render.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Layer } from "@deck.gl/core";
import { LineLayer, ScatterplotLayer, PolygonLayer } from "@deck.gl/layers";
import {
TILE_BORDER_COLOR,
buildCategoricalColorMap,
writeColorForScalarValue,
} from "../presentation";
Expand Down Expand Up @@ -71,26 +72,27 @@ function isTileSpace(spaceId: VizSpaceId): boolean {
return spaceId === "tile.hexOddR" || spaceId === "tile.hexOddQ";
}

function axialToPixelPointy(q: number, r: number, size: number): [number, number] {
const x = size * Math.sqrt(3) * (q + r / 2);
const y = size * 1.5 * r;
return [x, y];
}

function oddQToAxialR(row: number, colParityBase: number): number {
const q = colParityBase | 0;
return row - (q - (q & 1)) / 2;
}
// Odd-q tile positions MUST match mapgen-core's canonical hex space
// (`projectOddqToHexSpace`: HEX_WIDTH = √3, HEX_HEIGHT = 1.5) — the Delaunay
// mesh and every `world.xy` layer live in that frame, so tiles co-register
// with them only on the same lattice: columns √3·size apart, rows 1.5·size
// apart, odd COLUMNS shifted down 0.75·size (pre-flip y-down coords; the
// shared north-up flip happens after).
const ODD_Q_COL_SPACING = Math.sqrt(3);
const ODD_Q_ROW_SPACING = 1.5;
const ODD_Q_COL_OFFSET = ODD_Q_ROW_SPACING / 2;

function oddQTileCenter(col: number, row: number, size: number): [number, number] {
const r = oddQToAxialR(row, col);
return axialToPixelPointy(col, r, size);
const x = size * ODD_Q_COL_SPACING * col;
const y = size * (ODD_Q_ROW_SPACING * row + ((col & 1) ? ODD_Q_COL_OFFSET : 0));
return [x, y];
}

function oddQPointFromTileXY(x: number, y: number, size: number): [number, number] {
const qParityBase = Math.round(x);
const r = oddQToAxialR(y, qParityBase);
return axialToPixelPointy(x, r, size);
const colParity = Math.round(x) & 1;
const px = size * ODD_Q_COL_SPACING * x;
const py = size * (ODD_Q_ROW_SPACING * y + (colParity ? ODD_Q_COL_OFFSET : 0));
return [px, py];
}

function oddRTileCenter(col: number, row: number, size: number): [number, number] {
Expand Down Expand Up @@ -136,6 +138,31 @@ function hexPolygonPointy(center: [number, number], size: number): Array<[number
return out;
}

// The hexagon that exactly tiles the canonical odd-q lattice: a flat-top hex
// (column offset ⇒ flat-top orientation) with its vertical pitch compressed
// to the lattice's 1.5·size row spacing. Vertices, counterclockwise from
// east: E, NE, NW, W, SW, SE. Adjacent cells share edges exactly — no gaps,
// no overlaps, no phantom seams.
function hexPolygonOddQ(center: [number, number], size: number): Array<[number, number]> {
const [cx, cy] = center;
const rx = (2 / Math.sqrt(3)) * size; // east/west vertex reach
const ix = (1 / Math.sqrt(3)) * size; // diagonal vertex x
const iy = (ODD_Q_ROW_SPACING / 2) * size; // diagonal vertex y (half the row pitch)
return [
[cx + rx, cy],
[cx + ix, cy + iy],
[cx - ix, cy + iy],
[cx - rx, cy],
[cx - ix, cy - iy],
[cx + ix, cy - iy],
];
}

/** Row-offset (odd-R) lattices are pointy-top; the column-offset (odd-Q) lattice is flat-top. */
function hexPolygonForSpace(spaceId: VizSpaceId, center: [number, number], size: number): Array<[number, number]> {
return spaceId === "tile.hexOddQ" ? hexPolygonOddQ(center, size) : hexPolygonPointy(center, size);
}

function hexGridGeometryKey(args: { spaceId: VizSpaceId; width: number; height: number; tileSize: number }): string {
return `${args.spaceId}:${args.width}x${args.height}:s${args.tileSize}`;
}
Expand All @@ -162,7 +189,7 @@ async function getOrBuildHexGridGeometry(args: {
const x = i % width;
const y = (i / width) | 0;
const center = tileCenter(spaceId, x, y, tileSize);
polygons[i] = hexPolygonPointy(center, tileSize);
polygons[i] = hexPolygonForSpace(spaceId, center, tileSize);
}

const geom: HexGridGeometry = { indices, polygons };
Expand Down Expand Up @@ -190,11 +217,14 @@ export function boundsForTileGrid(spaceId: VizSpaceId, dims: { width: number; he
return [-s, -maxCenterY - s, maxCenterX + s, s];
}

// tile.hexOddQ
// tile.hexOddQ — the canonical hex-space lattice (matches the Delaunay
// world): columns √3·s apart, rows 1.5·s apart, odd columns +0.75·s.
const hasOddCol = width > 1;
const maxCenterX = s3 * (width - 1);
const maxCenterY = 1.5 * tileSize * ((height - 1) + (hasOddCol ? 0.5 : 0));
return [-s, -maxCenterY - s, maxCenterX + s, s];
const maxCenterY = 1.5 * tileSize * (height - 1) + (hasOddCol ? 0.75 * tileSize : 0);
const padX = (2 / Math.sqrt(3)) * tileSize;
const padY = 0.75 * tileSize;
return [-padX, -maxCenterY - padY, maxCenterX + padX, padY];
}

export function boundsForLayerInRenderSpace(layer: VizLayerEntryV1, tileSize = 1): Bounds {
Expand Down Expand Up @@ -388,7 +418,12 @@ async function renderSingleLayer(options: RenderSingleLayerArgs): Promise<Render
return [colors[base] ?? 0, colors[base + 1] ?? 0, colors[base + 2] ?? 0, colors[base + 3] ?? 255];
},
stroked: true,
getLineColor: [17, 24, 39, 220],
// Mesh contract: unfilled tiles draw nothing — the border follows
// the fill's alpha so transparent tiles leave no phantom mesh.
getLineColor: (i) => {
const alpha = colors[Number(i) * 4 + 3] ?? 0;
return alpha === 0 ? [0, 0, 0, 0] : TILE_BORDER_COLOR;
},
getLineWidth: 1,
lineWidthUnits: "pixels",
opacity,
Expand Down Expand Up @@ -636,7 +671,12 @@ async function renderSingleLayer(options: RenderSingleLayerArgs): Promise<Render
return [colors[base] ?? 0, colors[base + 1] ?? 0, colors[base + 2] ?? 0, colors[base + 3] ?? 255];
},
stroked: true,
getLineColor: [17, 24, 39, 220],
// Mesh contract: unfilled tiles draw nothing — the border follows
// the fill's alpha so transparent tiles leave no phantom mesh.
getLineColor: (i) => {
const alpha = colors[Number(i) * 4 + 3] ?? 0;
return alpha === 0 ? [0, 0, 0, 0] : TILE_BORDER_COLOR;
},
getLineWidth: 1,
lineWidthUnits: "pixels",
opacity,
Expand Down
14 changes: 13 additions & 1 deletion apps/mapgen-studio/src/features/viz/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,19 @@ const VALUE_RAMP: ReadonlyArray<RgbaColor> = [
[253, 231, 37, 230],
];

const UNKNOWN_COLOR: RgbaColor = [120, 120, 120, 220];
// Tile-mesh contract (Pass-5 tile-orientation spec): tiles "not filled by
// anything" — noData sentinels and non-finite values — render NOTHING, so no
// phantom mesh floats over the canvas background. Renderers key per-tile
// stroke alpha off the fill alpha, so transparent fills drop their borders.
const UNKNOWN_COLOR: RgbaColor = [0, 0, 0, 0];

/**
* The one tile-border ink (Pass-5 tile-orientation spec): a mid-luminance
* slate chosen to stay legible against white, black, and graphite canvas
* backgrounds. All tile-space polygon strokes use this — no per-call border
* literals.
*/
export const TILE_BORDER_COLOR: RgbaColor = [100, 116, 139, 220];

type ColorOut = { [index: number]: number };

Expand Down
97 changes: 97 additions & 0 deletions apps/mapgen-studio/test/viz/tileOrientation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,101 @@ describe("tile-space rendering orientation", () => {
expect(minY).toBeLessThan(0);
expect(maxY).toBeGreaterThan(0);
});

// Orientation contract (Pass-5 tile-orientation spec): row-offset (odd-R)
// lattices are pointy-top — a vertex sits straight above the center; the
// column-offset (odd-Q) lattice is flat-top — a vertex sits straight to
// the center's right (at 2/√3·size: the canonical-lattice tiling hex), and
// never straight above.
it.each([
["tile.hexOddR", "pointy"],
["tile.hexOddQ", "flat"],
] as const)("renders %s as %s-top hexes", async (spaceId, orientation) => {
const layer = gridLayer(spaceId);
const manifest: VizManifestV1 = {
runId: "test-run",
outputRoot: "browser://viz",
steps: [{ stepId: "test.step", stepIndex: 0 }],
layers: [layer],
};
const result = await renderDeckLayers({ manifest, layer, showEdgeOverlay: false });
const hexLayer = result.layers.find((candidate) => String(candidate.id).endsWith("::hex"));
if (!hexLayer) throw new Error("missing rendered hex layer");

const polygon = (hexLayer as any).props.getPolygon(0) as Array<[number, number]>;
const [cx, cy] = polygon
.reduce(([sx, sy], [x, y]) => [sx + x, sy + y], [0, 0])
.map((v) => v / polygon.length);
const hasVertexAbove = polygon.some(([x, y]) => Math.abs(x - cx) < 1e-6 && Math.abs(y - cy - 1) < 1e-6);
const hasVertexRight = polygon.some(
([x, y]) => Math.abs(y - cy) < 1e-6 && Math.abs(x - cx - 2 / Math.sqrt(3)) < 1e-6
);
if (orientation === "pointy") {
expect(hasVertexAbove).toBe(true);
expect(hasVertexRight).toBe(false);
} else {
expect(hasVertexRight).toBe(true);
expect(hasVertexAbove).toBe(false);
}
});

// The odd-Q lattice must match mapgen-core's canonical hex space
// (`projectOddqToHexSpace`): columns √3·size apart in x, odd columns
// dropped 0.75·size — the frame the Delaunay mesh (world.xy) lives in, so
// tile layers co-register with mesh layers.
it("places odd-Q tiles on the canonical hex-space lattice", async () => {
const layer = gridLayer("tile.hexOddQ");
const manifest: VizManifestV1 = {
runId: "test-run",
outputRoot: "browser://viz",
steps: [{ stepId: "test.step", stepIndex: 0 }],
layers: [layer],
};
const result = await renderDeckLayers({ manifest, layer, showEdgeOverlay: false });
const hexLayer = result.layers.find((candidate) => String(candidate.id).endsWith("::hex"));
if (!hexLayer) throw new Error("missing rendered hex layer");

const centerOf = (index: number): [number, number] => {
const polygon = (hexLayer as any).props.getPolygon(index) as Array<[number, number]>;
const [sx, sy] = polygon.reduce(([ax, ay], [x, y]) => [ax + x, ay + y], [0, 0]);
return [sx / polygon.length, sy / polygon.length];
};

// Tiles 0 and 1 are columns 0 and 1 of row 0; tile 2 is row 1 column 0.
const [c0x, c0y] = centerOf(0);
const [c1x, c1y] = centerOf(1);
const [, c2y] = centerOf(2);
expect(c1x - c0x).toBeCloseTo(Math.sqrt(3), 6);
expect(c0y - c1y).toBeCloseTo(0.75, 6); // odd column drops (north-up flipped)
expect(c0y - c2y).toBeCloseTo(1.5, 6); // row pitch
});

it("renders noData tiles fully transparent — no fill, no border (mesh contract)", async () => {
const layer: VizLayerEntryV1 = {
...gridLayer("tile.hexOddR"),
field: {
format: "u8",
data: { kind: "inline", buffer: new Uint8Array([1, 255, 3, 4]).buffer },
valueSpec: { noData: { kind: "value", value: 255 } },
},
};
const manifest: VizManifestV1 = {
runId: "test-run",
outputRoot: "browser://viz",
steps: [{ stepId: "test.step", stepIndex: 0 }],
layers: [layer],
};
const result = await renderDeckLayers({ manifest, layer, showEdgeOverlay: false });
const hexLayer = result.layers.find((candidate) => String(candidate.id).endsWith("::hex"));
if (!hexLayer) throw new Error("missing rendered hex layer");

const fill = (hexLayer as any).props.getFillColor(1) as number[];
const line = (hexLayer as any).props.getLineColor(1) as number[];
expect(fill[3]).toBe(0);
expect(line[3]).toBe(0);

// A filled neighbor keeps its border (the shared tile ink).
const filledLine = (hexLayer as any).props.getLineColor(0) as number[];
expect(filledLine[3]).toBeGreaterThan(0);
});
});
12 changes: 12 additions & 0 deletions docs/projects/mapgen-studio-redesign/pass-5-design-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ the `boundsForTileGrid` odd-Q branch (spacing math looks transposed from
pointy-top). The geometry cache key (`spaceId:WxH:s{size}`) already
separates spaces, so no stale-cache hazard — but confirm.

**Implementation addendum (deeper grounding):** the textbook flat-top layout
(columns 1.5·s, rows √3·s) was rejected mid-slice. mapgen-core's canonical
hex space (`projectOddqToHexSpace`: HEX_WIDTH √3, HEX_HEIGHT 1.5, odd
columns +0.75) is the frame the Delaunay mesh — and therefore every
`world.xy` layer — is built in; tiles co-register with those stages only on
the same lattice. Odd-Q therefore renders flat-top hexes whose vertical
pitch is compressed to the 1.5·s row spacing (the exact tiling hexagon of
that lattice). Note for a future engine conversation: Civ7's native
adjacency (E/W/NE/NW/SE/SW, no N/S) is pointy-top row-offset, while
mapgen-core models odd-q column-offset — a model↔game convention gap that
is upstream of the studio and out of scope here.

Second half — **the tile-mesh contract**, standardized across all
visualizations and both themes:

Expand Down
49 changes: 49 additions & 0 deletions openspec/changes/mapgen-studio-tile-orientation/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Tile orientation (odd-Q flat-top) + the tile-mesh contract

## Why

The user flagged tiles rendering in the wrong orientation ("might be an odd
Q vs odd R issue"). Root cause: `render.ts` renders BOTH tile spaces with
pointy-top geometry — `tile.hexOddQ` centers go through a pointy-top axial
conversion and every polygon is built with vertices at 30°+60°·i. Column-
offset (odd-q) layouts are flat-top by construction; the result was rotated
tiles on a sheared lattice for every odd-Q visualization. Alongside the
orientation fix, the user set a tile-mesh contract: borders legible against
any canvas background, unfilled tiles fully invisible (no phantom mesh), one
consistent background treatment.

## Target Authority Refs

- `docs/projects/mapgen-studio-redesign/pass-5-design-fixes.md` (X3)

## What Changes

- `render.ts`: odd-Q centers use the model's canonical hex-space lattice —
the same frame mapgen-core's `projectOddqToHexSpace` defines and the
Delaunay mesh (`world.xy` layers) is built in: `x = √3·s·col`,
`y = 1.5·s·row + (col odd ? 0.75·s : 0)`, then the shared north-up flip.
(A textbook flat-top layout — `1.5·s` columns, `√3·s` rows — was
considered and rejected: its world frame would misalign every tile stage
against the mesh/world stages of the same run.) The fractional-point
variant matches; the dead pointy-axial helpers are removed.
`hexPolygonOddQ` — the flat-top hexagon that exactly tiles the canonical
lattice (vertical pitch compressed to the 1.5·s row spacing) — joins
`hexPolygonPointy`, and the grid-geometry builder dispatches by space.
`boundsForTileGrid`'s odd-Q branch covers the same lattice.
- Tile-mesh contract: `UNKNOWN_COLOR` (noData / non-finite values) becomes
fully transparent — unfilled tiles draw nothing; the per-tile hex stroke
follows its fill's alpha (no border around invisible tiles); the border
ink becomes the exported `TILE_BORDER_COLOR`, a mid-luminance slate
legible on white, black, and graphite (replacing the near-black literal
that vanished on dark canvases). The square background graticule
(`bg.mesh.grid`, already theme-aware) remains the only sanctioned empty
mesh. Vector-arrow ink is data marking, not tile mesh — out of scope.
- Tests: flat-top vs pointy-top vertex assertions per space; noData tiles
render with zero fill and stroke alpha.

## Impact

- Affected specs: `mapgen-studio`
- Affected code: `apps/mapgen-studio/src/features/viz/deckgl/render.ts`,
`apps/mapgen-studio/src/features/viz/presentation.ts`,
`apps/mapgen-studio/test/viz/tileOrientation.test.ts`
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## ADDED Requirements

### Requirement: Tile Spaces Render With Their Native Hex Orientation

The renderer SHALL draw `tile.hexOddR` layers as pointy-top hexes on a
row-offset lattice, and `tile.hexOddQ` layers as flat-top hexes on the
model's canonical hex-space lattice (`projectOddqToHexSpace`: columns
√3·size apart, rows 1.5·size apart, odd columns shifted 0.75·size) — the
same frame the Delaunay mesh and all `world.xy` layers live in — with
centers, fractional points, polygons, and grid bounds all derived from the
same convention per space.

#### Scenario: Odd-R renders pointy-top

- **WHEN** a grid layer in `tile.hexOddR` renders
- **THEN** its hex polygons have vertices at 30°+60°·i around their centers
(top edge is a point) and rows offset alternately by half a tile width

#### Scenario: Odd-Q renders flat-top on the canonical lattice

- **WHEN** a grid layer in `tile.hexOddQ` renders
- **THEN** its hexes are flat-top (an east/west vertex, no vertex straight
above center), tile the canonical lattice exactly (no gaps, overlaps, or
shear), and co-register with `world.xy` layers of the same run
- **AND** `boundsForTileGrid` covers exactly that lattice

### Requirement: Unfilled Tiles Render Nothing

Tiles whose value is noData or non-finite SHALL render fully transparent —
zero fill alpha and zero stroke alpha — so only filled tiles are visible and
no phantom tile mesh appears over the canvas background.

#### Scenario: noData tile is invisible

- **WHEN** a grid tile's value matches the layer's declared noData sentinel
- **THEN** that tile's fill alpha is 0 and its hex border alpha is 0

### Requirement: Tile Borders Use One Background-Legible Ink

Filled tiles SHALL stroke their hex border with the single shared
`TILE_BORDER_COLOR` — a mid-luminance ink legible against white, black, and
graphite canvas backgrounds — replacing per-call hardcoded border colors.

#### Scenario: Borders share the standard ink

- **WHEN** any tile-space polygon layer renders (grid or gridFields)
- **THEN** filled tiles stroke with `TILE_BORDER_COLOR`
20 changes: 20 additions & 0 deletions openspec/changes/mapgen-studio-tile-orientation/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## 1. Implementation

- [x] 1.1 Odd-Q canonical hex-space math (centers + fractional points,
matching `projectOddqToHexSpace`); remove the dead pointy-axial
helpers.
- [x] 1.2 `hexPolygonOddQ` (the canonical lattice's tiling hexagon) +
per-space polygon dispatch in the grid geometry builder.
- [x] 1.3 `boundsForTileGrid` odd-Q branch covers the canonical lattice.
- [x] 1.4 Mesh contract: transparent `UNKNOWN_COLOR`, per-tile stroke alpha
follows fill alpha, shared `TILE_BORDER_COLOR` ink.
- [x] 1.5 Tests: per-space vertex orientation; canonical lattice spacing;
noData tile invisibility.

## 2. Verification

- [x] 2.1 `bun run openspec -- validate mapgen-studio-tile-orientation --strict`
- [x] 2.2 tsc + vitest green (167)
- [x] 2.3 Visual on :5173 (dark + light): odd-Q tile layers render flat-top
on a clean lattice (no shear, no gaps), zoom inspection confirms
shared edges. Screenshots.