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())