feat(coords): Chunk-Namen als Planquadrat A1 bis L12

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Haiku 4.5 committed 2026-09-13 09:26:59 +02:00
1 parent 44108f94f4
commit 2f63779363
5 files changed
+120 -2

No files matched your search

+22
View File
@@ -25,3 +25,25 @@ export function chunkForWorld(X, Y) {
export function chunkOrigin(i, j) { export function chunkOrigin(i, j) {
return { X: SLICE.x0 + i * CHUNK_SIZE, Y: SLICE.y1 - j * CHUNK_SIZE }; return { X: SLICE.x0 + i * CHUNK_SIZE, Y: SLICE.y1 - j * CHUNK_SIZE };
} }
const CHUNK_COLUMNS = "ABCDEFGHIJKL";
const CHUNK_NAME_PATTERN = /^([A-La-l])([1-9]\d?)$/;
/**
* Planquadrat-Name eines Chunks: Spalte A–L aus i (West → Ost), Zeile 1–12 aus j (Nord → Süd).
* A1 ist die Nordwest-Ecke, weil chunkOrigin i nach Osten und j von der Nordkante nach Süden zählt.
* Die Namen sind die Schnittstelle zur Feinkalibrierung: was im Chat "C10" heißt, ist chunk_2_9.
*/
export function chunkName(i, j) {
const inside = (k) => Number.isInteger(k) && k >= 0 && k < CHUNKS_PER_SIDE;
return inside(i) && inside(j) ? CHUNK_COLUMNS[i] + (j + 1) : null;
}
/** Umkehrung von chunkName. Akzeptiert Kleinschreibung, liefert null für alles außerhalb A1–L12. */
export function parseChunkName(name) {
const match = CHUNK_NAME_PATTERN.exec(String(name));
if (!match) return null;
const row = Number(match[2]);
if (row > CHUNKS_PER_SIDE) return null;
return { i: CHUNK_COLUMNS.indexOf(match[1].toUpperCase()), j: row - 1 };
}
+40 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CHUNK_SIZE, chunkForWorld, threeToWorld, tileForWorld, worldToThree } from "../src/world/coords.js"; import fs from "node:fs";
import { CHUNK_SIZE, chunkForWorld, chunkName, parseChunkName, threeToWorld, tileForWorld, worldToThree } from "../src/world/coords.js";
const NAMES = JSON.parse(fs.readFileSync(new URL("./fixtures/chunk-names.json", import.meta.url), "utf8"));
describe("coords", () => { describe("coords", () => {
it("dreht Y zur three-z-Achse", () => { it("dreht Y zur three-z-Achse", () => {
@@ -22,3 +25,39 @@ describe("coords", () => {
expect(chunkForWorld(1999, -499)).toEqual({ i: 11, j: 11 }); expect(chunkForWorld(1999, -499)).toEqual({ i: 11, j: 11 });
}); });
}); });
describe("Chunk-Namen", () => {
it("benennt Ecken und Beispiel wie in der Spec, in beide Richtungen", () => {
for (const [name, i, j] of NAMES.pairs) {
expect(chunkName(i, j)).toBe(name);
expect(parseChunkName(name)).toEqual({ i, j });
}
});
it("akzeptiert Kleinschreibung", () => {
for (const [name, i, j] of NAMES.lowercase) expect(parseChunkName(name)).toEqual({ i, j });
});
it("weist alles außerhalb von A1 bis L12 ab", () => {
for (const name of NAMES.invalid) expect(parseChunkName(name)).toBeNull();
});
it("liefert null für Indizes außerhalb des Ausschnitts", () => {
expect(chunkName(12, 0)).toBeNull();
expect(chunkName(0, 12)).toBeNull();
expect(chunkName(-1, 3)).toBeNull();
expect(chunkName(1.5, 3)).toBeNull();
});
it("kommt für alle 144 Chunks hin und zurück", () => {
for (let i = 0; i < 12; i++) {
for (let j = 0; j < 12; j++) expect(parseChunkName(chunkName(i, j))).toEqual({ i, j });
}
});
it("verbindet den Namen mit der Lage: der Downtown-Kern liegt in B9", () => {
const { i, j } = chunkForWorld(-511, 254);
expect({ i, j }).toEqual({ i: 1, j: 8 });
expect(chunkName(i, j)).toBe("B9");
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"pairs": [["A1", 0, 0], ["L1", 11, 0], ["A12", 0, 11], ["L12", 11, 11], ["C10", 2, 9]],
"lowercase": [["c10", 2, 9], ["l12", 11, 11]],
"invalid": ["M1", "A0", "A13", "A", "10", "", "A01", "B-3", " "]
}
+22
View File
@@ -2,6 +2,8 @@
Spiegelt src/world/coords.js. Beide Seiten müssen dieselben Werte liefern. Spiegelt src/world/coords.js. Beide Seiten müssen dieselben Werte liefern.
""" """
import re
TILE_PX = 256 TILE_PX = 256
WORLD_HALF = 16384 WORLD_HALF = 16384
CHUNK_SIZE = 250 CHUNK_SIZE = 250
@@ -26,3 +28,23 @@ def mosaic_origin_px() -> tuple[int, int]:
def pixel_to_world(px: int, py: int) -> tuple[float, float]: def pixel_to_world(px: int, py: int) -> tuple[float, float]:
return (SLICE["x0"] + px, SLICE["y1"] - py) return (SLICE["x0"] + px, SLICE["y1"] - py)
CHUNKS_PER_SIDE = (SLICE["x1"] - SLICE["x0"]) // CHUNK_SIZE # 12
CHUNK_COLUMNS = "ABCDEFGHIJKL"
_CHUNK_NAME = re.compile(r"^([A-La-l])([1-9]\d?)$")
def chunk_name(i: int, j: int) -> str | None:
"""Planquadrat-Name wie src/world/coords.js: Spalte A–L aus i, Zeile 1–12 aus j. A1 = Nordwest."""
if not (0 <= i < CHUNKS_PER_SIDE and 0 <= j < CHUNKS_PER_SIDE):
return None
return f"{CHUNK_COLUMNS[i]}{j + 1}"
def parse_chunk_name(name: str) -> tuple[int, int] | None:
"""Umkehrung von chunk_name. Akzeptiert Kleinschreibung, None außerhalb A1–L12."""
match = _CHUNK_NAME.match(str(name))
if not match or int(match.group(2)) > CHUNKS_PER_SIDE:
return None
return CHUNK_COLUMNS.index(match.group(1).upper()), int(match.group(2)) - 1
+31 -1
View File
@@ -1,4 +1,10 @@
from leonida.coords import SLICE, pixel_to_world, slice_tiles, tile_for_world import json
from pathlib import Path
from leonida.coords import SLICE, chunk_name, parse_chunk_name, pixel_to_world, slice_tiles, tile_for_world
# Derselbe Beispielsatz wie tests/coords.test.js — JavaScript und Python dürfen nicht auseinanderlaufen.
NAMES = json.loads((Path(__file__).resolve().parents[3] / "tests/fixtures/chunk-names.json").read_text())
def test_tile_of_vice_beach(): def test_tile_of_vice_beach():
assert tile_for_world(1409.4, 2803.2) == (53, 69) assert tile_for_world(1409.4, 2803.2) == (53, 69)
@@ -13,3 +19,27 @@ def test_pixel_origin_is_slice_corner():
# Pixel (0,0) des beschnittenen Mosaiks ist die Nordwest-Ecke des Ausschnitts. # Pixel (0,0) des beschnittenen Mosaiks ist die Nordwest-Ecke des Ausschnitts.
assert pixel_to_world(0, 0) == (SLICE["x0"], SLICE["y1"]) assert pixel_to_world(0, 0) == (SLICE["x0"], SLICE["y1"])
assert pixel_to_world(2999, 2999) == (SLICE["x0"] + 2999, SLICE["y1"] - 2999) assert pixel_to_world(2999, 2999) == (SLICE["x0"] + 2999, SLICE["y1"] - 2999)
def test_chunk_names_match_the_shared_examples():
for name, i, j in NAMES["pairs"]:
assert chunk_name(i, j) == name
assert parse_chunk_name(name) == (i, j)
def test_chunk_names_accept_lowercase():
for name, i, j in NAMES["lowercase"]:
assert parse_chunk_name(name) == (i, j)
def test_chunk_names_reject_everything_outside_a1_to_l12():
for name in NAMES["invalid"]:
assert parse_chunk_name(name) is None
assert chunk_name(12, 0) is None
assert chunk_name(0, -1) is None
def test_chunk_names_round_trip_for_all_144_chunks():
for i in range(12):
for j in range(12):
assert parse_chunk_name(chunk_name(i, j)) == (i, j)