From b7b160d3bc0779a15cd245b3b1ad74f9be10f139 Mon Sep 17 00:00:00 2001 From: "Hermes (godot-vr)" Date: Fri, 16 Oct 2026 17:14:54 +0000 Subject: [PATCH] =?UTF-8?q?Terrain-Darstellung=20(t=5F7ac70014):=20Winding?= =?UTF-8?q?-Order=20geflippt=20=E2=80=94=20Terrain=20bei=20h=3D0=20sofort?= =?UTF-8?q?=20als=20Rasen-Platte=20sichtbar.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/ui/main.gd | 35 +++++++++++ src/viewport/map_view_builder.gd | 19 ++++++ src/viewport/terrain_mesh.gd | 24 ++++++- tests/test_terrain.gd | 103 +++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/src/ui/main.gd b/src/ui/main.gd index 5f4cfd9..696f4cd 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -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() diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index f2f31ed..23bf5b3 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -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 diff --git a/src/viewport/terrain_mesh.gd b/src/viewport/terrain_mesh.gd index 7532343..d3bea40 100644 --- a/src/viewport/terrain_mesh.gd +++ b/src/viewport/terrain_mesh.gd @@ -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 diff --git a/tests/test_terrain.gd b/tests/test_terrain.gd index eada979..ffdd0bb 100644 --- a/tests/test_terrain.gd +++ b/tests/test_terrain.gd @@ -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