From dd1b7c0301d9118483e96a0e0ceef81fc31098fa Mon Sep 17 00:00:00 2001 From: Matei Date: Thu, 11 Jun 2026 22:21:27 -0400 Subject: [PATCH] fix(mapgen-studio): odd-Q tiles render flat-top on the canonical hex-space lattice + tile-mesh contract (transparent noData, shared border ink) --- .../src/features/viz/deckgl/render.ts | 82 ++++++++++++---- .../src/features/viz/presentation.ts | 14 ++- .../test/viz/tileOrientation.test.ts | 97 +++++++++++++++++++ .../pass-5-design-fixes.md | 12 +++ .../proposal.md | 49 ++++++++++ .../specs/mapgen-studio/spec.md | 47 +++++++++ .../mapgen-studio-tile-orientation/tasks.md | 20 ++++ 7 files changed, 299 insertions(+), 22 deletions(-) create mode 100644 openspec/changes/mapgen-studio-tile-orientation/proposal.md create mode 100644 openspec/changes/mapgen-studio-tile-orientation/specs/mapgen-studio/spec.md create mode 100644 openspec/changes/mapgen-studio-tile-orientation/tasks.md diff --git a/apps/mapgen-studio/src/features/viz/deckgl/render.ts b/apps/mapgen-studio/src/features/viz/deckgl/render.ts index 566b2fc7e1..ce12c7d3b3 100644 --- a/apps/mapgen-studio/src/features/viz/deckgl/render.ts +++ b/apps/mapgen-studio/src/features/viz/deckgl/render.ts @@ -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"; @@ -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] { @@ -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}`; } @@ -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 }; @@ -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 { @@ -388,7 +418,12 @@ async function renderSingleLayer(options: RenderSingleLayerArgs): Promise { + const alpha = colors[Number(i) * 4 + 3] ?? 0; + return alpha === 0 ? [0, 0, 0, 0] : TILE_BORDER_COLOR; + }, getLineWidth: 1, lineWidthUnits: "pixels", opacity, @@ -636,7 +671,12 @@ async function renderSingleLayer(options: RenderSingleLayerArgs): Promise { + const alpha = colors[Number(i) * 4 + 3] ?? 0; + return alpha === 0 ? [0, 0, 0, 0] : TILE_BORDER_COLOR; + }, getLineWidth: 1, lineWidthUnits: "pixels", opacity, diff --git a/apps/mapgen-studio/src/features/viz/presentation.ts b/apps/mapgen-studio/src/features/viz/presentation.ts index a3e65eea65..509564bca9 100644 --- a/apps/mapgen-studio/src/features/viz/presentation.ts +++ b/apps/mapgen-studio/src/features/viz/presentation.ts @@ -376,7 +376,19 @@ const VALUE_RAMP: ReadonlyArray = [ [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 }; diff --git a/apps/mapgen-studio/test/viz/tileOrientation.test.ts b/apps/mapgen-studio/test/viz/tileOrientation.test.ts index 1bb665c70d..046e304ef8 100644 --- a/apps/mapgen-studio/test/viz/tileOrientation.test.ts +++ b/apps/mapgen-studio/test/viz/tileOrientation.test.ts @@ -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); + }); }); diff --git a/docs/projects/mapgen-studio-redesign/pass-5-design-fixes.md b/docs/projects/mapgen-studio-redesign/pass-5-design-fixes.md index c550cf9b72..9e2068c691 100644 --- a/docs/projects/mapgen-studio-redesign/pass-5-design-fixes.md +++ b/docs/projects/mapgen-studio-redesign/pass-5-design-fixes.md @@ -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: diff --git a/openspec/changes/mapgen-studio-tile-orientation/proposal.md b/openspec/changes/mapgen-studio-tile-orientation/proposal.md new file mode 100644 index 0000000000..5f082aa796 --- /dev/null +++ b/openspec/changes/mapgen-studio-tile-orientation/proposal.md @@ -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` diff --git a/openspec/changes/mapgen-studio-tile-orientation/specs/mapgen-studio/spec.md b/openspec/changes/mapgen-studio-tile-orientation/specs/mapgen-studio/spec.md new file mode 100644 index 0000000000..4316422ee9 --- /dev/null +++ b/openspec/changes/mapgen-studio-tile-orientation/specs/mapgen-studio/spec.md @@ -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` diff --git a/openspec/changes/mapgen-studio-tile-orientation/tasks.md b/openspec/changes/mapgen-studio-tile-orientation/tasks.md new file mode 100644 index 0000000000..3041c24269 --- /dev/null +++ b/openspec/changes/mapgen-studio-tile-orientation/tasks.md @@ -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.