scopedvr-map-editor/tests/test_terrain.gd
Hermes (godot-vr) b7b160d3bc Terrain-Darstellung (t_7ac70014): Winding-Order geflippt — Terrain bei h=0 sofort als Rasen-Platte sichtbar.
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.
2026-10-16 17:14:54 +00:00

481 lines
20 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends SceneTree
## Editor-Tests (headless): Karte t_9aba04f1 — Terrain-Werkzeug (Höhenfeld
## heben/senken/glätten, Pinsel-Mathematik, Stroke-Undo, svmap-Roundtrip).
## Ausführen: godot --headless --path . --script res://tests/test_terrain.gd
## Exit 0 = alles grün. GUI-Seiten (Ring sichtbar, Mesh folgt live) werden im
## Desktop-Smoke mit Screenshots verifiziert (--terrain-demo), nicht hier.
var failures := 0
var checks := 0
func _init() -> void:
_run_tests()
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
func _run_tests() -> void:
_test_encode_roundtrip()
_test_brush_raise_lower()
_test_brush_smooth()
_test_stroke_undo_redo()
_test_range_expansion()
_test_svmap_roundtrip()
_test_resolution_limit()
_test_material_brush()
_test_paint_undo_redo()
_test_material_format()
_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())