Map-Editor R1 (t_0d436687): Resize-Kern + Vergrößern — SvmapDocument.resize_terrain(), Anker Mitte/Rand flach, Dichte-Leiter 129→257→513.

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.
This commit is contained in:
Hermes (godot-vr) 2026-10-17 18:48:21 +00:00
parent b85e3bed52
commit 2d8b9822f0
2 changed files with 247 additions and 0 deletions

View file

@ -137,3 +137,122 @@ static func _height_sample(bytes: PackedByteArray, res: int, xi: int, zi: int,
var i := (zi * res + xi) * 2 var i := (zi * res + xi) * 2
var raw := float(bytes[i]) + float(bytes[i + 1]) * 256.0 var raw := float(bytes[i]) + float(bytes[i + 1]) * 256.0
return hmin + (raw / 65535.0) * (hmax - hmin) 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", ""),
}

View file

@ -52,6 +52,7 @@ func _run_tests() -> void:
_test_live_material_colors() _test_live_material_colors()
_test_relief_shading() _test_relief_shading()
_test_grid_toggle() _test_grid_toggle()
_test_resize_grow()
# _test_ray_terrain_mapping läuft in _finish() — braucht Viewport im Baum. # _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: for v in a:
s += v s += v
return s / float(a.size()) 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")