From 2d8b9822f0e7a595a35dc4d484c7d044afaa8a2a Mon Sep 17 00:00:00 2001 From: "Hermes (godot-vr)" Date: Sat, 17 Oct 2026 18:48:21 +0000 Subject: [PATCH] =?UTF-8?q?Map-Editor=20R1=20(t=5F0d436687):=20Resize-Kern?= =?UTF-8?q?=20+=20Vergr=C3=B6=C3=9Fern=20=E2=80=94=20SvmapDocument.resize?= =?UTF-8?q?=5Fterrain(),=20Anker=20Mitte/Rand=20flach,=20Dichte-Leiter=201?= =?UTF-8?q?29=E2=86=92257=E2=86=92513.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semantik aus PLAN.md „Map-Größe ändern" (17.10., Sly-geankert): - Anker Mitte: Karte bleibt am Ursprung zentriert, bestehendes Terrain behält seine WELTPOSITION (bilinear resampeln, kein Stretch). - Neue Randzone: flach auf min_height + Material 0 (Gras), wie create_new. - Dichte-Regel: würde m/Punkt über ~2 sinken, res hoch 129→257→513 (Format-Limit 513 hart — darüber Warnband, Anzeige in R3). - size clamp 256–4096 m (MIN_SIZE_M/MAX_SIZE_M), bounds auf neue Größe (Y-Grenzen unverändert). - R1-Schnitt: spawn_zones/objects/gebaeude unverändert übernommen (Filter + Out-of-Bounds-Dialog kommen in R2). Reine Funktion — Eingabe-doc bleibt unangetastet (Undo-Snapshot), alte Prüfsumme verworfen (save signiert neu). Tests (test_terrain.gd, 22 neue Checks → Suite 134/134, 11 Suiten ALL OK): Grow 256→512 — alte Gridpunkte ±0.01 m an Weltposition, Rand flach/Gras, Material resampelt, res-Bump 129→257 (bei 2048 m gedeckelt auf 513), spawn/object-Erhalt, svmap-v2-Roundtrip, Clamp. --- src/svmap/map_document.gd | 119 +++++++++++++++++++++++++++++++++++ tests/test_terrain.gd | 128 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/src/svmap/map_document.gd b/src/svmap/map_document.gd index 9102f91..20ca075 100644 --- a/src/svmap/map_document.gd +++ b/src/svmap/map_document.gd @@ -137,3 +137,122 @@ static func _height_sample(bytes: PackedByteArray, res: int, xi: int, zi: int, var i := (zi * res + xi) * 2 var raw := float(bytes[i]) + float(bytes[i + 1]) * 256.0 return hmin + (raw / 65535.0) * (hmax - hmin) + + +# ----------------------------------------------------- Kartengröße ändern ---- +# Karte t_0d436687 / PLAN.md „Map-Größe ändern" (17.10., Sly-geankert). +# Semantik „Anker Mitte, Rand flach, Resample": die Karte bleibt am Ursprung +# zentriert, bestehendes Terrain behält seine WELTPOSITION (bilinear +# resampeln — kein Stretch), die neue Randzone wird flach (min_height) + +# Material Gras. Reine Funktion (altes Dict → NEUES Dict), damit R3 sie +# atomar hinter ein Undo-Snapshot hängen kann. + +## Dichte-Ziel: nach einem Resize nicht grober als ~2 m/Punkt sampeln. +const RESIZE_DENSITY_TARGET_M := 2.0 +## Auflösungs-Leiter beim Hochdrehen (Format erlaubt 17–513, V6). +const RESIZE_RESOLUTION_LADDER: Array[int] = [129, 257, 513] + + +## Auflösung nach einem Resize: kleinstmögliche Leiter-Sprosse ≥ alter res, +## die die ~2 m/Punkt-Dichte auf der GRÖSSTEN neuen Kante hält; über 513 +## (Format-Maximum) nicht mehr hochdrehen → ab ~1026 m grobt es (Warnband, +## Anzeige kommt in R3). +static func _resize_resolution(old_res: int, new_sx: float, new_sz: float) -> int: + var res := old_res + for step in RESIZE_RESOLUTION_LADDER: + if step < res: + continue + var m_per_point := maxf(new_sx, new_sz) / float(step - 1) + if m_per_point <= RESIZE_DENSITY_TARGET_M: + return step + return SvmapIO.MAX_TERRAIN_RESOLUTION + + +## m/Punkt einer res/size-Kombination — R3-Dialog zeigt das Warnband ab +## ~1024 m (Dichte grobt, Quest-Perf: Objektzahl/Draw-Calls). +static func resize_m_per_point(res: int, size_m: float) -> float: + return size_m / float(maxi(res - 1, 1)) + + +## Terrain auf neue Kartenmaße resampeln (Vergrößern; Verkleinern folgt in R2). +## R1-Schnitt (PLAN.md Kette): Spawn-Zonen, objects und gebaeude werden +## unverändert ÜBERNOMMEN — auch wenn etwas außerhalb des neuen Geländes +## liegt (Filter + Out-of-Bounds-Dialog kommen in R2). Kein Mutationsspaß: +## der Eingabe-doc bleibt unangetastet, das Ergebnis ist ein frisches Dict +## ohne Prüfsumme (die alte wäre nach dem Resize ungültig — save_to_file +## signiert beim nächsten Speichern neu). +static func resize_terrain(doc: Dictionary, new_size_x_m: float, + new_size_z_m: float) -> Dictionary: + var old_terrain: Dictionary = doc.get("terrain", {}) + var old_res := int(old_terrain.get("resolution", DEFAULT_RESOLUTION)) + var old_sx := float(old_terrain.get("size_x_m", 0.0)) + var old_sz := float(old_terrain.get("size_z_m", 0.0)) + var hmin := float(old_terrain.get("min_height_m", DEFAULT_MIN_HEIGHT)) + var hmax := float(old_terrain.get("max_height_m", DEFAULT_MAX_HEIGHT)) + + var new_sx := clampf(new_size_x_m, MIN_SIZE_M, MAX_SIZE_M) + var new_sz := clampf(new_size_z_m, MIN_SIZE_M, MAX_SIZE_M) + var new_res := _resize_resolution(old_res, new_sx, new_sz) + + var heights_old := SvmapIO.decode_heights(old_terrain) + var mats_old := SvmapIO.decode_materials(old_terrain) + + var heights_new := PackedFloat32Array() + heights_new.resize(new_res * new_res) + var mats_new := PackedByteArray() + mats_new.resize(new_res * new_res) + + var last_new := float(new_res - 1) + var last_old := float(old_res - 1) + var half_x_old := old_sx * 0.5 + var half_z_old := old_sz * 0.5 + var eps := 0.0001 + for j in new_res: + var z := (float(j) / last_new - 0.5) * new_sz + for i in new_res: + var x := (float(i) / last_new - 0.5) * new_sx + if x < -half_x_old - eps or x > half_x_old + eps \ + or z < -half_z_old - eps or z > half_z_old + eps: + # Neue Randzone: flach auf min_height (wie create_new) + Gras. + heights_new[j * new_res + i] = hmin + continue + # Bestehendes Terrain: Weltposition halten, bilinear sampeln. + heights_new[j * new_res + i] = TerrainField.height_at( + heights_old, old_res, old_sx, old_sz, x, z) + # Material-Schicht auflösungstreu (nächster alter Gridpunkt). + var fx := clampf(roundf((x / old_sx + 0.5) * last_old), 0.0, last_old) + var fz := clampf(roundf((z / old_sz + 0.5) * last_old), 0.0, last_old) + mats_new[j * new_res + i] = mats_old[int(fz) * old_res + int(fx)] + + var terrain := { + "type": "heightmap", + "size_x_m": new_sx, + "size_z_m": new_sz, + "min_height_m": hmin, + "max_height_m": hmax, + "resolution": new_res, + "heightmap_r16_b64": TerrainField.encode(heights_new, hmin, hmax), + "materials_r8_b64": SvmapIO.encode_materials(mats_new), + } + + # bounds wie create_new auf die neue Größe — Y-Grenzen vom alten doc + # behalten (Resize ändert nur die Grundfläche, nicht die Luft). + var half_x := new_sx / 2.0 + var half_z := new_sz / 2.0 + var old_bounds: Dictionary = doc.get("bounds", {}) + var old_min: Array = old_bounds.get("min", [-half_x, -10.0, -half_z]) + var old_max: Array = old_bounds.get("max", [half_x, 80.0, half_z]) + return { + "format_version": SvmapIO.FORMAT_VERSION, + "info": (doc.get("info", {}) as Dictionary).duplicate(), + "terrain": terrain, + # R1: Objekte/Bauteile/Spawns unverändert übernehmen (Filter in R2). + "objects": (doc.get("objects", []) as Array).duplicate(), + "gebaeude": (doc.get("gebaeude", []) as Array).duplicate(), + "spawn_zones": (doc.get("spawn_zones", {}) as Dictionary).duplicate(true), + "bounds": { + "min": [-half_x, float(old_min[1]), -half_z], + "max": [half_x, float(old_max[1]), half_z], + }, + "weather": doc.get("weather", ""), + } diff --git a/tests/test_terrain.gd b/tests/test_terrain.gd index c55d6c3..dc17df0 100644 --- a/tests/test_terrain.gd +++ b/tests/test_terrain.gd @@ -52,6 +52,7 @@ func _run_tests() -> void: _test_live_material_colors() _test_relief_shading() _test_grid_toggle() + _test_resize_grow() # _test_ray_terrain_mapping läuft in _finish() — braucht Viewport im Baum. @@ -901,3 +902,130 @@ func _mean(a: PackedFloat32Array) -> float: for v in a: s += v return s / float(a.size()) + + +## Resize-Kern (Karte t_0d436687, Plan „Map-Größe ändern" 17.10.): +## Vergrößern mit „Anker Mitte, Rand flach" — altes Terrain behält seine +## Weltposition (Resample, kein Stretch), neue Randzone = min_height + Gras, +## Dichte-Leiter 129→257→513, Spawn-Zonen/Objekte unverändert übernommen, +## svmap-v2-Roundtrip nach dem Resize. +func _test_resize_grow() -> void: + print("Testgruppe: Resize Vergrößern (Anker Mitte, Rand flach)") + var doc := SvmapDocument.create_new("Resize", 256.0, "mittag") + var terrain: Dictionary = doc["terrain"] + var heights := SvmapIO.decode_heights(terrain) + # Geformtes Terrain: Hügel + Senke (wie im Roundtrip-Test), plus Sand-Fleck. + TerrainField.apply_brush(heights, 129, 256.0, 256.0, -100000.0, 100000.0, + -64.0, -32.0, 40.0, TerrainField.Mode.RAISE, 18.0) + TerrainField.apply_brush(heights, 129, 256.0, 256.0, -100000.0, 100000.0, + 64.0, 48.0, 30.0, TerrainField.Mode.LOWER, 9.0) + terrain["min_height_m"] = floorf(TerrainField._min_h(heights) - 1.0) + terrain["max_height_m"] = ceilf(TerrainField._max_h(heights) + 1.0) + terrain["heightmap_r16_b64"] = TerrainField.encode(heights, + float(terrain["min_height_m"]), float(terrain["max_height_m"])) + var mats := SvmapIO.decode_materials(terrain) + TerrainField.apply_material_brush(mats, 129, 256.0, 256.0, 0.0, 0.0, 40.0, 1) + terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats) + doc["objects"] = [{"id": "baum_laub_1", "pos": [80.0, 0.0, 40.0], "scale": 1.0}] + + var old_heights_b64 := str(terrain["heightmap_r16_b64"]) + var spawn_before := JSON.stringify(doc["spawn_zones"]) + var grown := SvmapDocument.resize_terrain(doc, 512.0, 512.0) + + # Reine Funktion: Eingabe-doc unangetastet (Undo-Snapshot bleibt gültig). + _check(str(doc["terrain"]["heightmap_r16_b64"]) == old_heights_b64 + and int(doc["terrain"]["resolution"]) == 129 + and absf(float(doc["terrain"]["size_x_m"]) - 256.0) < 0.0001, + "Reine Funktion: Eingabe-doc bleibt unangetastet") + + var t_new: Dictionary = grown["terrain"] + _check(absf(float(t_new["size_x_m"]) - 512.0) < 0.0001 + and absf(float(t_new["size_z_m"]) - 512.0) < 0.0001, "Größe 256→512 m") + _check(int(t_new["resolution"]) == 257, + "Dichte-Leiter: 4 m/Punkt → res 129→257 hochgedreht") + _check(SvmapDocument.resize_m_per_point(int(t_new["resolution"]), 512.0) <= 2.0, + "Nach dem Grow wieder ≤ 2 m/Punkt") + + # Anker Mitte: Höhen an ALTEN Grid-Punkten (Weltposition) identisch — + # res 257 über 512 m hat dieselbe Punktlage wie res 129 über 256 m. + var grown_heights := SvmapIO.decode_heights(t_new) + var grown_mats := SvmapIO.decode_materials(t_new) + _check(grown_heights.size() == 257 * 257 + and str(t_new["heightmap_r16_b64"]).length() > 0, + "Heights-Bytes an neue res angepasst (257²)") + _check(grown_mats.size() == 257 * 257, "Material-Bytes an neue res angepasst (257²)") + var same_world := true + var max_err := 0.0 + for i in 129 * 129: + var xi := i % 129 + var zi := i / 129 + var x := (float(xi) / 128.0 - 0.5) * 256.0 + var z := (float(zi) / 128.0 - 0.5) * 256.0 + var j := int(roundf((z / 512.0 + 0.5) * 256.0)) * 257 \ + + int(roundf((x / 512.0 + 0.5) * 256.0)) + var err := absf(grown_heights[j] - heights[i]) + if err > max_err: + max_err = err + if err > 0.01: + same_world = false + _check(same_world, "Anker Mitte: alle alten Gridpunkte ±0.01 m (max %.4f m)" % max_err) + _check(absf(SvmapDocument.terrain_height_at(grown, -64.0, -32.0) + - SvmapDocument.terrain_height_at(doc, -64.0, -32.0)) < 0.05, + "Hügel-Kuppe an Weltposition erhalten (kein Stretch)") + + # Neue Randzone: flach auf min_height + Material Gras. + var hmin := float(t_new["min_height_m"]) + var border_flat := true + var border_grass := true + for j in 257: + for i in [0, 256]: + var a: int = j * 257 + i + var b: int = i * 257 + j + if absf(grown_heights[a] - hmin) > 0.001 or absf(grown_heights[b] - hmin) > 0.001: + border_flat = false + if int(grown_mats[a]) != 0 or int(grown_mats[b]) != 0: + border_grass = false + _check(border_flat, "Randzone: flach auf min_height (h=%s)" % str(hmin)) + _check(border_grass, "Randzone: Material 0 (Gras)") + + # Material-Inhalt resampelt: gemalter Sand-Fleck bleibt Sand. + _check(int(grown_mats[128 * 257 + 128]) == 1, "Sand-Fleck bleibt Sand (Mitte)") + var old_center_mats := SvmapIO.decode_materials(doc["terrain"]) + _check(int(old_center_mats[64 * 129 + 64]) == 1, "Gegenprobe: Quelle wirklich Sand") + + # Spawns/Objekte unverändert übernommen (Filter kommt in R2). + _check(JSON.stringify(grown["spawn_zones"]) == spawn_before, + "Spawn-Zonen unverändert übernommen") + _check((grown["objects"] as Array).size() == 1 + and str((grown["objects"] as Array)[0]["id"]) == "baum_laub_1", + "Objekte unverändert übernommen") + _check((grown["gebaeude"] as Array).is_empty() and not grown.has("checksum"), + "Bauteile übernommen, alte Prüfsumme verworfen") + _check(absf(float(grown["bounds"]["max"][0]) - 256.0) < 0.0001 + and absf(float(grown["bounds"]["min"][2]) + 256.0) < 0.0001 + and absf(float(grown["bounds"]["max"][1]) - 80.0) < 0.0001, + "bounds auf neue Größe (Y-Grenzen bleiben)") + + # svmap-v2-Roundtrip: speichern → laden → Prüfsumme + byte-identisches Feld. + var path := "/tmp/scopedvr_test_terrain_resize.svmap" + var save := SvmapIO.save_to_file(path, grown) + _check(save["ok"], "Resize-Ergebnis speichert valide (V1–V14)") + var load := SvmapIO.load_from_file(path) + _check(load["ok"], "Resize-Ergebnis lädt mit gültiger Prüfsumme") + if load["ok"]: + _check(str(load["data"]["terrain"]["heightmap_r16_b64"]) + == str(t_new["heightmap_r16_b64"]), + "Roundtrip: Höhenfeld byte-identisch nach Speichern→Laden") + + # Dichte-Deckel: über ~1026 m NICHT über 513 hochdrehen (Warnband, R3). + var huge := SvmapDocument.resize_terrain(doc, 2048.0, 2048.0) + _check(int(huge["terrain"]["resolution"]) == SvmapIO.MAX_TERRAIN_RESOLUTION, + "Grow über ~1026 m: res bleibt 513 (Format-Max), kein Bump darüber") + _check(SvmapDocument.resize_m_per_point(513, 2048.0) > 2.0, + "Warnband: 2048 m bei res 513 grober als 2 m/Punkt") + + # Clamp auf MIN/MAX (256/4096). + var clamped := SvmapDocument.resize_terrain(doc, 10.0, 100000.0) + _check(absf(float(clamped["terrain"]["size_x_m"]) - SvmapDocument.MIN_SIZE_M) < 0.0001 + and absf(float(clamped["terrain"]["size_z_m"]) - SvmapDocument.MAX_SIZE_M) < 0.0001, + "Clamp: 10 m→256 m, 100000 m→4096 m")