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.
This commit is contained in:
Hermes (godot-vr) 2026-10-16 17:14:54 +00:00
parent adb585d30e
commit b7b160d3bc
4 changed files with 178 additions and 3 deletions

View file

@ -43,6 +43,8 @@ var library_box: VBoxContainer
var library_buttons: Dictionary = {} # id → Button
var undo_btn: Button
var redo_btn: Button
var grid_btn: Button # „⊞ Gitter“ (Karte t_7ac70014)
var _grid_on := false # Nutzer-Wahl für das Maßstabsgitter
# Auswahl-Panel
var sel_label: Label
@ -185,6 +187,18 @@ func _build_ui() -> void:
redo_btn.pressed.connect(_do_redo)
top_h.add_child(redo_btn)
# Maßstabsgitter-Toggle (Karte t_7ac70014): über sichtbarem Terrain ist das
# Gitter standardmäßig AUS (das Terrain ist die Bodendarstellung); wer das
# 100-m-Raster als Planungshilfe will, schaltet es hier ohne Rebuild zu.
grid_btn = Button.new()
grid_btn.text = "⊞ Gitter"
grid_btn.toggle_mode = true
grid_btn.button_pressed = false
grid_btn.custom_minimum_size = Vector2(120, 40)
grid_btn.tooltip_text = "Maßstabsgitter (100-m-Raster) im Viewport ein-/ausblenden. Über sichtbarem Terrain ist es standardmäßig aus."
grid_btn.toggled.connect(_on_grid_toggled)
top_h.add_child(grid_btn)
# Map-Fertig-Check (Karte t_c4162c5d): Klartext-Liste VOR dem Export.
check_btn = Button.new()
check_btn.text = "✅ Karte prüfen"
@ -1654,6 +1668,7 @@ func _on_weather_changed(index: int) -> void:
_refresh_file_label()
# Licht/Stimmung im Viewport sofort anpassen
view_builder.call("rebuild", doc, manifest)
_sync_grid_to_builder()
_set_status("Wetter geändert auf „%s“." % weathers[index])
@ -1669,6 +1684,7 @@ func _after_structure_change() -> void:
if interaction != null:
interaction.call("clear_selection", true)
view_builder.call("rebuild", doc, manifest)
_sync_grid_to_builder()
_refresh_tree()
_recheck_terrain_mode()
if tool_mode == "spawns" and spawn_tool != null:
@ -1709,9 +1725,28 @@ func _refresh_all() -> void:
func _refresh_viewport() -> void:
view_builder.call("rebuild", doc, manifest)
_sync_grid_to_builder()
camera.call("frame_map", float(doc.get("terrain", {}).get("size_x_m", 1024.0)))
# --- Maßstabsgitter (Karte t_7ac70014) -------------------------------------
func _on_grid_toggled(pressed: bool) -> void:
_grid_on = pressed
_sync_grid_to_builder()
## Nach jedem Viewport-Rebuild: Nutzer-Wahl (bzw. Boden-Referenz ohne Terrain-
## Block) wieder anwenden — der Rebuild setzt das Gitter auf seinen Default.
func _sync_grid_to_builder() -> void:
if view_builder == null:
return
var no_terrain: bool = doc.get("terrain", {}).is_empty()
view_builder.call("set_grid_visible", _grid_on or no_terrain)
if grid_btn != null and grid_btn.button_pressed != _grid_on:
grid_btn.set_pressed_no_signal(_grid_on)
func _refresh_tree() -> void:
content_tree.clear()
var root := content_tree.create_item()

View file

@ -27,6 +27,7 @@ var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc
var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius)
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
var gebaeude_markers: Array = [] # MeshInstance3D je Bauteil (Karte t_97ea59c5)
var grid_node: MeshInstance3D # Maßstabsgitter (Toggle „⊞ Gitter“, Karte t_7ac70014)
var _gebaeude_baked: Dictionary = {} # faces/offsets für Ray-Pick (Bauen-Werkzeug)
var _gebaeude_sel: MeshInstance3D # orangener AABB-Rahmen um das gewählte Teil
@ -47,6 +48,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
object_markers = []
spawn_markers = []
gebaeude_markers = []
grid_node = null
_gebaeude_baked = {}
_gebaeude_sel = null
_doc_ref = data
@ -71,6 +73,12 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
_build_environment(weather)
_build_terrain(data)
_build_grid(size_x, size_z, ground_y)
# Gitter default AUS über sichtbarem Terrain (Karte t_7ac70014): das
# Terrain ist die Bodendarstellung — das Gitter lag als Linien-Overlay
# DARÜBER und wirkte wie ein „unfertiges“ Editor-Raster. Ohne Terrain-
# Block bleibt es als Boden-Referenz sichtbar.
if grid_node != null:
grid_node.visible = terrain.is_empty()
_build_objects(data.get("objects", []), manifest)
_rebuild_spawn_markers(data)
_build_gebaeude(data)
@ -155,9 +163,20 @@ func _build_grid(size_x: float, size_z: float, ground_y: float) -> void:
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.vertex_color_use_as_albedo = true
mi.material_override = mat
grid_node = mi
add_child(mi)
## Gitter ohne Rebuild zu-/abschalten (Toggle in der Top-Bar, Karte t_7ac70014).
func set_grid_visible(visible_now: bool) -> void:
if grid_node != null and is_instance_valid(grid_node):
grid_node.visible = visible_now
func grid_is_visible() -> bool:
return grid_node != null and is_instance_valid(grid_node) and grid_node.visible
func _grid_step(size_m: float) -> float:
# Ziel: ~10–20 Linien pro Achse, runde Werte.
var raw := size_m / 16.0

View file

@ -31,6 +31,12 @@ var _triangles := 0
func _ready() -> void:
_ensure_mesh_instance()
func _ensure_mesh_instance() -> void:
if _mi != null and is_instance_valid(_mi):
return
_mi = MeshInstance3D.new()
var mat := StandardMaterial3D.new()
mat.vertex_color_use_as_albedo = true
@ -65,7 +71,9 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0
size_x = _size_x
if size_z <= 0.0:
size_z = _size_z
if _mi == null or res < 2 or heights.size() < res * res or size_x <= 0.0 or size_z <= 0.0:
if _mi == null:
_ensure_mesh_instance()
if res < 2 or heights.size() < res * res or size_x <= 0.0 or size_z <= 0.0:
return
_res = res
_size_x = size_x
@ -100,11 +108,17 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0
heights[idx],
-z / last * size_z + size_z * 0.5)
# Normale aus zentralen Differenzen (Rand: einseitig geklemmt).
# x-Index läuft GEGEN die Welt-X-Achse (Spiegelung in positions) —
# die Gradienten-Terme hängen deshalb an hx_r/hx_l bzw. hz_u/hz_d:
# n ∝ (-dh/dWeltX, 1, -dh/dWeltZ), skaliert mit 2·cell_x·cell_z.
var hx_l := heights[z * res + maxi(x - 1, 0)]
var hx_r := heights[z * res + mini(x + 1, res - 1)]
var hz_d := heights[maxi(z - 1, 0) * res + x]
var hz_u := heights[mini(z + 1, res - 1) * res + x]
normals[idx] = Vector3(hx_l - hx_r, 2.0 * cell_x, hz_d - hz_u).normalized()
normals[idx] = Vector3(
(hx_r - hx_l) * cell_z,
2.0 * cell_x * cell_z,
(hz_u - hz_d) * cell_x).normalized()
var t := (heights[idx] - hmin) / span
# Material-Farbe × Höhen-Faktor (Steigungen bleiben ablesbar).
var mat_id := 0
@ -120,7 +134,11 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0
var x := 0
while x < res - 1:
var i := z * res + x
indices.append_array([i, i + res, i + 1, i + 1, i + res, i + res + 1])
# Winding-Order: Godot betrachtet Clockwise (von der Vorderseite
# betrachtet) als Vorderseite. Von oben gelesen muss jedes Dreieck
# IM Uhrzeigersinn laufen, sonst cullt der Renderer die komplette
# Deckfläche als Backface (Karte t_7ac70014: „dunkle Platte“).
indices.append_array([i, i + 1, i + res, i + 1, i + res + 1, i + res])
x += 1
z += 1

View file

@ -39,6 +39,8 @@ func _run_tests() -> void:
_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).
@ -347,6 +349,107 @@ func _has_err(errors: PackedStringArray, needle: String) -> bool:
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