From 66df3bdc1e6cece55e4fa4f01b1eb15ffc5b5600 Mon Sep 17 00:00:00 2001 From: slyphax Date: Sat, 24 Oct 2026 23:27:48 +0200 Subject: [PATCH] =?UTF-8?q?Editor:=20Klick=20w=C3=A4hlt=20das=20exakt=20ge?= =?UTF-8?q?troffene=20Objekt=20(Mauer=20statt=20Nachbar-Geb=C3=A4ude)=20+?= =?UTF-8?q?=20Malen-Hinweis=20statt=20St=C3=A4rke-Regler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _pick_object: Kugel-Näherung nur noch Vorfilter, entschieden wird über den echten Mesh-Treffer (SelectionMath.ray_mesh_distance, Faces gecacht). Ohne Mesh-Treffer bleibt die alte Kugel-Wahl als Fallback. - Terrain-Panel: im Malen-Modus ist der Stärke-Regler weg (Malen setzt das Material zellenweise, Stärke hatte nie eine Wirkung); stattdessen steht die feinste Mal-Einheit der Karte da (size / (resolution - 1)). - Tests: test_klick_exakt 6/6 (reproduziert Gebäude-Kugel vor Mauer), test_mal_hinweis 4/4. Vorhandene Suiten unverändert (4 schon vorher rote Umgebungs-Tests: assets_panel, identitaet_client, model_sources, playtest_identitaet). Ersetzt die nie gepushten Arbeitskopie-Änderungen der Karten t_12feca66 / t_c487a430 / t_271a77bd (Material-IDs per lerp gemischt, Sub-Zellen ohne Client/Server-Unterstützung). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/editor/interaction.gd | 19 ++++++-- src/editor/selection_math.gd | 50 ++++++++++++++++++++ src/ui/panels/terrain_panel.gd | 31 ++++++++++++- tests/test_klick_exakt.gd | 83 ++++++++++++++++++++++++++++++++++ tests/test_mal_hinweis.gd | 26 +++++++++++ 5 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 tests/test_klick_exakt.gd create mode 100644 tests/test_mal_hinweis.gd diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 15476c2..d50579d 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -166,8 +166,12 @@ func _terrain_half() -> Vector2: return Vector2(float(terrain.get("size_x_m", 1024.0)), float(terrain.get("size_z_m", 1024.0))) * 0.5 -## Cursor-Strahl → getroffenes Objekt (Kugel-Approximation der Marker). -## Rückgabe: Index in doc.objects oder -1. +## Cursor-Strahl → getroffenes Objekt. Rückgabe: Index in doc.objects oder -1. +## Zwei Stufen (Mauer-Klick-Fix 24.10.): Die Kugel-Näherung der Marker ist nur +## noch Vorfilter — entschieden wird über den EXAKTEN Treffer auf den echten +## Mesh-Dreiecken (nächstes getroffenes Objekt gewinnt). Nur wenn der Strahl +## kein Mesh trifft, zählt wie bisher die nächste Kugel (kleine Objekte und +## Platzhalter bleiben leicht anklickbar). func _pick_object(viewport_pos: Vector2) -> int: var origin := camera.project_ray_origin(viewport_pos) var dir := camera.project_ray_normal(viewport_pos) @@ -175,6 +179,8 @@ func _pick_object(viewport_pos: Vector2) -> int: var ground_t := INF if ground != null: ground_t = (ground as Vector3).distance_to(origin) + var exact := -1 + var exact_t := INF var best := -1 var best_t := INF for marker in view_builder.object_markers: @@ -193,7 +199,14 @@ func _pick_object(viewport_pos: Vector2) -> int: if t < best_t and t < ground_t + 0.01: best = int(marker.get_meta("obj_index", -1)) best_t = t - return best + var mi := SelectionMath.marker_mesh(marker) + if mi != null and mi.mesh != null: + var mt := SelectionMath.ray_mesh_distance(mi.global_transform, + SelectionMath.mesh_faces(mi.mesh), origin, dir) + if mt >= 0.0 and mt < exact_t and mt < ground_t + 0.01: + exact = int(marker.get_meta("obj_index", -1)) + exact_t = mt + return exact if exact >= 0 else best # ------------------------------------------------------------ Platzieren ---- diff --git a/src/editor/selection_math.gd b/src/editor/selection_math.gd index 4fdde39..3a74548 100644 --- a/src/editor/selection_math.gd +++ b/src/editor/selection_math.gd @@ -71,3 +71,53 @@ static func focus_center(markers: Array, indices: Array) -> Vector3: if count == 0: return Vector3.ZERO return sum / float(count) + + +# ------------------------------------------------- Exakter Klick-Treffer --- + +## Faces-Cache pro Mesh (Klick-Pick, Karte Mauer-Klick 24.10.): get_faces() +## kopiert jedes Mal das ganze Dreiecks-Array — einmal pro Mesh reicht. +static var _faces_cache: Dictionary = {} + + +## Strahl gegen die ECHTEN Dreiecke eines Meshes (Möller–Trumbore über +## BuildOps._ray_tri). xform = globale Transform der MeshInstance (inkl. +## Objekt-Drehung/-Kippung, Platzhalter-Lift und Node-Scale). +## Rückgabe: Welt-Abstand vom Strahl-Ursprung zum nächsten Treffer, -1 = keiner. +## WARUM: Die Kugel-Näherung (pick_radius = halbe AABB-Diagonale) eines großen +## Gebäudes ragt weit über seine Wände hinaus — ein Klick auf die schmale +## Mauer daneben traf zuerst die Gebäude-Kugel (Sly-Befund 22.10.). +static func ray_mesh_distance(xform: Transform3D, faces: PackedVector3Array, + origin: Vector3, dir: Vector3) -> float: + if faces.size() < 3: + return -1.0 + var inv := xform.affine_inverse() + var lo: Vector3 = inv * origin + var ld: Vector3 = inv.basis * dir + var best := INF + for i in int(faces.size() / 3): + var t := BuildOps._ray_tri(lo, ld, faces[i * 3], faces[i * 3 + 1], faces[i * 3 + 2]) + if t >= 0.0 and t < best: + best = t + if best == INF: + return -1.0 + return origin.distance_to(xform * (lo + ld * best)) + + +## Faces eines Meshes (gecacht). Leeres Array, wenn kein Mesh. +static func mesh_faces(mesh: Mesh) -> PackedVector3Array: + if mesh == null: + return PackedVector3Array() + var key := mesh.get_rid() + if not _faces_cache.has(key): + _faces_cache[key] = mesh.get_faces() + return _faces_cache[key] + + +## Objekt-Mesh eines Markers: das Modell ist laut +## map_view_builder._build_objects IMMER das erste MeshInstance3D-Kind. +static func marker_mesh(marker: Node3D) -> MeshInstance3D: + for child in marker.get_children(): + if child is MeshInstance3D: + return child as MeshInstance3D + return null diff --git a/src/ui/panels/terrain_panel.gd b/src/ui/panels/terrain_panel.gd index c0991a3..464ee16 100644 --- a/src/ui/panels/terrain_panel.gd +++ b/src/ui/panels/terrain_panel.gd @@ -20,6 +20,7 @@ var terrain_radius_slider: HSlider var terrain_radius_label: Label var terrain_strength_slider: HSlider var terrain_strength_label: Label +var terrain_paint_hint: Label # Malen: Hinweis auf die Raster-Feinheit (24.10.) # --- Bauen (Karte t_97ea59c5) — im selben Werkzeug-Tab (aus main.gd ausgezogen) var build_edit_btn: Button @@ -150,6 +151,14 @@ func build() -> void: terrain_strength_slider.value_changed.connect(_on_terrain_strength_changed) terrain_panel.add_child(terrain_strength_slider) + # Malen kennt keine Stärke: das Material wird zellenweise gesetzt + # (Material-Schicht = ein Wert pro Rasterpunkt, weiche Übergänge macht + # der Splat-Renderer). Statt eines wirkungslosen Reglers steht im + # Malen-Modus die echte Feinheit da (Sly-Befund 22.10.: „Stärke ändert nichts“). + terrain_paint_hint = _muted_label("") + terrain_paint_hint.visible = false + terrain_panel.add_child(terrain_paint_hint) + terrain_panel.add_child(_muted_label( "Linksklick-Ziehen formt bzw. malt (je nach Werkzeug), Strg+Linksklick macht das Gegenteil — im Malen-Modus malt Strg+Klick Gras zurück. " + "Jeder Strich ist EIN Rückgängig-Schritt (Strg+Z). Rechte Maustaste = Flug, ESC beendet den Terrain-Modus.")) @@ -235,12 +244,32 @@ func _selected_terrain_mode() -> int: ## Mode-Buttons (ButtonGroup) → Pinsel-Modus; Palette nur im Malen-Modus. func _on_terrain_mode_changed(_pressed: bool) -> void: + var paint := _selected_terrain_mode() == TerrainField.Mode.PAINT if terrain_palette != null: - terrain_palette.visible = _selected_terrain_mode() == TerrainField.Mode.PAINT + terrain_palette.visible = paint + if terrain_strength_slider != null: + terrain_strength_slider.visible = not paint + terrain_strength_label.visible = not paint + if terrain_paint_hint != null: + terrain_paint_hint.visible = paint + terrain_paint_hint.text = paint_hint_text(main.doc.get("terrain", {}) if main.doc is Dictionary else {}) if main.terrain_tool != null: main.terrain_tool.call("set_brush_mode", _selected_terrain_mode()) +## Hinweistext Malen-Modus (pure, testbar): feinste Mal-Einheit = Abstand +## zweier Rasterpunkte der Karte (size / (resolution − 1)). +static func paint_hint_text(terrain: Dictionary) -> String: + var res := int(terrain.get("resolution", 0)) + var size := maxf(float(terrain.get("size_x_m", 0.0)), float(terrain.get("size_z_m", 0.0))) + if res < 2 or size <= 0.0: + return "Malen setzt das Material zellenweise — Stärke gibt es hier nicht." + var m := size / float(res - 1) + var m_text := str(int(roundf(m))) if absf(m - roundf(m)) < 0.05 \ + else String.num(m, 1).replace(".", ",") + return "Malen setzt das Material zellenweise: feinste Einheit auf dieser Karte ≈ %s m. Stärke gibt es hier nicht — kleiner geht's nur mit einem feineren Kartenraster." % m_text + + ## Material-Palette (Karte t_603ec1a8): gewähltes Material an den Pinsel. func _on_terrain_material_changed(pressed: bool, mat_id: int) -> void: if pressed and main.terrain_tool != null: diff --git a/tests/test_klick_exakt.gd b/tests/test_klick_exakt.gd new file mode 100644 index 0000000..067b438 --- /dev/null +++ b/tests/test_klick_exakt.gd @@ -0,0 +1,83 @@ +extends SceneTree +## Editor-Tests (headless): exakter Klick-Treffer (Mauer-Klick-Fix 24.10.). +## Ausführen: +## godot --headless --path . -s res://tests/test_klick_exakt.gd +## Exit 0 = alles grün. +## +## K1 ray_mesh_distance trifft eine Box im richtigen Abstand, verfehlt sie +## daneben, respektiert Drehung + Scale der Transform. +## K2 Sly-Szenario: großes Gebäude (20×10×20) direkt neben schmaler Mauer +## (6×2×0,4). Der Strahl trifft die Mauer, liegt aber auch IN der +## Gebäude-Kugel und tritt dort früher ein → Kugel allein wählt das +## Gebäude (alter Fehler), der exakte Test wählt die Mauer. + +var failures := 0 +var checks := 0 + + +func _initialize() -> void: + _run_all.call_deferred() + + +func _check(cond: bool, label: String) -> void: + checks += 1 + print("%s: %s" % ["ok " if cond else "FAIL", label]) + if not cond: + failures += 1 + + +func _box_faces(size: Vector3) -> PackedVector3Array: + var m := BoxMesh.new() + m.size = size + return m.get_faces() + + +func _run_all() -> void: + # K1: Grundfälle + var faces := _box_faces(Vector3(2, 2, 2)) + var xf := Transform3D(Basis(), Vector3(0, 1, 0)) + var d := SelectionMath.ray_mesh_distance(xf, faces, Vector3(0, 1, 10), Vector3(0, 0, -1)) + _check(absf(d - 9.0) < 0.001, "K1 Box frontal: Abstand 9 m (ist %.3f)" % d) + d = SelectionMath.ray_mesh_distance(xf, faces, Vector3(3, 1, 10), Vector3(0, 0, -1)) + _check(d < 0.0, "K1 daneben: kein Treffer") + var xf_rot := Transform3D(Basis(Vector3.UP, deg_to_rad(45.0)), Vector3(0, 1, 0)) + d = SelectionMath.ray_mesh_distance(xf_rot, faces, Vector3(1.2, 1, 10), Vector3(0, 0, -1)) + _check(d > 0.0, "K1 gedreht 45°: Ecke ragt bis x≈1,41 → Treffer bei x=1,2") + var xf_scale := Transform3D(Basis().scaled(Vector3.ONE * 3.0), Vector3(0, 1, 0)) + d = SelectionMath.ray_mesh_distance(xf_scale, faces, Vector3(0, 1, 10), Vector3(0, 0, -1)) + _check(absf(d - 7.0) < 0.001, "K1 Scale 3: Abstand in Welt-Metern 7 (ist %.3f)" % d) + + # K2: Gebäude neben Mauer + var building_size := Vector3(20, 10, 20) + var wall_size := Vector3(6, 2, 0.4) + var building_xf := Transform3D(Basis(), Vector3(0, 5, 0)) + var wall_xf := Transform3D(Basis(), Vector3(0, 1, 12)) # Mauer 2 m vor der Gebäudefront (z = 10) + var origin := Vector3(0, 1.5, 40) + var dir := Vector3(0, 0, -1) + # Kugel-Näherung wie map_view_builder: halbe Diagonale + max(0.6, half.y·0.25) + var bh := building_size * 0.5 + var wh := wall_size * 0.5 + var b_rad := bh.length() + maxf(0.6, bh.y * 0.25) + var w_rad := wh.length() + maxf(0.6, wh.y * 0.25) + var b_center := Vector3(0, 5, 0) + var w_center := Vector3(0, 1, 12) + var b_sphere := _sphere_t(origin, dir, b_center, b_rad) + var w_sphere := _sphere_t(origin, dir, w_center, w_rad) + _check(b_sphere >= 0.0 and b_sphere < w_sphere, + "K2 Ausgangslage: Gebäude-Kugel wird VOR der Mauer-Kugel getroffen (alter Fehler reproduziert)") + var b_exact := SelectionMath.ray_mesh_distance(building_xf, _box_faces(building_size), origin, dir) + var w_exact := SelectionMath.ray_mesh_distance(wall_xf, _box_faces(wall_size), origin, dir) + _check(w_exact > 0.0 and (b_exact < 0.0 or w_exact < b_exact), + "K2 exakt: Mauer (%.2f m) vor Gebäude (%.2f m) → Mauer wird gewählt" % [w_exact, b_exact]) + + print("test_klick_exakt: %d/%d ok" % [checks - failures, checks]) + quit(1 if failures > 0 else 0) + + +func _sphere_t(origin: Vector3, dir: Vector3, center: Vector3, radius: float) -> float: + var to_c := center - origin + var tca := to_c.dot(dir) + var d2 := to_c.length_squared() - tca * tca + if tca <= 0.0 or d2 > radius * radius: + return -1.0 + return tca - sqrt(radius * radius - d2) diff --git a/tests/test_mal_hinweis.gd b/tests/test_mal_hinweis.gd new file mode 100644 index 0000000..b9a7d17 --- /dev/null +++ b/tests/test_mal_hinweis.gd @@ -0,0 +1,26 @@ +extends SceneTree +## Editor-Tests (headless): Malen-Hinweis statt wirkungslosem Stärke-Regler (24.10.). +## godot --headless --path . -s res://tests/test_mal_hinweis.gd + +var failures := 0 +var checks := 0 + + +func _initialize() -> void: + var panel = load("res://src/ui/panels/terrain_panel.gd") + _check(panel != null, "terrain_panel.gd lädt ohne Parse-Fehler") + var t: String = panel.paint_hint_text({"resolution": 129, "size_x_m": 256.0, "size_z_m": 256.0}) + _check(t.contains("2 m"), "256 m / 129 Punkte → feinste Einheit 2 m (%s)" % t) + t = panel.paint_hint_text({"resolution": 129, "size_x_m": 1024.0, "size_z_m": 512.0}) + _check(t.contains("8 m"), "1024 m / 129 Punkte → 8 m (größte Kante zählt)") + t = panel.paint_hint_text({}) + _check(t.contains("Stärke gibt es hier nicht"), "ohne Terrain: neutraler Hinweis") + print("test_mal_hinweis: %d/%d ok" % [checks - failures, checks]) + quit(1 if failures > 0 else 0) + + +func _check(cond: bool, label: String) -> void: + checks += 1 + print("%s: %s" % ["ok " if cond else "FAIL", label]) + if not cond: + failures += 1