scopedvr-map-editor/tests/test_terrain.gd
2026-11-01 19:34:05 +01:00

1229 lines
56 KiB
GDScript
Raw Permalink 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()
_finish()
## Ray→Terrain-Gruppe braucht einen Viewport IM Baum: erst zwei Frames warten
## (wie test_gui_smoke), dann Kamera-Mathe fahren und Danach beenden.
func _finish() -> void:
await process_frame
await process_frame
_test_ray_terrain_mapping()
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()
_test_mesh_winding_live_grid()
_test_live_material_colors()
_test_relief_shading()
_test_grid_toggle()
_test_resize_grow()
_test_resize_shrink()
# _test_ray_terrain_mapping läuft in _finish() — braucht Viewport im Baum.
## 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)
# Feines Material-Raster (Karte 24.10.): neue 1024-m-Maps = 1025² (1 m).
var mres := SvmapIO.material_resolution(terrain)
_check(mres == 1025 and mats.size() == mres * mres, "Neue Map: Material-Schicht 1025² (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, mres, 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[512 * mres + 512] == 1, "Stroke-Mitte = Sand")
_check(painted[80 * mres + 80] == 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"])
var mres := SvmapIO.material_resolution(load["data"]["terrain"])
_check(mats.size() == mres * mres and mres == 1025, "v2: Material-Schicht komplett dekodiert (1025²)")
# 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.
# Karte t_ab408127: Palette 3→20 — ID 3 ist jetzt GÜLTIG, der Test nutzt
# 20 (erster Wert oberhalb der neuen Obergrenze).
bad = doc.duplicate(true)
var mraw := PackedByteArray()
mraw.resize(mres * mres)
mraw[5000] = 20
(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 (ID 20 > Palette 0–19)")
# Neu: GÜLTIGE neue ID (3 = gras_satt) muss DURCHLASSEN werden.
var good_new := doc.duplicate(true)
var gmraw := PackedByteArray()
gmraw.resize(mres * mres)
gmraw[5000] = 3
(good_new["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(gmraw)
_check(not _has_err(SvmapIO.validate_map(good_new, {}), "V15"),
"V15: neue Palette-ID 3 (gras_satt) wird akzeptiert")
# 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")
(legacy["terrain"] as Dictionary).erase("material_resolution") # Alt-Karten kennen das Feld nicht
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
## Stubs für die terrain_tool-Integration (gleiche Konvention wie
## test_shortcuts.gd ProbeMain/ProbeBuilder): der Builder zeichnet AUF,
## was der Live-Stroke an update_terrain übergibt.
class ToolBuilder extends Node3D:
var last_heights := PackedFloat32Array()
var last_mats := PackedByteArray()
var calls := 0
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray()) -> void:
last_heights = heights
last_mats = mats
calls += 1
class ToolMain:
var doc: Dictionary = {}
var history: ActionHistory = ActionHistory.new()
var commits := 0
func _set_status(_text: String, _err := false) -> void:
pass
func _on_terrain_stroke_committed() -> void:
commits += 1
## Karte t_55ed041a: Material-Farben bleiben DURCHGEHEND sandfarben, wenn
## über eine gemalte Sand-Zone gehoben wird. Sly-Befund: während des
## Heben/Senk-Strichs „verschwanden" Sand/Stein live (alles Gras), erst am
## Stroke-Ende kehrten sie zurück — der Live-Rebuild rief update_heights
## OHNE Material-Schicht auf. Geprüft wird PER FRAME (nicht nur Endzustand):
## a) terrain_mesh-Cache: Live-Update ohne mats-Param behält Sand-Farben.
## b) terrain_tool: Heben-Stroke übergibt die Material-Schicht mit.
## c) Legacy (nie gesehene Schicht) bleibt alles Gras — kein Phantom-Cache.
func _test_live_material_colors() -> void:
print("Testgruppe: Live-Material-Farben während Heben/Senk (t_55ed041a)")
var res := 33
var size := 320.0
var center := 16 * res + 16
var heights := PackedFloat32Array()
heights.resize(res * res)
var mats := PackedByteArray()
mats.resize(res * res)
TerrainField.apply_material_brush(mats, res, size, size, 0.0, 0.0, 40.0, 1)
_check(mats[center] == 1, "Vorbereitung: Zentrum = Sand gemalt")
# --- a) Live-Stroke am Mesh: update_heights OHNE mats (exakter Bug-Pfad) ---
var view := Node3D.new()
view.set_script(load("res://core/viewport/terrain_mesh.gd"))
view.call("update_heights", heights, res, size, size, mats) # Vollrebuild mit Schicht
var sand_seen := false
for frame in 5:
TerrainField.apply_brush(heights, res, size, size, -100000.0, 100000.0,
0.0, 0.0, 40.0, TerrainField.Mode.RAISE, 4.0)
# Der alte Live-Aufruf: OHNE Material-Schicht (so rief terrain_tool
# vorher bei Heben/Senken auf) — der Cache muss Sand liefern.
view.call("update_heights", heights)
var col: PackedColorArray = (view.get("_mi").mesh.surface_get_arrays(0))[Mesh.ARRAY_COLOR]
var hmin := INF
var hmax := -INF
for v in heights:
hmin = minf(hmin, v)
hmax = maxf(hmax, v)
# Karte t_3ebe4086: Shading = Material × Höhen-Kurve × Pseudo-Licht —
# die Erwartung kommt aus derselben relief_color()-Formel wie das Mesh.
var nrm: PackedVector3Array = (view.get("_mi").mesh.surface_get_arrays(0))[Mesh.ARRAY_NORMAL]
var expected := _relief_color(1, heights[center], hmin, hmax, nrm[center]) # Sand
var c := col[center]
_check(absf(c.r - expected.r) < 0.005 and absf(c.g - expected.g) < 0.005
and absf(c.b - expected.b) < 0.005,
"Live-Frame %d: Zentrum sandfarben (ist %s, soll ~%s)" % [
frame + 1, c, expected])
sand_seen = true
_check(sand_seen, "Live-Stroke: 5 Frames geprüft (per-Frame, nicht nur Endzustand)")
view.free()
# --- b) terrain_tool-Integration: Heben-Stroke gibt die Schicht mit ---
var doc := SvmapDocument.create_new("LiveMats", 320.0, "mittag")
var terrain: Dictionary = doc["terrain"]
terrain["resolution"] = res
terrain["size_x_m"] = size
terrain["size_z_m"] = size
terrain["min_height_m"] = 0.0
terrain["max_height_m"] = 10.0
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, 0.0, 10.0)
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
terrain.erase("material_resolution") # Test-Raster: Material = Höhenraster (Alt-Karte)
var tmain := ToolMain.new()
tmain.doc = doc
var builder := ToolBuilder.new()
var ttool := Node3D.new()
ttool.set_script(load("res://core/editor/terrain_tool.gd"))
# Bewusst NICHT in den Baum: sonst feuert Godots automatisches _process
# zwischen den manuellen Calls und der Live-Update-Zähler wäre fragil.
ttool.setup(tmain, Camera3D.new(), builder, null)
_check(bool(ttool.call("activate")), "terrain_tool: aktiviert (Test-Map)")
var tmats: PackedByteArray = ttool.get("_mats")
_check(tmats.size() == res * res and tmats[center] == 1,
"terrain_tool: _refresh_from_doc dekodiert die Material-Schicht (Sand)")
ttool.set("_cursor_valid", true)
ttool.set("_cursor", Vector3(0.0, 0.0, 0.0))
ttool.call("_begin_stroke", TerrainField.Mode.RAISE)
ttool.call("_process", 0.016)
ttool.call("_process", 0.016)
_check(int(builder.get("calls")) == 2, "terrain_tool: Live-Updates sind angekommen")
var bmat: PackedByteArray = builder.get("last_mats")
_check(bmat.size() == res * res and bmat[center] == 1,
"terrain_tool: Heben-Stroke übergibt Material-Schicht (Zentrum = Sand)")
ttool.call("_end_stroke")
_check(int(tmain.get("commits")) == 1, "terrain_tool: Stroke-Ende = EIN Commit (unangetastet)")
ttool.free()
builder.free()
# --- c) Legacy: nie gesehene Schicht → alles Gras (kein Phantom-Cache) ---
var view2 := Node3D.new()
view2.set_script(load("res://core/viewport/terrain_mesh.gd"))
var flat := PackedFloat32Array()
flat.resize(res * res)
view2.call("update_heights", flat, res, size, size) # OHNE Schicht, Cache leer
var col2: PackedColorArray = (view2.get("_mi").mesh.surface_get_arrays(0))[Mesh.ARRAY_COLOR]
# Karte t_3ebe4086: flach = t 0 (S-Kurve → SHADING_LOW) × Normale UP →
# Erwartung aus derselben relief_color()-Formel wie das Mesh.
var grass_expected := _relief_color(0, 0.0, 0.0, 0.0, Vector3.UP)
var c2 := col2[center]
_check(absf(c2.r - grass_expected.r) < 0.005 and absf(c2.g - grass_expected.g) < 0.005
and absf(c2.b - grass_expected.b) < 0.005,
"Legacy (nie gesehene Schicht): Gras-Farbe bleibt (kein Phantom-Cache)")
view2.free()
## Karte t_7ac70014: Winding-Order + Normale + Live-Pinsel am ECHTEN Mesh.
## Gerätesymptom war: dunkle Platte + Gitter (Terrain von oben komplett
## ge-cullt, weil die Dreiecke von oben Counter-Clockwise liefen = Backface),
## plus „Pinsel wirkt erst nach langem Drücken“ (Folge: gehobene Flanken
## ragten erst sichtbar über die unsichtbare Deckfläche).
func _test_mesh_winding_live_grid() -> void:
print("Testgruppe: Terrain-Mesh (Winding/Normale/Erster Pinselkontakt)")
var view := Node3D.new()
view.set_script(load("res://core/viewport/terrain_mesh.gd"))
var res := 33
var size := 320.0
var heights := PackedFloat32Array()
heights.resize(res * res)
# Flache Standard-Platte (alles h=0) — außerhalb des Baums erzeugt
# update_heights die MeshInstance lazily (Absicherung Aufruf-vor-Ready).
view.call("update_heights", heights, res, size, size)
var mi = view.get("_mi")
_check(mi != null and mi.mesh != null,
"Flache Platte (h=0): Mesh gebaut, Terrain-Rasen sofort da")
var arrays: Array = mi.mesh.surface_get_arrays(0)
var pos: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
var nrm: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL]
# Godot: Clockwise von der Vorderseite = Frontseite. Deckfläche von oben
# betrachtet → Kreuzprodukt-Normale MUSS nach UNTEN zeigen (sonst cullt
# der Renderer die gesamte Platte als Backface = „dunkle Fläche“).
var all_front := true
var tri := 0
while tri < idx.size():
var a := pos[idx[tri]]
var b := pos[idx[tri + 1]]
var c := pos[idx[tri + 2]]
if (b - a).cross(c - a).y > 0.0:
all_front = false
break
tri += 3
_check(all_front, "Winding: ALLE Dreiecke von oben Frontseiten (CW von oben)")
var flat_normals := true
for n in nrm:
if absf(n.y - 1.0) > 0.001:
flat_normals = false
break
_check(flat_normals, "Normale bei h=0: (0,1,0) überall (korrekt beleuchtet)")
# Erster Pinselkontakt: EIN RAISE-Schritt → Mesh-Vertex SOFORT sichtbar
# verschoben (Live-Pfad ohne Rebuild — „Pinsel wirkt ab Frame 1“).
TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
0.0, 0.0, 40.0, TerrainField.Mode.RAISE, 8.0)
view.call("update_heights", heights)
var arrays2: Array = mi.mesh.surface_get_arrays(0)
var pos2: PackedVector3Array = arrays2[Mesh.ARRAY_VERTEX]
var center := 16 * res + 16
_check(pos2[center].y > 5.0 and absf(pos2[center].y - heights[center]) < 0.001,
"Erster Pinsel-Druck: Vertex-Zentrum sofort auf %.2f m (Live-Pfad)" % pos2[center].y)
_check((pos2[center] - pos[center]).length() > 5.0,
"Erster Pinsel-Druck: Vertex-Position MESSBAR verschoben")
# Hügel-Normale lehnt korrekt: Index→Welt-Konvention (t_234490cf) —
# x-Index läuft MIT +WeltX → Höhe steigt Richtung +WeltX → Oberfläche
# fällt Richtung −WeltX → Normale lehnt −X.
var slope := PackedFloat32Array()
slope.resize(res * res)
for i in slope.size():
slope[i] = 0.5 * float(i % res)
view.call("update_heights", slope, res, size, size)
var nrm2: PackedVector3Array = (mi.mesh.surface_get_arrays(0))[Mesh.ARRAY_NORMAL]
var n_mid: Vector3 = nrm2[16 * res + 16]
_check(n_mid.x < -0.01 and n_mid.y > 0.99,
"X-Hügel: Normale lehnt korrekt Richtung −WeltX (nicht invertiert, n=%s)" % n_mid)
for i in slope.size():
slope[i] = 0.5 * float(i / res)
view.call("update_heights", slope, res, size, size)
var nrm3: PackedVector3Array = (mi.mesh.surface_get_arrays(0))[Mesh.ARRAY_NORMAL]
var n_mid3: Vector3 = nrm3[16 * res + 16]
_check(n_mid3.z < -0.01 and n_mid3.y > 0.99,
"Z-Hügel: Normale lehnt korrekt Richtung −WeltZ (nicht invertiert, n=%s)" % n_mid3)
view.free()
## Karte t_7ac70014: Gitter default AUS über sichtbarem Terrain, ohne Rebuild
## zu-/abschaltbar; ohne Terrain-Block bleibt es als Boden-Referenz sichtbar.
func _test_grid_toggle() -> void:
print("Testgruppe: Gitter-Toggle (map_view_builder)")
var builder := Node3D.new()
builder.set_script(load("res://core/viewport/map_view_builder.gd"))
var doc := SvmapDocument.create_new("Gitter", 1024.0, "mittag")
builder.call("rebuild", doc, {})
var grid = builder.get("grid_node")
_check(grid != null and not grid.visible,
"Standard-Map (Terrain sichtbar): Gitter default AUS")
builder.call("set_grid_visible", true)
_check(builder.call("grid_is_visible") == true and grid.visible,
"Toggle AN: Gitter sichtbar (ohne Rebuild)")
builder.call("set_grid_visible", false)
_check(grid.visible == false, "Toggle AUS: Gitter wieder weg")
# Kein Terrain-Block → Gitter als Boden-Referenz sichtbar.
var bare := {"weather": "mittag"}
builder.call("rebuild", bare, {})
var grid2 = builder.get("grid_node")
_check(grid2 != null and grid2.visible,
"Kein Terrain-Block: Gitter bleibt Boden-Referenz (sichtbar)")
builder.free()
## Karte t_234490cf: Pinsel-Position == Cursor-Position. Simulierte Kamera +
## exakter Screen-Punkt (unproject des Zielpunkts) → project_ray_origin/normal
## → TerrainField.raycast → Brush-Stroke. asserted: Treffer/Stroke-Zentrum ==
## exakt erwartete Terrain-Zelle (Toleranz < 1 Zelle). Regressionen: Rand-/
## Corner-Zellen, schiefe Perspektive, erhabenes Terrain unter dem Cursor
## (Strahl muss die NEUE Oberfläche treffen).
## Gleicher Komplex wie der Renderer: zusätzlich asserted, dass ALLE
## Mesh-Vertices der Dokument-Konvention folgen (Index 0 = −X/−Z-Ecke) —
## das war die Root-Cause (Renderer spiegelte beide Achsen, Raycast-Mathe
## nicht → Pinsel wirkte punktgespiegelt um die Map-Mitte).
func _test_ray_terrain_mapping() -> void:
print("Testgruppe: Ray→Terrain-Mapping (Pinsel-Position == Cursor-Position)")
var res := 33
var size := 160.0
var cell := size / float(res - 1)
var heights := PackedFloat32Array()
heights.resize(res * res)
# --- Mesh-Vertices == Dokument-Konvention (die eigentliche Root-Cause) ---
var view := Node3D.new()
view.set_script(load("res://core/viewport/terrain_mesh.gd"))
view.call("update_heights", heights, res, size, size)
var mi = view.get("_mi")
var pos: PackedVector3Array = (mi.mesh.surface_get_arrays(0))[Mesh.ARRAY_VERTEX]
var all_match := true
var half := size * 0.5
for z in res:
for x in res:
var want := Vector3(-half + float(x) * cell, 0.0, -half + float(z) * cell)
if pos[z * res + x].distance_to(want) > 0.001:
all_match = false
break
_check(all_match, "Mesh-Vertices == Doc-Konvention (Index 0 = −X/−Z-Ecke, kein Spiegel)")
# Eck-Vertices explizit: (0,0)→−X/−Z-Ecke, (res−1,0)→+X, (0,res−1)→+Z.
_check(pos[0].distance_to(Vector3(-half, 0.0, -half)) < 0.001,
"Vertex (0,0) liegt an der −X/−Z-Ecke")
_check(pos[res - 1].distance_to(Vector3(half, 0.0, -half)) < 0.001,
"Vertex (res−1,0) liegt an der +X/−Z-Ecke")
_check(pos[(res - 1) * res].distance_to(Vector3(-half, 0.0, half)) < 0.001,
"Vertex (0,res−1) liegt an der −X/+Z-Ecke")
view.free()
# --- Simulierte Kamera: schief von oben (Süd-West, ~40° Elevation) ---
var vp := SubViewport.new()
vp.size = Vector2i(1280, 720)
root.add_child(vp)
var cam := Camera3D.new()
vp.add_child(cam)
cam.current = true
cam.look_at_from_position(Vector3(-95.0, 115.0, 135.0), Vector3.ZERO, Vector3.UP)
# Cursor-Punkt → Ray → Raycast-Treffer muss auf der Zelle landen.
var targets := [
Vector2(0.0, 0.0), # Map-Mitte
Vector2(40.0, 40.0), # Innenraum
Vector2(size * 0.45, -size * 0.45), # nahe +X/−Z-CORNER
Vector2(-size * 0.45, size * 0.45), # nahe −X/+Z-CORNER
Vector2(-size * 0.48, 10.0), # nahe −X-KANTE
Vector2(12.0, size * 0.48), # nahe +Z-KANTE
]
for tp in targets:
var surface := Vector3(tp.x, 0.0, tp.y)
var screen := cam.unproject_position(surface)
var origin := cam.project_ray_origin(screen)
var dir := cam.project_ray_normal(screen)
var hit: Variant = TerrainField.raycast(heights, res, size, size, origin, dir)
_check(hit != null, "Raycast trifft bei Ziel %s" % tp)
if hit == null:
continue
var p := hit as Vector3
var dx := absf(p.x - tp.x)
var dz := absf(p.z - tp.y)
_check(dx < cell and dz < cell,
"Treffer-Zelle == Cursor-Zelle bei %s (Abweichung %.2f/%.2f m, Zelle %.1f m)" % [
tp, dx, dz, cell])
# Brush-Stroke am Trefferpunkt: Zentrum der Höhen-Änderung == Cursor-Zelle.
var stroke_point: Vector2 = targets[2] # Corner-Nähe, die empfindlichste Stelle
var screen2 := cam.unproject_position(Vector3(stroke_point.x, 0.0, stroke_point.y))
var hit2: Variant = TerrainField.raycast(heights, res, size, size,
cam.project_ray_origin(screen2), cam.project_ray_normal(screen2))
if hit2 != null:
var before := heights.duplicate()
TerrainField.apply_brush(heights, res, size, size, -100000.0, 100000.0,
(hit2 as Vector3).x, (hit2 as Vector3).z, 12.0,
TerrainField.Mode.RAISE, 8.0)
var best := 0
var best_d := -1.0
for i in heights.size():
var d := heights[i] - before[i]
if d > best_d:
best_d = d
best = i
var bi := best % res
var bz := best / res
var want_gx := int(round(((hit2 as Vector3).x / size + 0.5) * float(res - 1)))
var want_gz := int(round(((hit2 as Vector3).z / size + 0.5) * float(res - 1)))
_check(absi(bi - want_gx) <= 1 and absi(bz - want_gz) <= 1,
"Stroke-Zentrum == erwartete Terrain-Zelle (%d,%d vs. %d,%d)" % [
bi, bz, want_gx, want_gz])
_check(best_d > 1.0, "Stroke hat messbar erhöht (%.2f m)" % best_d)
else:
_check(false, "Corner-Stroke: Raycast trifft")
# --- Erhabenes Terrain: Strahl muss die NEUE Oberfläche treffen ---
var hill := Vector2(20.0, -25.0)
TerrainField.apply_brush(heights, res, size, size, -100000.0, 100000.0,
hill.x, hill.y, 25.0, TerrainField.Mode.RAISE, 30.0)
var h_hill := TerrainField.height_at(heights, res, size, size, hill.x, hill.y)
_check(h_hill > 5.0, "Hügel steht (%.1f m)" % h_hill)
var screen3 := cam.unproject_position(Vector3(hill.x, h_hill, hill.y))
var hit3: Variant = TerrainField.raycast(heights, res, size, size,
cam.project_ray_origin(screen3), cam.project_ray_normal(screen3))
if hit3 != null:
var p3 := hit3 as Vector3
_check(absf(p3.x - hill.x) < cell and absf(p3.z - hill.y) < cell,
"Erhaben: Treffer bleibt auf Cursor-Zelle (%.2f/%.2f m)" % [
absf(p3.x - hill.x), absf(p3.z - hill.y)])
_check(absf(p3.y - h_hill) < 1.5,
"Erhaben: Strahl trifft die NEUE Oberfläche (y=%.2f vs. %.2f)" % [p3.y, h_hill])
else:
_check(false, "Erhaben: Raycast trifft")
# --- Steile Draufsicht (zweite Perspektive, Corner-Nähe) ---
var cam2 := Camera3D.new()
vp.add_child(cam2)
cam2.current = true
cam2.look_at_from_position(Vector3(0.0, 170.0, 25.0), Vector3.ZERO, Vector3.UP)
var steep_target := Vector2(-size * 0.44, -size * 0.2)
var screen4 := cam2.unproject_position(Vector3(steep_target.x, 0.0, steep_target.y))
var hit4: Variant = TerrainField.raycast(heights, res, size, size,
cam2.project_ray_origin(screen4), cam2.project_ray_normal(screen4))
if hit4 != null:
var p4 := hit4 as Vector3
_check(absf(p4.x - steep_target.x) < cell and absf(p4.z - steep_target.y) < cell,
"Steile Perspektive: Treffer-Zelle == Cursor-Zelle (%.2f/%.2f m)" % [
absf(p4.x - steep_target.x), absf(p4.z - steep_target.y)])
else:
_check(false, "Steile Perspektive: Raycast trifft")
vp.free()
## Karte t_3ebe4086: Relief-Lesbarkeit — Sly-Befund „Erhöhungen/Vertiefungen
## kaum erkennbar, alles knallgrün und hell“. Neue Vertex-Farb-Pipeline:
## Höhen-Kurve (S statt linear, breitere Spanne) × Pseudo-Licht (Normale ×
## feste Sonnenrichtung). asserted:
## - Tal vs. Hügel: deutlicher Helligkeits-Unterschied (besser als die alte
## Linear-Kalibrierung 0.72…1.15),
## - Pseudo-Licht: zugewandter Hang heller als abgewandter (gleiche Höhe),
## - Farb-Delta über einer Reliefkante (Stufe) > Schwellwert UND > alter Pfad,
## - Material-Farben bleiben unterscheidbar (Gras/Sand/Fels),
## - Alpha bleibt 1.0 (Shading-Faktor darf nur RGB skalieren).
func _test_relief_shading() -> void:
print("Testgruppe: Relief-Shading (Höhen-Kurve + Pseudo-Licht)")
var hmin := 0.0
var hmax := 20.0
var up := Vector3.UP
# Tal (h=1) vs. Hügel (h=19), beide flach:
var tal := _relief_color(0, 1.0, hmin, hmax, up)
var huegel := _relief_color(0, 19.0, hmin, hmax, up)
var delta_h := _col_maxdiff(tal, huegel)
var tal_alt := _old_cell_color(0, 1.0, hmin, hmax)
var huegel_alt := _old_cell_color(0, 19.0, hmin, hmax)
var delta_alt := _col_maxdiff(tal_alt, huegel_alt)
_check(delta_h > 0.10,
"Relief: Tal vs. Hügel deutlich unterschiedlich (Δ %.3f)" % delta_h)
_check(delta_h > delta_alt * 1.2,
"Relief: Kontrast deutlich besser als alte Kalibrierung (%.3f vs. %.3f)" % [
delta_h, delta_alt])
_check(tal.r < huegel.r and tal.g < huegel.g and tal.b < huegel.b,
"Relief: Hügel heller als Tal (monoton, nicht nur kanalweise gemischt)")
# Pseudo-Licht: gleiche Höhe (h=10, Mitte), Hang zur Sonne vs. weg.
# Sonne steht editor-seitig bei RELIEF_SUN (≈ +X/+Z oben). Ein Hang zeigt
# zur Sonne, wenn seine Normale Richtung +X/+Z kippt.
var zur_sonne := Vector3(0.45, 0.75, 0.5).normalized() # Normale ∥ Sonnenrichtung
var von_sonne := Vector3(-0.45, 0.75, -0.5).normalized() # gegenüberliegender Hang
var hell := _relief_color(0, 10.0, hmin, hmax, zur_sonne)
var dunkel := _relief_color(0, 10.0, hmin, hmax, von_sonne)
var delta_l := _col_maxdiff(hell, dunkel)
_check(delta_l > 0.08,
"Pseudo-Licht: zugewandter Hang heller als abgewandter (Δ %.3f)" % delta_l)
_check(hell.r > dunkel.r and hell.g > dunkel.g and hell.b > dunkel.b,
"Pseudo-Licht: Richtung stimmt (Sonne +X/+Z → +X/+Z-Hang heller)")
# Stufe: Feld mit harter Kante (h springt 0 → 10 m) — Farb-Delta über die
# Kante muss klar messbar sein (das ist die eigentliche Sly-Messgröße).
var res := 33
var heights := PackedFloat32Array()
heights.resize(res * res)
for i in heights.size():
heights[i] = 10.0 if (i % res) >= res / 2 else 0.0
var view := Node3D.new()
view.set_script(load("res://core/viewport/terrain_mesh.gd"))
view.call("update_heights", heights, res, 320.0, 320.0)
var mi = view.get("_mi")
var col: PackedColorArray = (mi.mesh.surface_get_arrays(0))[Mesh.ARRAY_COLOR]
var c_links: Color = col[16 * res + 14]
var c_rechts: Color = col[16 * res + 18]
var stufe_delta := _col_maxdiff(c_links, c_rechts)
_check(stufe_delta > 0.08,
"Relief: Farb-Delta über der Stufen-Kante messbar (Δ %.3f)" % stufe_delta)
_check(stufe_delta > _old_step_delta() * 1.2,
"Relief: Stufen-Kontrast deutlich besser als alte Kalibrierung (%.3f vs. %.3f)" % [
stufe_delta, _old_step_delta()])
view.free()
# Material-Zellen unterscheiden sich NACH dem Shading (gleiche
# Höhe/Neigung → nur die Palette trennt).
# Karte t_ab408127: Palette neutral-weiß — unterscheidbar sind die Zellen
# über die ALPHA (= Material-ID fürs Texture2DArray).
var g := _relief_color(0, 5.0, 0.0, 10.0, up)
var s := _relief_color(1, 5.0, 0.0, 10.0, up)
var f := _relief_color(2, 5.0, 0.0, 10.0, up)
_check(absf(s.a - g.a) > 0.01 and absf(f.a - g.a) > 0.01
and absf(f.a - s.a) > 0.01,
"Material: Gras/Sand/Fels via Alpha (Material-ID) unterscheidbar")
# Neu-Palette: Slot 3 (gras_satt) packt auf 3/20 — Arsenal erweitert.
var g3 := _relief_color(3, 5.0, 0.0, 10.0, up)
_check(absf(g3.a - 3.0 / 20.0) < 0.02,
"Material: neuer Slot 3 packt auf Alpha 3/20 (Texture2DArray-Layer)")
# Alpha-Kanal trägt die Material-ID (t_ab408127), das Shading skaliert
# NUR RGB (t_603ec1a8-Falle bleibt für den RGB-Anteil bestehen).
var alpha_rgb_ok := tal.r >= 0.0 and huegel.r >= 0.0 and hell.r >= 0.0 and dunkel.r >= 0.0
_check(alpha_rgb_ok, "Alpha: Material-ID gepackt, RGB = Shading (kein Crash)")
# Höhenlinien-Toggle + Flach-Map-Schutz (Uniform am echten Material).
var flat := PackedFloat32Array()
flat.resize(res * res)
var view2 := Node3D.new()
view2.set_script(load("res://core/viewport/terrain_mesh.gd"))
view2.call("update_heights", flat, res, 320.0, 320.0)
var mat2 = (view2.get("_mi") as MeshInstance3D).material_override
_check(mat2 is ShaderMaterial, "Höhenlinien: Kontur-Shader aktiv (Editor-only)")
_check(float(mat2.get_shader_parameter("linien_an")) == 0.0,
"Höhenlinien: flache Map (< 2 m Relief) → automatisch AUS")
view2.call("set_contours", false)
view2.free()
var view3 := Node3D.new()
view3.set_script(load("res://core/viewport/terrain_mesh.gd"))
view3.call("update_heights", heights, res, 320.0, 320.0) # Stufe = 10 m Relief
var mat3 = (view3.get("_mi") as MeshInstance3D).material_override
_check(float(mat3.get_shader_parameter("linien_an")) == 1.0,
"Höhenlinien: Relief ≥ 2 m → AN (Default)")
view3.call("set_contours", false)
_check(float(mat3.get_shader_parameter("linien_an")) == 0.0,
"Höhenlinien: Toggle AUS wirkt ohne Rebuild")
view3.free()
## Statische relief_color()-Mathematik aus terrain_mesh.gd über ein
## Script-Exemplar rufen (statische Funktionen sind über die Instanz
## aufrufbar; das Exemplar braucht NICHT im Baum zu liegen).
var _relief_helper: Node3D
func _relief_color(mat_id: int, h: float, hmin: float, hmax: float,
normal: Vector3) -> Color:
if _relief_helper == null or not is_instance_valid(_relief_helper):
_relief_helper = Node3D.new()
_relief_helper.set_script(load("res://core/viewport/terrain_mesh.gd"))
return _relief_helper.call("relief_color", mat_id, h, hmin, hmax, normal)
## Die ALTE Kalibrierung (t_603ec1a8): linear 0.72…1.15 — Referenz für die
## Kontrast-Regression („besser als heute“).
func _old_cell_color(mat_id: int, h: float, hmin: float, hmax: float) -> Color:
var pal := [Color(0.30, 0.45, 0.22), Color(0.78, 0.70, 0.45), Color(0.45, 0.44, 0.42)]
var span := maxf(hmax - hmin, 0.0001)
var t := clampf((h - hmin) / span, 0.0, 1.0)
var f := lerpf(0.72, 1.15, t)
var base: Color = pal[clampi(mat_id, 0, 2)]
return Color(base.r * f, base.g * f, base.b * f, 1.0)
## Alter Stufen-Kontrast (gleiche Stufe wie oben, alter Farb-Pfad).
func _old_step_delta() -> float:
return _col_maxdiff(_old_cell_color(0, 0.0, 0.0, 10.0), _old_cell_color(0, 10.0, 0.0, 10.0))
func _col_maxdiff(a: Color, b: Color) -> float:
return maxf(absf(a.r - b.r), maxf(absf(a.g - b.g), absf(a.b - b.b)))
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())
## 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"]))
terrain.erase("material_resolution") # Quelle = Alt-Karte (Material = Höhenraster)
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": "acacia_005", "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²)")
# Feines Raster (24.10.): 512 m / res 257 → 2 m → Material-Raster 513 (1 m).
var gm := SvmapIO.material_resolution(t_new)
_check(gm == 513 and grown_mats.size() == gm * gm, "Material-Bytes auf feines Raster (513²)")
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
for j in gm:
for i in [0, gm - 1]:
if int(grown_mats[j * gm + i]) != 0 or int(grown_mats[i * gm + j]) != 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[256 * gm + 256]) == 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"]) == "acacia_005",
"Objekte unverändert übernommen")
_check(not grown.has("checksum"), "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")
## R2 (t_27ba6c37): Verkleinern 512→256 m + Out-of-Bounds-Liste + Filter.
## Semantik „Anker Mitte": Terrain behält Weltposition, Heights/Material
## außerhalb fliegen weg. Objekte/Bauteile/Spawns bleiben in resize_terrain
## unangetastet — resize_preview listet, was komplett außerhalb läge,
## resize_apply(drop_outside=true) entfernt genau diese Einträge.
func _test_resize_shrink() -> void:
print("Testgruppe: Resize Verkleinern + Out-of-Bounds-Liste + Filter (R2)")
var doc := SvmapDocument.create_new("Shrink", 512.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var heights := SvmapIO.decode_heights(terrain)
# Geformtes Terrain: Hügel nahe der künftigen +X-Kante (bleibt drin),
# Senke weit draußen bei (-200, 150) — ihr Inhalt fällt beim Shrink weg.
TerrainField.apply_brush(heights, 129, 512.0, 512.0, -100000.0, 100000.0,
100.0, 0.0, 40.0, TerrainField.Mode.RAISE, 18.0)
TerrainField.apply_brush(heights, 129, 512.0, 512.0, -100000.0, 100000.0,
-200.0, 150.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, 512.0, 512.0, 100.0, 0.0, 40.0, 1)
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
# Objekte drin/draußen gemischt (eins EXAKT auf der Kante = drin, eps).
doc["objects"] = [
{"id": "box_001", "pos": [50.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "box_001", "pos": [128.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "box_001", "pos": [200.0, 0.0, -80.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "box_001", "pos": [-300.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0},
]
# Bauteile: Wand mit Fußabdruck ÜBER der Kante (Punkte 127→129 m) = drin,
# Fundament komplett draußen, gedrehte Wand über der -Z-Kante = drin.
doc["gebaeude"] = [
{"id": "wand", "pos": [127.0, 0.0, 10.0], "rot": 0.0},
{"id": "fundament", "pos": [150.0, 0.0, -50.0], "rot": 0.0},
{"id": "wand", "pos": [0.0, 0.0, -127.0], "rot": 90.0},
]
# Spawn-Punkte drin/draußen gemischt (Default-Zonen von 512 m lägen alle
# bei ±204,8 m draußen — hier bewusst gemischt, damit nach dem Filter
# beide Teams ≥ 4 Punkte behalten und die Map speicherbar bleibt, V11).
doc["spawn_zones"] = {
"0": {"label": "Team Blau", "points": [
{"id": "A1", "pos": [100.0, 0.2, 0.0]},
{"id": "A2", "pos": [300.0, 0.2, 0.0]},
{"id": "A3", "pos": [60.0, 0.2, 40.0]},
{"id": "A4", "pos": [-100.0, 0.2, -60.0]},
{"id": "A5", "pos": [20.0, 0.2, -80.0]},
]},
"1": {"label": "Team Rot", "points": [
{"id": "B1", "pos": [-204.8, 0.2, 0.0]},
{"id": "B2", "pos": [0.0, 0.2, -100.0]},
{"id": "B3", "pos": [40.0, 0.2, 80.0]},
{"id": "B4", "pos": [-50.0, 0.2, 100.0]},
{"id": "B5", "pos": [-20.0, 0.2, -80.0]},
]},
}
var doc_json_before := JSON.stringify(doc)
# --- resize_preview: Liste je Kategorie, doc bleibt unangetastet ---------
var preview := SvmapDocument.resize_preview(doc, 256.0, 256.0)
_check(str(doc_json_before) == JSON.stringify(doc),
"Preview: reine Funktion, Eingabe-doc unangetastet")
var objects_out: Array = preview["objects_out"]
_check(objects_out.size() == 2 and int(objects_out[0]) == 2
and int(objects_out[1]) == 3,
"Preview objects_out: Indizes 2+3 draußen, Kante-Objekt (128 m) drin")
var parts_out: Array = preview["parts_out"]
_check(parts_out.size() == 1 and int(parts_out[0]) == 1,
"Preview parts_out: nur Fundament draußen, Rand-überlappende Wände drin")
var spawns_out: Array = preview["spawns_out"]
var spawn_keys := []
for entry in spawns_out:
spawn_keys.append("%d:%s" % [int(entry["team"]), str(entry["id"])])
spawn_keys.sort()
_check(spawn_keys == ["0:A2", "1:B1"],
"Preview spawns_out: A2 (300 m) + B1 (-204,8 m) draußen")
var grow_preview := SvmapDocument.resize_preview(doc, 1024.0, 1024.0)
_check((grow_preview["objects_out"] as Array).is_empty()
and (grow_preview["parts_out"] as Array).is_empty()
and (grow_preview["spawns_out"] as Array).is_empty(),
"Preview beim Vergrößern: nichts draußen")
var clamp_preview := SvmapDocument.resize_preview(doc, 100.0, 512.0)
_check((clamp_preview["objects_out"] as Array).size() == 2,
"Preview clamp: 100 m→256 m zählt wie 256 m")
# --- resize_apply ohne Filter: wie resize_terrain ------------------------
var kept := SvmapDocument.resize_apply(doc, 256.0, 256.0, false)
_check((kept["objects"] as Array).size() == 4
and (kept["gebaeude"] as Array).size() == 3
and (kept["spawn_zones"]["0"]["points"] as Array).size() == 5,
"Apply ohne Filter: Objekte/Bauteile/Spawns unverändert übernommen")
_check(absf(float(kept["terrain"]["size_x_m"]) - 256.0) < 0.0001
and absf(float(kept["terrain"]["size_z_m"]) - 256.0) < 0.0001
and int(kept["terrain"]["resolution"]) == 129,
"Shrink 512→256 m: Größe ok, Dichte 2 m/Punkt hält res 129")
# Drin bleibt 1:1: alle ALTEN Gridpunkte innerhalb des neuen Gebiets
# (Vierer-Raster = exakte Knoten) stimmen ±0.05 m (R16-Quantisierung).
var old_res := 129
var old_sx := 512.0
var old_sz := 512.0
var new_doc := SvmapDocument.resize_apply(doc, 256.0, 256.0, true)
var max_err := 0.0
var world_kept := true
for zi in old_res:
for xi in old_res:
var x := (float(xi) / float(old_res - 1) - 0.5) * old_sx
var z := (float(zi) / float(old_res - 1) - 0.5) * old_sz
if absf(x) > 124.0 or absf(z) > 124.0:
continue
var expected := heights[zi * old_res + xi]
var actual := SvmapDocument.terrain_height_at(new_doc, x, z)
var err := absf(actual - expected)
if err > max_err:
max_err = err
if err > 0.05:
world_kept = false
_check(world_kept,
"Anker Mitte beim Shrink: alter Grid drin ±0.05 m (max %.4f m)" % max_err)
_check(absf(SvmapDocument.terrain_height_at(new_doc, 100.0, 0.0)
- SvmapDocument.terrain_height_at(doc, 100.0, 0.0)) < 0.05,
"Hügel an der künftigen Kante (100 m) an Weltposition erhalten")
# Terrain außerhalb fliegt weg: der Hügel ragt über die neue Kante hinaus
# (RAISE bei 100 m, Radius 40) — der neue Rand trägt die ALTE Höhe an
# derselben Weltposition (Anker Mitte), ist aber NICHT flach abgeschnitten.
_check(absf(SvmapDocument.terrain_height_at(new_doc, 128.0, 0.0)
- SvmapDocument.terrain_height_at(doc, 128.0, 0.0)) < 0.05
and SvmapDocument.terrain_height_at(new_doc, 128.0, 0.0)
> float(new_doc["terrain"]["min_height_m"]) + 0.5,
"Kante beim Shrink: neuer Rand = alte Höhe an Weltposition (kein flacher Schnitt)")
# --- Filter: genau die gelisteten Einträge raus, Rest 1:1 ----------------
var filtered_objects: Array = new_doc["objects"]
_check(filtered_objects.size() == 2
and str(filtered_objects[0]["pos"][0]) == "50.0"
and absf(float(filtered_objects[1]["pos"][0]) - 128.0) < 0.0001,
"Filter: 2 Objekte raus, drin bleibt 1:1 (Position unangetastet)")
var filtered_parts: Array = new_doc["gebaeude"]
_check(filtered_parts.size() == 2
and str(filtered_parts[0]["id"]) == "wand"
and absf(float(filtered_parts[0]["pos"][0]) - 127.0) < 0.0001
and absf(float(filtered_parts[1]["rot"]) - 90.0) < 0.0001,
"Filter: Fundament raus, Rand-Wände bleiben (kein Clampen)")
var zones: Dictionary = new_doc["spawn_zones"]
_check((zones["0"]["points"] as Array).size() == 4
and (zones["1"]["points"] as Array).size() == 4
and str(zones["0"]["label"]) == "Team Blau",
"Filter: A2/B1 raus, Team-Struktur bleibt (je 4 Punkte, V11)")
# Reihenfolge/IDs der verbleibenden Spawn-Punkte unangetastet.
var ids0 := []
for p in zones["0"]["points"]:
ids0.append(str(p["id"]))
_check(ids0 == ["A1", "A3", "A4", "A5"],
"Filter: Spawn-Punkte ohne Umnummerierung entfernt (A2 fehlt)")
# --- Roundtrip: gefiltertes Shrink-Ergebnis speichert + lädt -------------
var path := "/tmp/scopedvr_test_terrain_shrink.svmap"
var save := SvmapIO.save_to_file(path, new_doc)
_check(save["ok"], "Gefiltertes Shrink-Ergebnis speichert valide (V1–V14)")
var load := SvmapIO.load_from_file(path)
_check(load["ok"], "Gefiltertes Shrink-Ergebnis lädt mit gültiger Prüfsumme")
if load["ok"]:
_check(str(load["data"]["terrain"]["heightmap_r16_b64"])
== str(new_doc["terrain"]["heightmap_r16_b64"]),
"Shrink-Roundtrip: Höhenfeld byte-identisch")
_check((load["data"]["objects"] as Array).size() == 2
and (load["data"]["gebaeude"] as Array).size() == 2,
"Shrink-Roundtrip: gefilterte Objekte/Bauteile bleiben gefiltert")