scopedvr-map-editor/tests/test_terrain.gd

238 lines
9.6 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends SceneTree
## Editor-Tests (headless): Karte t_9aba04f1 — Terrain-Werkzeug (Höhenfeld
## heben/senken/glätten, Pinsel-Mathematik, Stroke-Undo, svmap-Roundtrip).
## Ausführen: godot --headless --path . --script res://tests/test_terrain.gd
## Exit 0 = alles grün. GUI-Seiten (Ring sichtbar, Mesh folgt live) werden im
## Desktop-Smoke mit Screenshots verifiziert (--terrain-demo), nicht hier.
var failures := 0
var checks := 0
func _init() -> void:
_run_tests()
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
func _run_tests() -> void:
_test_encode_roundtrip()
_test_brush_raise_lower()
_test_brush_smooth()
_test_stroke_undo_redo()
_test_range_expansion()
_test_svmap_roundtrip()
_test_resolution_limit()
## Decode → Encode bleibt innerhalb eines Quantisierungsschritts (R16).
func _test_encode_roundtrip() -> void:
print("Testgruppe: R16-Encode/Decode-Roundtrip")
var doc := SvmapDocument.create_new("Quant", 1024.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var heights := SvmapIO.decode_heights(terrain)
_check(heights.size() == 129 * 129, "Neue Map: 129×129 Höhenpunkte (Editor-MVP-Limit)")
for i in heights.size():
heights[i] = 13.0 + float(i % 17) * 1.5
var b64 := TerrainField.encode(heights, 0.0, 50.0)
var terrain2: Dictionary = terrain.duplicate(true)
terrain2["heightmap_r16_b64"] = b64
var back := SvmapIO.decode_heights(terrain2)
var worst := 0.0
for i in heights.size():
worst = maxf(worst, absf(back[i] - heights[i]))
_check(worst <= 50.0 / 65535.0 + 0.0001,
"Encode-Fehler ≤ ½ Quantisierungsschritt (worst %.6f m)" % worst)
## Heben/Senken: Kosinus-Falloff, außerhalb des Radius nichts verändert,
## Senken unter min_height geht dank Range-Expansion (getestet separat).
func _test_brush_raise_lower() -> void:
print("Testgruppe: Pinsel heben/senken")
var res := 33
var size := 320.0
var heights := PackedFloat32Array()
heights.resize(res * res)
TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
0.0, 0.0, 40.0, TerrainField.Mode.RAISE, 10.0)
var center := heights[16 * res + 16]
var mid := heights[16 * res + 18] # 20 m vom Zentrum (Zelle = 10 m)
var far := heights[2 * res + 16] # 140 m weg — außerhalb des Radius
_check(center > 9.5, "Heben: Zentrum um ~10 m angehoben (ist %.2f)" % center)
_check(mid > 0.0 and mid < center, "Heben: Falloff zwischen Zentrum und Rand")
_check(far == 0.0, "Heben: außerhalb des Radius unverändert")
var before := heights.duplicate()
TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
0.0, 0.0, 40.0, TerrainField.Mode.LOWER, 4.0)
_check(heights[16 * res + 16] < center, "Senken: Zentrum wieder tiefer")
var diff := false
for i in heights.size():
if not before.is_empty() and absf(heights[i] - before[i]) > 0.0001:
diff = true
break
_check(diff, "Senken: Feld hat sich tatsächlich geändert")
## Glätten: Variance sinkt IN DER PINSEL-FLÄCHE, Form bleibt (Mittel-Mischung).
func _test_brush_smooth() -> void:
print("Testgruppe: Pinsel glätten")
var res := 33
var size := 320.0
var heights := PackedFloat32Array()
heights.resize(res * res)
# Zackiger Sägezahn in der Pinsel-Mitte.
for i in heights.size():
var x := i % res
heights[i] = 10.0 + (5.0 if x % 2 == 0 else -5.0)
var variance_before := _variance_block(heights, res, 14, 18, 14, 18)
var moved := false
for pass_i in 2:
moved = TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
160.0, 160.0, 60.0, TerrainField.Mode.SMOOTH, 1.0) or moved
_check(moved, "Glätten: Feld wurde verändert")
var variance_after := _variance_block(heights, res, 14, 18, 14, 18)
_check(variance_after < variance_before * 0.4,
"Glätten: Varianz in der Pinsel-Fläche deutlich gesunken (%.1f → %.1f)" % [
variance_before, variance_after])
_check(absf(_mean(heights) - 10.0) < 1.0,
"Glätten: Gesamt-Mittelhöhe bleibt ~10 m (Form bleibt)")
## Der GANZE Stroke = EIN Undo-Eintrag (Chef-Kriterium): viele Einzel-Schritte
## auf derselben Stelle → Strg+Z stellt die Form VOR dem Strich wieder her.
func _test_stroke_undo_redo() -> void:
print("Testgruppe: Stroke-Undo (ganzer Strich)")
var doc := SvmapDocument.create_new("Stroke", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var terrain0: Dictionary = doc["terrain"]
var heights := SvmapIO.decode_heights(terrain0)
# Ein Stroke = 12 Anwendungs-Schritte + EIN commit am Ende (wie terrain_tool).
var before: Dictionary = hist.snapshot(["terrain"])
for step in 12:
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, 0.0, 50.0,
0.0, 0.0, 60.0, TerrainField.Mode.RAISE, 0.8)
terrain0["heightmap_r16_b64"] = TerrainField.encode(heights, 0.0, 50.0)
hist.commit("Terrain geformt (Heben)", before, hist.snapshot(["terrain"]))
_check(hist.entries.size() == 1, "12 Pinsel-Schritte → EIN History-Eintrag")
var after_b64 := str(terrain0["heightmap_r16_b64"])
var raised := SvmapIO.decode_heights(terrain0)
_check(raised[64 * 129 + 64] > 5.0, "Stroke: Mitte ist deutlich angehoben")
var desc := hist.undo()
_check(desc == "Terrain geformt (Heben)", "Undo-Desc nennt den Stroke")
_check(str(terrain0["heightmap_r16_b64"]) != after_b64, "Undo: Höhenfeld geändert")
var restored := SvmapIO.decode_heights(terrain0)
_check(absf(restored[64 * 129 + 64]) < 0.01, "Undo: Boden wieder FLACH (ganzer Strich)")
desc = hist.redo()
_check(desc == "Terrain geformt (Heben)", "Redo-Desc stimmt")
_check(str(terrain0["heightmap_r16_b64"]) == after_b64,
"Redo: byte-identisches Höhenfeld zurück (kein Quantisierungs-Drift)")
## Range-Expansion: Senken unter min_height und Heben über max_height
## weiten min/max_height_m automatisch auf (Format-gültig, Spannweite ≤ 500).
func _test_range_expansion() -> void:
print("Testgruppe: Range-Expansion (Senken auf flacher Map)")
var doc := SvmapDocument.create_new("Dig", 1024.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var heights := SvmapIO.decode_heights(terrain) # alles 0 = min_height
# Wie terrain_tool: ohne Clamp anwenden, Range danach expandieren.
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0,
0.0, 0.0, 50.0, TerrainField.Mode.LOWER, 6.0)
_check(heights[64 * 129 + 64] < -5.0, "Senken UNTER 0 m möglich (kein Clamp an min_height)")
var hmin := floorf(TerrainField._min_h(heights) - 1.0)
var hmax := maxf(float(terrain["max_height_m"]), ceilf(TerrainField._max_h(heights) + 1.0))
terrain["min_height_m"] = hmin
terrain["max_height_m"] = hmax
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, hmin, hmax)
var path := "/tmp/scopedvr_test_terrain_dig.svmap"
var save := SvmapIO.save_to_file(path, doc)
_check(save["ok"], "Expandierte Map bleibt V1–V14-gültig (Speichern 0 Fehler)")
var load := SvmapIO.load_from_file(path)
_check(load["ok"], "Expandierte Map lädt sauber zurück (inkl. Prüfsumme)")
var reloaded := SvmapIO.decode_heights(load["data"]["terrain"])
_check(absf(reloaded[64 * 129 + 64] - heights[64 * 129 + 64]) < 0.01,
"Range-Expansion: Tiefe überlebt Encode/Decode")
## svmap-Roundtrip: geformtes Terrain speichern → laden → Höhenfeld byte-identisch,
## Prüfsumme gültig, Loader nimmt die Map an.
func _test_svmap_roundtrip() -> void:
print("Testgruppe: svmap-Roundtrip (Speichern/Laden)")
var doc := SvmapDocument.create_new("Roundtrip", 1024.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var heights := SvmapIO.decode_heights(terrain)
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0,
-200.0, 100.0, 80.0, TerrainField.Mode.RAISE, 18.0)
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0,
300.0, -300.0, 60.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 path := "/tmp/scopedvr_test_terrain_roundtrip.svmap"
var save := SvmapIO.save_to_file(path, doc)
_check(save["ok"], "Speichern: 0 Fehler (Validierung V1–V14 vor dem Schreiben)")
var load := SvmapIO.load_from_file(path)
_check(load["ok"], "Laden: 0 Fehler (inkl. Prüfsumme)")
var t_in: Dictionary = load["data"]["terrain"]
_check(str(t_in["heightmap_r16_b64"]) == str(terrain["heightmap_r16_b64"]),
"Höhenfeld-Diff LEER (byte-identisch nach Speichern→Laden)")
_check(absf(float(t_in["min_height_m"]) - float(terrain["min_height_m"])) < 0.0001
and absf(float(t_in["max_height_m"]) - float(terrain["max_height_m"])) < 0.0001,
"Höhen-Range bleibt erhalten")
func _test_resolution_limit() -> void:
print("Testgruppe: Auflösungs-Limit (Editor-MVP)")
_check(TerrainField.EDITOR_MAX_RESOLUTION == 129, "EDITOR_MAX_RESOLUTION = 129")
_check(SvmapDocument.DEFAULT_RESOLUTION == 129, "Neue Maps: res 129 (Perf-Limit)")
_check(SvmapIO.MAX_TERRAIN_RESOLUTION == 513, "Format-Limit 513 unverändert (Interop)")
func _variance_block(a: PackedFloat32Array, res: int, x0: int, x1: int, z0: int, z1: int) -> float:
var s := 0.0
var count := 0
var z := z0
while z <= z1:
var x := x0
while x <= x1:
s += a[z * res + x]
count += 1
x += 1
z += 1
var block_mean := s / float(count)
var sq := 0.0
z = z0
while z <= z1:
var x := x0
while x <= x1:
var d := a[z * res + x] - block_mean
sq += d * d
x += 1
z += 1
return sq / float(count)
func _mean(a: PackedFloat32Array) -> float:
var s := 0.0
for v in a:
s += v
return s / float(a.size())