Editor: Klick wählt das exakt getroffene Objekt (Mauer statt Nachbar-Gebäude) + Malen-Hinweis statt Stärke-Regler

- _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) <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-10-24 23:27:48 +02:00
parent 1d8763b881
commit 66df3bdc1e
5 changed files with 205 additions and 4 deletions

View file

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

View file

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

View file

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

83
tests/test_klick_exakt.gd Normal file
View file

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

26
tests/test_mal_hinweis.gd Normal file
View file

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