Terrain: Material-Farben bleiben LIVE während Heben/Senk-Stroke (t_55ed041a) — Live-Rebuild bekommt die Material-Schicht mit

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).
This commit is contained in:
Hermes (godot-vr) 2026-10-17 17:51:07 +00:00
parent db6bcec971
commit e1c737df61
4 changed files with 155 additions and 5 deletions

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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),