From e1c737df61cd9e3f91ad65b0104c1a1c60a58b3d Mon Sep 17 00:00:00 2001 From: "Hermes (godot-vr)" Date: Sat, 17 Oct 2026 17:51:07 +0000 Subject: [PATCH] =?UTF-8?q?Terrain:=20Material-Farben=20bleiben=20LIVE=20w?= =?UTF-8?q?=C3=A4hrend=20Heben/Senk-Stroke=20(t=5F55ed041a)=20=E2=80=94=20?= =?UTF-8?q?Live-Rebuild=20bekommt=20die=20Material-Schicht=20mit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sly-Befund: Beim Heben/Senken über gemalten Sand/Fels verschwanden die Material-Farben live (alles Gras), erst am Stroke-Ende kehrten sie mit dem Vollrebuild zurück. Root-Cause: terrain_tool rief im Live-Pfad (Heben/Senken/Glätten) update_terrain(_heights) OHNE Material-Schicht auf — _mats wurde nur im Malen-Modus dekodiert — und terrain_mesh fällt bei leerer Schicht auf alles-Gras zurück (Legacy-Fallback). Fix (zwei Ebenen): - terrain_tool._refresh_from_doc dekodiert die Material-Schicht immer, der Live-Stroke übergibt sie mit (Heben/Senken/Glätten wie Malen). - terrain_mesh cached die zuletzt gesehene Schicht: Live-Updates ohne mats-Param nutzen denselben Farb-Pfad wie der Idle-Rebuild; Legacy-v1 (nie gesehene Schicht) bleibt alles Gras — kein Phantom-Cache. Test: tests/test_terrain.gd neue Gruppe „Live-Material-Farben" — per-Frame-Check (5 Frames Heben über Sand-Zone, nicht nur Endzustand), terrain_tool-Integration (Heben-Stroke übergibt Sand-Schicht, Stroke-Ende = EIN Commit), Legacy-Gras ohne Phantom-Cache. Suite: 99 checks ALL OK; alle weiteren Headless-Suiten grün (test_glb_view 12/22 FAIL ist vorbestehend auf origin/main — GLB-Quellen fehlen im Workspace). --- src/editor/terrain_tool.gd | 14 +++- src/viewport/map_view_builder.gd | 3 +- src/viewport/terrain_mesh.gd | 17 ++++- tests/test_terrain.gd | 126 +++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 5 deletions(-) diff --git a/src/editor/terrain_tool.gd b/src/editor/terrain_tool.gd index c931290..142c110 100644 --- a/src/editor/terrain_tool.gd +++ b/src/editor/terrain_tool.gd @@ -38,7 +38,9 @@ var _stroking := false var _stroke_mode: int = TerrainField.Mode.RAISE var _stroke_material: int = 0 var _heights := PackedFloat32Array() -var _mats := PackedByteArray() # Material-Schicht (res²) während des Mal-Strichs +var _mats := PackedByteArray() # Material-Schicht (res²) — seit _refresh_ + # _from_doc immer dekodiert (t_55ed041a), + # Mal-Strich schreibt live hinein. var _res := 0 var _sx := 0.0 var _sz := 0.0 @@ -116,6 +118,10 @@ func _refresh_from_doc() -> bool: if _res < 2 or _sx <= 0.0 or _sz <= 0.0 or _hmax <= _hmin: return false _heights = SvmapIO.decode_heights(terrain) + # Material-Schicht mit dekodieren (Karte t_55ed041a): der Live-Rebuild + # beim Heben/Senken/Glätten braucht sie, sonst fällt das Mesh während + # des Strichs auf alles-Gras zurück (Sand/Fels „verschwinden" live). + _mats = SvmapIO.decode_materials(terrain) return _heights.size() >= _res * _res @@ -248,7 +254,11 @@ func _process(delta: float) -> void: _cursor.x, _cursor.z, brush_radius, _stroke_mode, amount) if changed: _expand_range_if_needed() - view_builder.call("update_terrain", _heights) + # Karte t_55ed041a: Material-Schicht MITGEBEN — ohne sie hat der + # Live-Rebuild alles als Gras gerendert (Sand/Fels „verschwanden" + # während des Heben/Senk-Strichs und kehrten erst am Stroke-Ende + # mit dem Vollrebuild zurück). + view_builder.call("update_terrain", _heights, _mats) _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index fee3cfa..fbc7911 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -98,7 +98,8 @@ func _build_terrain(data: Dictionary) -> void: ## Live-Update während eines Pinsel-Strichs (aus terrain_tool.gd): nur die ## Vertices werden neu geschrieben — Grid/Objekte/Marker bleiben unangetastet. ## mats (optional) = Material-Schicht (Karte t_603ec1a8, Malen-Modus) — -## leer = Farben nur aus der Höhe (Legacy). +## leer = terrain_mesh verwendet die zuletzt gesehene Schicht weiter (Cache, +## Karte t_55ed041a); nur bei NIE gesehener Schicht (Legacy-v1) gilt Gras. func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray()) -> void: if terrain_view != null and is_instance_valid(terrain_view): terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats) diff --git a/src/viewport/terrain_mesh.gd b/src/viewport/terrain_mesh.gd index ea93fba..0677b7c 100644 --- a/src/viewport/terrain_mesh.gd +++ b/src/viewport/terrain_mesh.gd @@ -27,6 +27,9 @@ var _mi: MeshInstance3D var _res := 0 var _size_x := 0.0 var _size_z := 0.0 +var _mats_cache := PackedByteArray() # zuletzt gesehene Material-Schicht + # (res²) — Live-Pfad-Fallback, Karte + # t_55ed041a. var _triangles := 0 @@ -61,8 +64,11 @@ func rebuild_from_doc(data: Dictionary) -> void: ## Mesh aus einem (neuen) Höhenfeld bauen — auch live während des Strichs. ## res/size 0 = zuletzt verwendete Werte weiterverwenden (Live-Pfad). -## mats = Material-Schicht (res² Bytes, 0=Gras/1=Sand/2=Fels); leer/zu klein -## → alles Gras (Legacy-Verhalten). +## mats = Material-Schicht (res² Bytes, 0=Gras/1=Sand/2=Fels); leer → die +## zuletzt gesehene Schicht (Cache) wird weiterverwendet, damit Live-Updates +## ohne explizite Schicht (z. B. Heben/Senken-Stroke) die Material-Farben +## BEHALTEN statt live auf alles-Gras zurückzufallen (Karte t_55ed041a). +## Nur wenn NIE eine Schicht gesehen wurde (Legacy-v1-Map) gilt alles Gras. func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0.0, size_z: float = 0.0, mats: PackedByteArray = PackedByteArray()) -> void: if res < 2: @@ -78,6 +84,13 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0 _res = res _size_x = size_x _size_z = size_z + if mats.is_empty() and _mats_cache.size() == res * res: + mats = _mats_cache + elif mats.size() == res * res: + _mats_cache = mats + elif not mats.is_empty(): + _mats_cache = mats # Größe passt nicht (res-Wechsel) → trotzdem + # merken; der Index-Clamp unten schützt. var last := float(res - 1) var positions := PackedVector3Array() var normals := PackedVector3Array() diff --git a/tests/test_terrain.gd b/tests/test_terrain.gd index 31be61a..cef85a9 100644 --- a/tests/test_terrain.gd +++ b/tests/test_terrain.gd @@ -49,6 +49,7 @@ func _run_tests() -> void: _test_paint_undo_redo() _test_material_format() _test_mesh_winding_live_grid() + _test_live_material_colors() _test_grid_toggle() # _test_ray_terrain_mapping läuft in _finish() — braucht Viewport im Baum. @@ -359,6 +360,131 @@ func _has_err(errors: PackedStringArray, needle: String) -> bool: 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),