- 423 Objekte in 19 deutschen Kategorien (Panzer, Radfahrzeuge, Flugzeuge, Gebaeude, Zelte, Tuerme, Mauern, Sperren, Kisten, Technik, Felsen, Boden, Decals ...); Gruppen "Bauwerke" und "Kisten & Barrieren" samt Test-Asset-Namenslisten entfernt - LibraryBrowser: Gruppen/Namen aus dem Manifest (kategorie, name_de), unbekannte Kategorien bekommen automatisch eine eigene Gruppe - Thumbs neu, library/ synchronisiert (inkl. neuer Kollision) - Neue Identitaetsprobe: Kollision ALLER Objekte Editor = Client = Server - Tests auf Pack-Objekte umgestellt Co-Authored-By: Claude <noreply@anthropic.com>
1229 lines
56 KiB
GDScript
1229 lines
56 KiB
GDScript
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://src/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://src/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://src/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://src/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://src/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://src/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://src/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://src/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://src/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://src/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")
|