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_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) _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 ## 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) var shade := lerpf(0.72, 1.15, (heights[center] - hmin) / maxf(hmax - hmin, 0.0001)) var expected := Color(0.78, 0.70, 0.45) * shade # Sand × Höhen-Shading 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) 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] var grass_expected := Color(0.30, 0.45, 0.22) * 0.72 # flach: t=0 → shade=SHADING_LOW 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-Farben bleiben unterscheidbar NACH dem Shading (gleiche # Höhe/Neigung → nur die Palette trennt): 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(_col_maxdiff(g, s) > 0.06 and _col_maxdiff(g, f) > 0.06 and _col_maxdiff(s, f) > 0.06, "Material: Gras/Sand/Fels nach Shading weiterhin unterscheidbar") # Alpha-Kanal: Shading darf Alpha NICHT skalieren (t_603ec1a8-Falle). var alpha_ok := tal.a == 1.0 and huegel.a == 1.0 and hell.a == 1.0 and dunkel.a == 1.0 _check(alpha_ok, "Alpha: bleibt 1.0 (Shading nur auf RGB)") # 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())