From 2f63779363b6bf866338813da43a828ef23cb528 Mon Sep 17 00:00:00 2001 From: KNSO Date: Sun, 13 Sep 2026 09:26:59 +0200 Subject: [PATCH] feat(coords): Chunk-Namen als Planquadrat A1 bis L12 Co-Authored-By: Claude Haiku 4.5 --- src/world/coords.js | 22 ++++++++++++++++ tests/coords.test.js | 41 +++++++++++++++++++++++++++++- tests/fixtures/chunk-names.json | 5 ++++ tools/extract/leonida/coords.py | 22 ++++++++++++++++ tools/extract/tests/test_coords.py | 32 ++++++++++++++++++++++- 5 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/chunk-names.json diff --git a/src/world/coords.js b/src/world/coords.js index 893242f..ae1805c 100644 --- a/src/world/coords.js +++ b/src/world/coords.js @@ -25,3 +25,25 @@ export function chunkForWorld(X, Y) { export function chunkOrigin(i, j) { 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 }; +} diff --git a/tests/coords.test.js b/tests/coords.test.js index 23c264e..b24ab34 100644 --- a/tests/coords.test.js +++ b/tests/coords.test.js @@ -1,5 +1,8 @@ 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", () => { it("dreht Y zur three-z-Achse", () => { @@ -22,3 +25,39 @@ describe("coords", () => { 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"); + }); +}); diff --git a/tests/fixtures/chunk-names.json b/tests/fixtures/chunk-names.json new file mode 100644 index 0000000..648ce3a --- /dev/null +++ b/tests/fixtures/chunk-names.json @@ -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", " "] +} diff --git a/tools/extract/leonida/coords.py b/tools/extract/leonida/coords.py index 0bf55e3..051a62e 100644 --- a/tools/extract/leonida/coords.py +++ b/tools/extract/leonida/coords.py @@ -2,6 +2,8 @@ Spiegelt src/world/coords.js. Beide Seiten müssen dieselben Werte liefern. """ +import re + TILE_PX = 256 WORLD_HALF = 16384 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]: 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 diff --git a/tools/extract/tests/test_coords.py b/tools/extract/tests/test_coords.py index f206ab4..be690fe 100644 --- a/tools/extract/tests/test_coords.py +++ b/tools/extract/tests/test_coords.py @@ -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(): 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. assert pixel_to_world(0, 0) == (SLICE["x0"], SLICE["y1"]) 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)