scopedvr-map-editor/tests/test_terrain.gd

378 lines
16 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()
_test_material_brush()
_test_paint_undo_redo()
_test_material_format()
## 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,
0.0, 0.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)")
## Material-Pinsel (Karte t_603ec1a8): Kreisfläche malt die Material-ID,
## außerhalb bleibt, Höhenfeld bleibt unangetastet, Gras zurückmalen räumt auf.
func _test_material_brush() -> void:
print("Testgruppe: Material-Pinsel (Malen)")
var res := 33
var size := 320.0
var mats := PackedByteArray()
mats.resize(res * res)
var heights := PackedFloat32Array()
heights.resize(res * res)
for i in heights.size():
heights[i] = 7.0 + float(i % 5)
var changed := TerrainField.apply_material_brush(mats, res, size, size,
0.0, 0.0, 40.0, 1)
_check(changed, "Malen: apply meldet Änderung")
_check(mats[16 * res + 16] == 1, "Malen: Zentrum = Sand (1)")
_check(mats[2 * res + 16] == 0, "Malen: 140 m weg — außerhalb des Radius unverändert")
# Höhenfeld bleibt vom Material-Pinsel komplett unberührt.
var heights_before := heights.duplicate()
TerrainField.apply_material_brush(mats, res, size, size, 0.0, 0.0, 40.0, 2)
var same := true
for i in heights.size():
if not is_equal_approx(heights[i], heights_before[i]):
same = false
break
_check(same, "Malen: Höhenfeld bleibt unangetastet (Malen statt Formen)")
# Radierer-Semantik: mit 0 (Gras) übermalt ist die Fläche wieder leer.
TerrainField.apply_material_brush(mats, res, size, size, 0.0, 0.0, 40.0, 0)
var grass := true
for m in mats:
if m != 0:
grass = false
break
_check(grass, "Malen: Gras zurückmalen räumt die Fläche leer")
# Ungültige Eingaben: zu kleines Grid wird abgelehnt; PAINT über
# apply_brush (Höhen-API) ist bewusst neutral.
_check(not TerrainField.apply_material_brush(mats, 1, size, size,
0.0, 0.0, 10.0, 1), "Malen: res < 2 wird abgelehnt")
_check(not TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
0.0, 0.0, 10.0, TerrainField.Mode.PAINT, 5.0),
"apply_brush mit PAINT ist neutral (keine Höhe, kein Crash)")
## Mal-Stroke = EIN Undo-Eintrag (Chef-Kriterium wie beim Formen): Sand-Streifen
## über mehrere Anwendungen, Strg+Z → komplett Gras, Strg+Shift+Z → byte-identisch.
func _test_paint_undo_redo() -> void:
print("Testgruppe: Mal-Stroke-Undo (ganzer Strich)")
var doc := SvmapDocument.create_new("Paint", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var terrain: Dictionary = doc["terrain"]
var mats := SvmapIO.decode_materials(terrain)
_check(mats.size() == 129 * 129, "Neue Map: Material-Schicht 129² (alles Gras)")
var before: Dictionary = hist.snapshot(["terrain"])
for step in 6: # Streifen von links nach rechts, wie ein Zieh-Stroke
TerrainField.apply_material_brush(mats, 129, 1024.0, 1024.0,
-300.0 + float(step) * 60.0, 0.0, 24.0, 1)
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
hist.commit("Boden gemalt (sand)", before, hist.snapshot(["terrain"]))
_check(hist.entries.size() == 1, "6 Mal-Schritte → EIN History-Eintrag")
var painted := SvmapIO.decode_materials(terrain)
_check(painted[64 * 129 + 64] == 1, "Stroke-Mitte = Sand")
_check(painted[10 * 129 + 10] == 0, "Außerhalb des Streifens bleibt Gras")
var painted_b64 := str(terrain["materials_r8_b64"])
var desc := hist.undo()
_check(desc == "Boden gemalt (sand)", "Undo-Desc nennt den Mal-Stroke")
_check(str(terrain["materials_r8_b64"]) != painted_b64, "Undo: Material-Schicht geändert")
var restored := SvmapIO.decode_materials(terrain)
var all_grass := true
for m in restored:
if m != 0:
all_grass = false
break
_check(all_grass, "Undo: komplett Gras (Zustand vor dem Strich)")
desc = hist.redo()
_check(desc == "Boden gemalt (sand)", "Redo-Desc stimmt")
_check(str(terrain["materials_r8_b64"]) == painted_b64,
"Redo: byte-identische Material-Schicht zurück")
## Format v2/V15: Material-Schicht speichert/lädt, falsche Länge + unbekannte
## ID werden abgelehnt, Legacy-v1 OHNE Schicht lädt mit Default Gras.
func _test_material_format() -> void:
print("Testgruppe: Material-Format (v2/V15 + Legacy)")
var doc := SvmapDocument.create_new("Format", 1024.0, "mittag")
_check(int(doc["format_version"]) == 2, "Neue Maps tragen format_version 2")
var path := "/tmp/scopedvr_test_terrain_material.svmap"
var save := SvmapIO.save_to_file(path, doc)
_check(save["ok"], "v2-Map (mit Schicht) speichert ohne Fehler")
var load := SvmapIO.load_from_file(path)
_check(load["ok"], "v2-Map lädt sauber zurück (inkl. Prüfsumme)")
var mats := SvmapIO.decode_materials(load["data"]["terrain"])
_check(mats.size() == 129 * 129, "v2: Material-Schicht komplett dekodiert")
# V15: falsche Länge → Ablehnung.
var bad: Dictionary = doc.duplicate(true)
(bad["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(
PackedByteArray([0, 1, 2]))
_check(_has_err(SvmapIO.validate_map(bad, {}), "V15"),
"V15: falsche Material-Länge abgelehnt")
# V15: unbekannte Material-ID → Ablehnung.
bad = doc.duplicate(true)
var mraw := PackedByteArray()
mraw.resize(129 * 129)
mraw[5000] = 3
(bad["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(mraw)
_check(_has_err(SvmapIO.validate_map(bad, {}), "V15"),
"V15: unbekannte Material-ID abgelehnt")
# Legacy-v1: OHNE Material-Schicht lädt und gilt als alles Gras.
var legacy: Dictionary = doc.duplicate(true)
legacy["format_version"] = 1
(legacy["terrain"] as Dictionary).erase("materials_r8_b64")
SvmapIO.sign_map(legacy)
var legacy_path := "/tmp/scopedvr_test_terrain_legacy_v1.svmap"
var lsave := SvmapIO.save_to_file(legacy_path, legacy)
_check(lsave["ok"], "Legacy-v1 (ohne Schicht) bleibt speicherbar")
var lload := SvmapIO.load_from_file(legacy_path)
_check(lload["ok"], "Legacy-v1 lädt sauber (V3 akzeptiert 1 und 2)")
var lmats := SvmapIO.decode_materials(lload["data"]["terrain"])
var l_grass := true
for m in lmats:
if m != 0:
l_grass = false
break
_check(l_grass and lmats.size() == 129 * 129,
"Legacy-v1: fehlende Schicht → Default Gras überall")
func _has_err(errors: PackedStringArray, needle: String) -> bool:
for e in errors:
if str(e).contains(needle):
return true
return false
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())