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
7 changes: 7 additions & 0 deletions apps/mapgen-studio/.interface-design/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,10 @@ supersedes the Pass-4 dock placement, keeps the console split + icon contract:
(`History`); hover tooltip presents the run (seed/size/players/resources),
the accessible name mirrors it, click copies the last seed (the affordance
the old inline seed button carried). Tooltip, never a popup.
- **Tile grid (X6, user-grounded):** tile layers render the GAME's plot
geometry — regular pointy-top hexes on the odd-R row-offset lattice (the
hex-convention audit proved `tile.hexOddQ` mislabels that grid; the model's
column-offset projection is not a regular tiling, hence the "squished"
look it produced). Tile borders use ONE graphite ink (`#0d0d11`, α200) in
both themes — dark seams against the fills; never a mid-luminance color
that competes with the data palette. Unfilled tiles draw nothing.
92 changes: 22 additions & 70 deletions apps/mapgen-studio/src/features/viz/deckgl/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,29 +72,16 @@ function isTileSpace(spaceId: VizSpaceId): boolean {
return spaceId === "tile.hexOddR" || spaceId === "tile.hexOddQ";
}

// 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 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 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];
}

// BOTH tile spaces render the GAME's plot geometry: pointy-top hexes on a
// row-offset (odd-R) lattice — columns √3·size apart, rows 1.5·size apart,
// odd ROWS shifted east half a tile (pre-flip y-down coords; the shared
// north-up flip happens after). `tile.hexOddQ` is, by audit evidence, a
// mislabel of this same grid: Civ7's direction set (E/W/NE/NW/SE/SW) and
// Firaxis's own debug dumpers (odd-row indents) are odd-R, and the engine
// boundary writes (x,y) untransposed. The world frame (√3·width ×
// 1.5·height) matches the Delaunay/world.xy frame, so cross-space layers
// co-register. See docs/projects/mapgen-studio-redesign/research/
// 03-hex-convention-audit.md.
function oddRTileCenter(col: number, row: number, size: number): [number, number] {
const x = size * Math.sqrt(3) * (col + ((row & 1) ? 0.5 : 0));
const y = size * 1.5 * row;
Expand All @@ -113,14 +100,12 @@ function orientTilePointNorthUp(point: [number, number]): [number, number] {
return [x, -y];
}

function tilePoint(spaceId: VizSpaceId, x: number, y: number, size: number): [number, number] {
const point = spaceId === "tile.hexOddQ" ? oddQPointFromTileXY(x, y, size) : oddRPointFromTileXY(x, y, size);
return orientTilePointNorthUp(point);
function tilePoint(_spaceId: VizSpaceId, x: number, y: number, size: number): [number, number] {
return orientTilePointNorthUp(oddRPointFromTileXY(x, y, size));
}

function tileCenter(spaceId: VizSpaceId, col: number, row: number, size: number): [number, number] {
const point = spaceId === "tile.hexOddQ" ? oddQTileCenter(col, row, size) : oddRTileCenter(col, row, size);
return orientTilePointNorthUp(point);
function tileCenter(_spaceId: VizSpaceId, col: number, row: number, size: number): [number, number] {
return orientTilePointNorthUp(oddRTileCenter(col, row, size));
}

function transformPoint(spaceId: VizSpaceId, x: number, y: number, tileSize: number): [number, number] {
Expand All @@ -138,30 +123,6 @@ 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 Down Expand Up @@ -189,7 +150,7 @@ async function getOrBuildHexGridGeometry(args: {
const x = i % width;
const y = (i / width) | 0;
const center = tileCenter(spaceId, x, y, tileSize);
polygons[i] = hexPolygonForSpace(spaceId, center, tileSize);
polygons[i] = hexPolygonPointy(center, tileSize);
}

const geom: HexGridGeometry = { indices, polygons };
Expand All @@ -203,28 +164,19 @@ async function getOrBuildHexGridGeometry(args: {
return geom;
}

export function boundsForTileGrid(spaceId: VizSpaceId, dims: { width: number; height: number }, tileSize: number): Bounds {
export function boundsForTileGrid(_spaceId: VizSpaceId, dims: { width: number; height: number }, tileSize: number): Bounds {
const { width, height } = dims;
if (width <= 0 || height <= 0) return [0, 0, 1, 1];

const s3 = Math.sqrt(3) * tileSize;
const s = tileSize;

if (spaceId === "tile.hexOddR") {
const hasOddRow = height > 1;
const maxCenterX = s3 * ((width - 1) + (hasOddRow ? 0.5 : 0));
const maxCenterY = 1.5 * tileSize * (height - 1);
return [-s, -maxCenterY - s, maxCenterX + s, s];
}

// 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.75 * tileSize : 0);
const padX = (2 / Math.sqrt(3)) * tileSize;
const padY = 0.75 * tileSize;
return [-padX, -maxCenterY - padY, maxCenterX + padX, padY];
// One lattice for both tile spaces: the game's odd-R grid (see the
// convention note above oddRTileCenter).
const hasOddRow = height > 1;
const maxCenterX = s3 * ((width - 1) + (hasOddRow ? 0.5 : 0));
const maxCenterY = 1.5 * tileSize * (height - 1);
return [-s, -maxCenterY - s, maxCenterX + s, s];
}

export function boundsForLayerInRenderSpace(layer: VizLayerEntryV1, tileSize = 1): Bounds {
Expand Down
13 changes: 8 additions & 5 deletions apps/mapgen-studio/src/features/viz/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,12 +383,15 @@ const VALUE_RAMP: ReadonlyArray<RgbaColor> = [
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.
* The one tile-border ink (Pass-5 tile-orientation spec, retuned on user
* feedback): graphite — the dark page substrate (#0d0d11), one ink in BOTH
* themes. Borders only ever separate FILLED tiles (unfilled tiles draw
* nothing), so a dark seam reads against the fills everywhere: in dark mode
* it recedes into the canvas like grout; in light mode it is a crisp
* graphite grid. All tile-space polygon strokes use this — no per-call
* border literals, no mid-luminance slate (it clashed with the palette).
*/
export const TILE_BORDER_COLOR: RgbaColor = [100, 116, 139, 220];
export const TILE_BORDER_COLOR: RgbaColor = [13, 13, 17, 200];

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

Expand Down
119 changes: 58 additions & 61 deletions apps/mapgen-studio/test/viz/tileOrientation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,73 +53,70 @@ describe("tile-space rendering orientation", () => {
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");
// Orientation contract (Pass-5, retuned by the hex-convention audit —
// docs/projects/mapgen-studio-redesign/research/03-hex-convention-audit.md):
// BOTH tile spaces render the GAME's plot geometry — regular pointy-top
// hexes (a vertex straight above the center, none straight east) on a
// row-offset odd-R lattice. `tile.hexOddQ` is a mislabel of that grid.
it.each(["tile.hexOddR", "tile.hexOddQ"] as const)(
"renders %s as regular pointy-top hexes (game geometry)",
async (spaceId) => {
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") {
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);
// Regular pointy-top: a vertex exactly `size` above the center, and
// every vertex exactly `size` away (no squash on any axis).
const hasVertexAbove = polygon.some(
([x, y]) => Math.abs(x - cx) < 1e-6 && Math.abs(y - cy - 1) < 1e-6
);
const allUnitRadius = polygon.every(([x, y]) => Math.abs(Math.hypot(x - cx, y - cy) - 1) < 1e-6);
expect(hasVertexAbove).toBe(true);
expect(hasVertexRight).toBe(false);
} else {
expect(hasVertexRight).toBe(true);
expect(hasVertexAbove).toBe(false);
expect(allUnitRadius).toBe(true);
}
});
);

// 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");
it.each(["tile.hexOddR", "tile.hexOddQ"] as const)(
"places %s tiles on the game's row-offset lattice",
async (spaceId) => {
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 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];
};
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
});
// Tiles 0 and 1 are columns 0 and 1 of row 0; tile 2 is row 1 col 0.
const [c0x, c0y] = centerOf(0);
const [c1x, c1y] = centerOf(1);
const [c2x, c2y] = centerOf(2);
expect(c1x - c0x).toBeCloseTo(Math.sqrt(3), 6); // column pitch
expect(c1y - c0y).toBeCloseTo(0, 6); // same row, same y
expect(c0y - c2y).toBeCloseTo(1.5, 6); // row pitch
expect(c2x - c0x).toBeCloseTo(Math.sqrt(3) / 2, 6); // odd row shifts east
}
);

it("renders noData tiles fully transparent — no fill, no border (mesh contract)", async () => {
const layer: VizLayerEntryV1 = {
Expand Down
11 changes: 11 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 @@ -103,6 +103,17 @@ 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.

**Superseded by X6 (same session):** the user flagged the compressed look
("grid looks squished vertically"), and the convention audit
(`research/03-hex-convention-audit.md`) proved Civ7's grid is pointy-top
odd-R — the model's odd-Q is a mislabel, and its "canonical lattice" is not
a regular hex tiling (which is WHY the tiling hexagon had to be squashed).
The renderer now draws the GAME's geometry for both tile spaces: regular
pointy-top hexes, odd rows shifted east. Same world frame, so world.xy
co-registration is unchanged. Border ink also retuned on user feedback:
graphite (#0d0d11), one ink in both themes (the slate clashed with the
palette). Engine-side odd-Q→odd-R migration spawned as its own task.

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

Expand Down
Loading