Root-Cause für Slys Gerätesymptom (dunkle Platte + Gitter, Terrain „wacht erst nach langem Drücken auf“): terrain_mesh.gd baute alle Dreiecke von oben CCW = Backface → komplette Deckfläche wurde von oben ge-cullt; sichtbar war nur der dunkle Sky-Boden (0.1/0.1/0.12) plus Gitter-Overlay. Gehobene Flanken waren nur sichtbar, wenn sie über die unsichtbare Deckfläche ragten (Rückenansicht) — daher „wirkt der Pinsel erst nach langem Drücken“. - terrain_mesh.gd: Dreiecks-Indices gespiegelt [i, i+1, i+res, i+1, i+res+1, i+res] (CW von oben = Frontseite, Godot-Konvention); Normalen-Gradient korrigiert (x-Index läuft gegen Welt-X: n ∝ ((hx_r-hx_l)·cell_z, 2·cell_x·cell_z, (hz_u-hz_d)·cell_x)) — Hänge waren vorher falsch beleuchtet. MeshInstance lazily erzeugt (update_heights sicher vor _ready). - map_view_builder.gd: Gitter default AUS über sichtbarem Terrain (grid_node), ohne Terrain-Block als Boden-Referenz sichtbar; set_grid_visible()/grid_is_visible() ohne Rebuild. - main.gd: Toggle-Knopf „⊞ Gitter“ in der Top-Bar; Nutzer-Wahl überlebt jeden Viewport-Rebuild. - Erster Pinselkontakt: mit sichtbarem Terrain wirkt der Live-Pfad ab Frame 1 (code-seitig korrekt, war nur unsichtbar). Tests: test_terrain 64 Checks (NEU: Winding aller Dreiecke = Frontseite von oben, Normale h=0 = (0,1,0), X/Z-Hügel-Normale nicht invertiert, erster RAISE-Druck verschiebt Vertex messbar im Mesh, Gitter-Default/Toggle/Boden-Referenz). Suite 441 Checks + Export-Suite 21 grün; --import ohne Parse-Error.
481 lines
20 KiB
GDScript
481 lines
20 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()
|
||
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_grid_toggle()
|
||
|
||
|
||
## Decode → Encode bleibt innerhalb eines Quantisierungsschritts (R16).
|
||
func _test_encode_roundtrip() -> void:
|
||
print("Testgruppe: R16-Encode/Decode-Roundtrip")
|
||
var doc := SvmapDocument.create_new("Quant", 1024.0, "mittag")
|
||
var terrain: Dictionary = doc["terrain"]
|
||
var heights := SvmapIO.decode_heights(terrain)
|
||
_check(heights.size() == 129 * 129, "Neue Map: 129×129 Höhenpunkte (Editor-MVP-Limit)")
|
||
for i in heights.size():
|
||
heights[i] = 13.0 + float(i % 17) * 1.5
|
||
var b64 := TerrainField.encode(heights, 0.0, 50.0)
|
||
var terrain2: Dictionary = terrain.duplicate(true)
|
||
terrain2["heightmap_r16_b64"] = b64
|
||
var back := SvmapIO.decode_heights(terrain2)
|
||
var worst := 0.0
|
||
for i in heights.size():
|
||
worst = maxf(worst, absf(back[i] - heights[i]))
|
||
_check(worst <= 50.0 / 65535.0 + 0.0001,
|
||
"Encode-Fehler ≤ ½ Quantisierungsschritt (worst %.6f m)" % worst)
|
||
|
||
|
||
## Heben/Senken: Kosinus-Falloff, außerhalb des Radius nichts verändert,
|
||
## Senken unter min_height geht dank Range-Expansion (getestet separat).
|
||
func _test_brush_raise_lower() -> void:
|
||
print("Testgruppe: Pinsel heben/senken")
|
||
var res := 33
|
||
var size := 320.0
|
||
var heights := PackedFloat32Array()
|
||
heights.resize(res * res)
|
||
TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
|
||
0.0, 0.0, 40.0, TerrainField.Mode.RAISE, 10.0)
|
||
var center := heights[16 * res + 16]
|
||
var mid := heights[16 * res + 18] # 20 m vom Zentrum (Zelle = 10 m)
|
||
var far := heights[2 * res + 16] # 140 m weg — außerhalb des Radius
|
||
_check(center > 9.5, "Heben: Zentrum um ~10 m angehoben (ist %.2f)" % center)
|
||
_check(mid > 0.0 and mid < center, "Heben: Falloff zwischen Zentrum und Rand")
|
||
_check(far == 0.0, "Heben: außerhalb des Radius unverändert")
|
||
var before := heights.duplicate()
|
||
TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
|
||
0.0, 0.0, 40.0, TerrainField.Mode.LOWER, 4.0)
|
||
_check(heights[16 * res + 16] < center, "Senken: Zentrum wieder tiefer")
|
||
var diff := false
|
||
for i in heights.size():
|
||
if not before.is_empty() and absf(heights[i] - before[i]) > 0.0001:
|
||
diff = true
|
||
break
|
||
_check(diff, "Senken: Feld hat sich tatsächlich geändert")
|
||
|
||
|
||
## Glätten: Variance sinkt IN DER PINSEL-FLÄCHE, Form bleibt (Mittel-Mischung).
|
||
func _test_brush_smooth() -> void:
|
||
print("Testgruppe: Pinsel glätten")
|
||
var res := 33
|
||
var size := 320.0
|
||
var heights := PackedFloat32Array()
|
||
heights.resize(res * res)
|
||
# Zackiger Sägezahn in der Pinsel-Mitte.
|
||
for i in heights.size():
|
||
var x := i % res
|
||
heights[i] = 10.0 + (5.0 if x % 2 == 0 else -5.0)
|
||
var variance_before := _variance_block(heights, res, 14, 18, 14, 18)
|
||
var moved := false
|
||
for pass_i in 2:
|
||
moved = TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
|
||
0.0, 0.0, 60.0, TerrainField.Mode.SMOOTH, 1.0) or moved
|
||
_check(moved, "Glätten: Feld wurde verändert")
|
||
var variance_after := _variance_block(heights, res, 14, 18, 14, 18)
|
||
_check(variance_after < variance_before * 0.4,
|
||
"Glätten: Varianz in der Pinsel-Fläche deutlich gesunken (%.1f → %.1f)" % [
|
||
variance_before, variance_after])
|
||
_check(absf(_mean(heights) - 10.0) < 1.0,
|
||
"Glätten: Gesamt-Mittelhöhe bleibt ~10 m (Form bleibt)")
|
||
|
||
|
||
## Der GANZE Stroke = EIN Undo-Eintrag (Chef-Kriterium): viele Einzel-Schritte
|
||
## auf derselben Stelle → Strg+Z stellt die Form VOR dem Strich wieder her.
|
||
func _test_stroke_undo_redo() -> void:
|
||
print("Testgruppe: Stroke-Undo (ganzer Strich)")
|
||
var doc := SvmapDocument.create_new("Stroke", 1024.0, "mittag")
|
||
var hist := ActionHistory.new()
|
||
hist.begin(doc)
|
||
var terrain0: Dictionary = doc["terrain"]
|
||
var heights := SvmapIO.decode_heights(terrain0)
|
||
|
||
# Ein Stroke = 12 Anwendungs-Schritte + EIN commit am Ende (wie terrain_tool).
|
||
var before: Dictionary = hist.snapshot(["terrain"])
|
||
for step in 12:
|
||
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, 0.0, 50.0,
|
||
0.0, 0.0, 60.0, TerrainField.Mode.RAISE, 0.8)
|
||
terrain0["heightmap_r16_b64"] = TerrainField.encode(heights, 0.0, 50.0)
|
||
hist.commit("Terrain geformt (Heben)", before, hist.snapshot(["terrain"]))
|
||
_check(hist.entries.size() == 1, "12 Pinsel-Schritte → EIN History-Eintrag")
|
||
|
||
var after_b64 := str(terrain0["heightmap_r16_b64"])
|
||
var raised := SvmapIO.decode_heights(terrain0)
|
||
_check(raised[64 * 129 + 64] > 5.0, "Stroke: Mitte ist deutlich angehoben")
|
||
|
||
var desc := hist.undo()
|
||
_check(desc == "Terrain geformt (Heben)", "Undo-Desc nennt den Stroke")
|
||
_check(str(terrain0["heightmap_r16_b64"]) != after_b64, "Undo: Höhenfeld geändert")
|
||
var restored := SvmapIO.decode_heights(terrain0)
|
||
_check(absf(restored[64 * 129 + 64]) < 0.01, "Undo: Boden wieder FLACH (ganzer Strich)")
|
||
|
||
desc = hist.redo()
|
||
_check(desc == "Terrain geformt (Heben)", "Redo-Desc stimmt")
|
||
_check(str(terrain0["heightmap_r16_b64"]) == after_b64,
|
||
"Redo: byte-identisches Höhenfeld zurück (kein Quantisierungs-Drift)")
|
||
|
||
|
||
## Range-Expansion: Senken unter min_height und Heben über max_height
|
||
## weiten min/max_height_m automatisch auf (Format-gültig, Spannweite ≤ 500).
|
||
func _test_range_expansion() -> void:
|
||
print("Testgruppe: Range-Expansion (Senken auf flacher Map)")
|
||
var doc := SvmapDocument.create_new("Dig", 1024.0, "mittag")
|
||
var terrain: Dictionary = doc["terrain"]
|
||
var heights := SvmapIO.decode_heights(terrain) # alles 0 = min_height
|
||
# Wie terrain_tool: ohne Clamp anwenden, Range danach expandieren.
|
||
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0,
|
||
0.0, 0.0, 50.0, TerrainField.Mode.LOWER, 6.0)
|
||
_check(heights[64 * 129 + 64] < -5.0, "Senken UNTER 0 m möglich (kein Clamp an min_height)")
|
||
var hmin := floorf(TerrainField._min_h(heights) - 1.0)
|
||
var hmax := maxf(float(terrain["max_height_m"]), ceilf(TerrainField._max_h(heights) + 1.0))
|
||
terrain["min_height_m"] = hmin
|
||
terrain["max_height_m"] = hmax
|
||
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, hmin, hmax)
|
||
var path := "/tmp/scopedvr_test_terrain_dig.svmap"
|
||
var save := SvmapIO.save_to_file(path, doc)
|
||
_check(save["ok"], "Expandierte Map bleibt V1–V14-gültig (Speichern 0 Fehler)")
|
||
var load := SvmapIO.load_from_file(path)
|
||
_check(load["ok"], "Expandierte Map lädt sauber zurück (inkl. Prüfsumme)")
|
||
var reloaded := SvmapIO.decode_heights(load["data"]["terrain"])
|
||
_check(absf(reloaded[64 * 129 + 64] - heights[64 * 129 + 64]) < 0.01,
|
||
"Range-Expansion: Tiefe überlebt Encode/Decode")
|
||
|
||
|
||
## svmap-Roundtrip: geformtes Terrain speichern → laden → Höhenfeld byte-identisch,
|
||
## Prüfsumme gültig, Loader nimmt die Map an.
|
||
func _test_svmap_roundtrip() -> void:
|
||
print("Testgruppe: svmap-Roundtrip (Speichern/Laden)")
|
||
var doc := SvmapDocument.create_new("Roundtrip", 1024.0, "mittag")
|
||
var terrain: Dictionary = doc["terrain"]
|
||
var heights := SvmapIO.decode_heights(terrain)
|
||
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0,
|
||
-200.0, 100.0, 80.0, TerrainField.Mode.RAISE, 18.0)
|
||
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0,
|
||
300.0, -300.0, 60.0, TerrainField.Mode.LOWER, 9.0)
|
||
terrain["min_height_m"] = floorf(TerrainField._min_h(heights) - 1.0)
|
||
terrain["max_height_m"] = ceilf(TerrainField._max_h(heights) + 1.0)
|
||
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, float(terrain["min_height_m"]), float(terrain["max_height_m"]))
|
||
|
||
var path := "/tmp/scopedvr_test_terrain_roundtrip.svmap"
|
||
var save := SvmapIO.save_to_file(path, doc)
|
||
_check(save["ok"], "Speichern: 0 Fehler (Validierung V1–V14 vor dem Schreiben)")
|
||
var load := SvmapIO.load_from_file(path)
|
||
_check(load["ok"], "Laden: 0 Fehler (inkl. Prüfsumme)")
|
||
var t_in: Dictionary = load["data"]["terrain"]
|
||
_check(str(t_in["heightmap_r16_b64"]) == str(terrain["heightmap_r16_b64"]),
|
||
"Höhenfeld-Diff LEER (byte-identisch nach Speichern→Laden)")
|
||
_check(absf(float(t_in["min_height_m"]) - float(terrain["min_height_m"])) < 0.0001
|
||
and absf(float(t_in["max_height_m"]) - float(terrain["max_height_m"])) < 0.0001,
|
||
"Höhen-Range bleibt erhalten")
|
||
|
||
|
||
func _test_resolution_limit() -> void:
|
||
print("Testgruppe: Auflösungs-Limit (Editor-MVP)")
|
||
_check(TerrainField.EDITOR_MAX_RESOLUTION == 129, "EDITOR_MAX_RESOLUTION = 129")
|
||
_check(SvmapDocument.DEFAULT_RESOLUTION == 129, "Neue Maps: res 129 (Perf-Limit)")
|
||
_check(SvmapIO.MAX_TERRAIN_RESOLUTION == 513, "Format-Limit 513 unverändert (Interop)")
|
||
|
||
|
||
## Material-Pinsel (Karte t_603ec1a8): Kreisfläche malt die Material-ID,
|
||
## außerhalb bleibt, Höhenfeld bleibt unangetastet, Gras zurückmalen räumt auf.
|
||
func _test_material_brush() -> void:
|
||
print("Testgruppe: Material-Pinsel (Malen)")
|
||
var res := 33
|
||
var size := 320.0
|
||
var mats := PackedByteArray()
|
||
mats.resize(res * res)
|
||
var heights := PackedFloat32Array()
|
||
heights.resize(res * res)
|
||
for i in heights.size():
|
||
heights[i] = 7.0 + float(i % 5)
|
||
var changed := TerrainField.apply_material_brush(mats, res, size, size,
|
||
0.0, 0.0, 40.0, 1)
|
||
_check(changed, "Malen: apply meldet Änderung")
|
||
_check(mats[16 * res + 16] == 1, "Malen: Zentrum = Sand (1)")
|
||
_check(mats[2 * res + 16] == 0, "Malen: 140 m weg — außerhalb des Radius unverändert")
|
||
# Höhenfeld bleibt vom Material-Pinsel komplett unberührt.
|
||
var heights_before := heights.duplicate()
|
||
TerrainField.apply_material_brush(mats, res, size, size, 0.0, 0.0, 40.0, 2)
|
||
var same := true
|
||
for i in heights.size():
|
||
if not is_equal_approx(heights[i], heights_before[i]):
|
||
same = false
|
||
break
|
||
_check(same, "Malen: Höhenfeld bleibt unangetastet (Malen statt Formen)")
|
||
# Radierer-Semantik: mit 0 (Gras) übermalt ist die Fläche wieder leer.
|
||
TerrainField.apply_material_brush(mats, res, size, size, 0.0, 0.0, 40.0, 0)
|
||
var grass := true
|
||
for m in mats:
|
||
if m != 0:
|
||
grass = false
|
||
break
|
||
_check(grass, "Malen: Gras zurückmalen räumt die Fläche leer")
|
||
# Ungültige Eingaben: zu kleines Grid wird abgelehnt; PAINT über
|
||
# apply_brush (Höhen-API) ist bewusst neutral.
|
||
_check(not TerrainField.apply_material_brush(mats, 1, size, size,
|
||
0.0, 0.0, 10.0, 1), "Malen: res < 2 wird abgelehnt")
|
||
_check(not TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
|
||
0.0, 0.0, 10.0, TerrainField.Mode.PAINT, 5.0),
|
||
"apply_brush mit PAINT ist neutral (keine Höhe, kein Crash)")
|
||
|
||
|
||
## Mal-Stroke = EIN Undo-Eintrag (Chef-Kriterium wie beim Formen): Sand-Streifen
|
||
## über mehrere Anwendungen, Strg+Z → komplett Gras, Strg+Shift+Z → byte-identisch.
|
||
func _test_paint_undo_redo() -> void:
|
||
print("Testgruppe: Mal-Stroke-Undo (ganzer Strich)")
|
||
var doc := SvmapDocument.create_new("Paint", 1024.0, "mittag")
|
||
var hist := ActionHistory.new()
|
||
hist.begin(doc)
|
||
var terrain: Dictionary = doc["terrain"]
|
||
var mats := SvmapIO.decode_materials(terrain)
|
||
_check(mats.size() == 129 * 129, "Neue Map: Material-Schicht 129² (alles Gras)")
|
||
var before: Dictionary = hist.snapshot(["terrain"])
|
||
for step in 6: # Streifen von links nach rechts, wie ein Zieh-Stroke
|
||
TerrainField.apply_material_brush(mats, 129, 1024.0, 1024.0,
|
||
-300.0 + float(step) * 60.0, 0.0, 24.0, 1)
|
||
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
|
||
hist.commit("Boden gemalt (sand)", before, hist.snapshot(["terrain"]))
|
||
_check(hist.entries.size() == 1, "6 Mal-Schritte → EIN History-Eintrag")
|
||
var painted := SvmapIO.decode_materials(terrain)
|
||
_check(painted[64 * 129 + 64] == 1, "Stroke-Mitte = Sand")
|
||
_check(painted[10 * 129 + 10] == 0, "Außerhalb des Streifens bleibt Gras")
|
||
var painted_b64 := str(terrain["materials_r8_b64"])
|
||
var desc := hist.undo()
|
||
_check(desc == "Boden gemalt (sand)", "Undo-Desc nennt den Mal-Stroke")
|
||
_check(str(terrain["materials_r8_b64"]) != painted_b64, "Undo: Material-Schicht geändert")
|
||
var restored := SvmapIO.decode_materials(terrain)
|
||
var all_grass := true
|
||
for m in restored:
|
||
if m != 0:
|
||
all_grass = false
|
||
break
|
||
_check(all_grass, "Undo: komplett Gras (Zustand vor dem Strich)")
|
||
desc = hist.redo()
|
||
_check(desc == "Boden gemalt (sand)", "Redo-Desc stimmt")
|
||
_check(str(terrain["materials_r8_b64"]) == painted_b64,
|
||
"Redo: byte-identische Material-Schicht zurück")
|
||
|
||
|
||
## Format v2/V15: Material-Schicht speichert/lädt, falsche Länge + unbekannte
|
||
## ID werden abgelehnt, Legacy-v1 OHNE Schicht lädt mit Default Gras.
|
||
func _test_material_format() -> void:
|
||
print("Testgruppe: Material-Format (v2/V15 + Legacy)")
|
||
var doc := SvmapDocument.create_new("Format", 1024.0, "mittag")
|
||
_check(int(doc["format_version"]) == 2, "Neue Maps tragen format_version 2")
|
||
var path := "/tmp/scopedvr_test_terrain_material.svmap"
|
||
var save := SvmapIO.save_to_file(path, doc)
|
||
_check(save["ok"], "v2-Map (mit Schicht) speichert ohne Fehler")
|
||
var load := SvmapIO.load_from_file(path)
|
||
_check(load["ok"], "v2-Map lädt sauber zurück (inkl. Prüfsumme)")
|
||
var mats := SvmapIO.decode_materials(load["data"]["terrain"])
|
||
_check(mats.size() == 129 * 129, "v2: Material-Schicht komplett dekodiert")
|
||
|
||
# V15: falsche Länge → Ablehnung.
|
||
var bad: Dictionary = doc.duplicate(true)
|
||
(bad["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(
|
||
PackedByteArray([0, 1, 2]))
|
||
_check(_has_err(SvmapIO.validate_map(bad, {}), "V15"),
|
||
"V15: falsche Material-Länge abgelehnt")
|
||
|
||
# V15: unbekannte Material-ID → Ablehnung.
|
||
bad = doc.duplicate(true)
|
||
var mraw := PackedByteArray()
|
||
mraw.resize(129 * 129)
|
||
mraw[5000] = 3
|
||
(bad["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(mraw)
|
||
_check(_has_err(SvmapIO.validate_map(bad, {}), "V15"),
|
||
"V15: unbekannte Material-ID abgelehnt")
|
||
|
||
# Legacy-v1: OHNE Material-Schicht lädt und gilt als alles Gras.
|
||
var legacy: Dictionary = doc.duplicate(true)
|
||
legacy["format_version"] = 1
|
||
(legacy["terrain"] as Dictionary).erase("materials_r8_b64")
|
||
SvmapIO.sign_map(legacy)
|
||
var legacy_path := "/tmp/scopedvr_test_terrain_legacy_v1.svmap"
|
||
var lsave := SvmapIO.save_to_file(legacy_path, legacy)
|
||
_check(lsave["ok"], "Legacy-v1 (ohne Schicht) bleibt speicherbar")
|
||
var lload := SvmapIO.load_from_file(legacy_path)
|
||
_check(lload["ok"], "Legacy-v1 lädt sauber (V3 akzeptiert 1 und 2)")
|
||
var lmats := SvmapIO.decode_materials(lload["data"]["terrain"])
|
||
var l_grass := true
|
||
for m in lmats:
|
||
if m != 0:
|
||
l_grass = false
|
||
break
|
||
_check(l_grass and lmats.size() == 129 * 129,
|
||
"Legacy-v1: fehlende Schicht → Default Gras überall")
|
||
|
||
|
||
func _has_err(errors: PackedStringArray, needle: String) -> bool:
|
||
for e in errors:
|
||
if str(e).contains(needle):
|
||
return true
|
||
return false
|
||
|
||
|
||
## 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: x-Index läuft GEGEN die Welt-X-Achse —
|
||
# Höhe steigt mit x → 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()
|
||
|
||
|
||
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())
|