scopedvr-map-editor/tests/test_shortcuts.gd
Hermes (godot-vr) 08a75ac430 Shortcuts 2/3 (t_55257792): R-Drehen + Pfeil-Raster + PgUp/Down + Strg+Bild↑ + Greifen + Terrain-Pinsel-Tasten.
- R = Auswahl 90° drehen (Szene um Auswahl-Mitte, Bauen-Bauteil um Fußpunkt, Gruppe komplett; Umschalt+R = -90°), Mausrad-Feindrehen 15° bleibt
- Pfeiltasten = 2-m-Raster-Verschiebung, Richtung = Kamera-Blickrichtung auf nächste Weltachse gerastet (MoveMath.nudge_delta, pure)
- Bild↑/↓ = ±0,25 m auf der GANZEN Auswahl (EIN Undo), Strg+Bild↑ = Fußpunkt aufs Terrain/Boden absetzen
- G = Greifen (Blender-Semantik): Auswahl hängt an der Maus, LMB setzt mit Raster-Snap, Esc rollt byte-genau zurück (ShortcutPolicy cancel_grab)
- Terrain-Pinsel: [ / ] = Preset-Zyklus Klein/Mittel/Groß (Slider/Label/Preset-Buttons wandern mit), 1-4 = Heben/Senken/Glätten/Malen (PAINT-Clamp-Fix, Palette mit Malen sichtbar); Ziffern NUR im Terrain-Werkzeug
- Strg halten = temporärer Snap-Zwang beim Bewegen/Greifen/Platzieren
- Tests: test_shortcuts.gd 157 Checks (R byte-genau, Pfeil-Raster, Lift-Stufen, Boden-Snap, Greifen+Esc, Pinsel-Zyklus, History-Eintrag pro Operation), test_gui_smoke.gd erweitert (38 Checks) und in run_tests.sh aufgenommen; F-Regression seit Karte 1 damit abgedeckt
- Suite 9/9 grün (696 Checks), Builder-Export 21/0, Parse-Check 11 Dateien grün
2026-10-16 17:53:10 +00:00

739 lines
No EOL
34 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends SceneTree
## Editor-Tests (headless): Karte t_c46d31c2 — Tastatur-Shortcuts 1/3
## (Freelook-Flug, Focus, Esc, Entf-Mehrfachauswahl, Strg+D, Strg+LMB).
## Ausführen: godot --headless --path . --script res://tests/test_shortcuts.gd
## Exit 0 = alles grün. Reine Mathe/Policy/Objekt-Tests + ECHTE interaction.gd
## gegen Doppel-Stub (Muster aus test_library.gd — kein UI nötig).
var failures := 0
var checks := 0
func _init() -> void:
ShortcutKeys.register()
_test_inputmap_actions()
_test_fly_math()
_test_esc_policy()
_test_esc_grab_policy()
_test_duplicate_offset()
_test_move_math()
# Node-basierte Tests (global_position, look_at) brauchen einen fertigen
# Baum — in _init ist root noch nicht "inside tree" (Gui-Smoke-Muster).
await process_frame
_test_multi_delete()
_test_focus_center()
_test_camera_fly_roundtrip()
_test_rotate_90()
_test_nudge_grid()
_test_lift_multi()
_test_snap_to_floor()
_test_grab()
_test_build_nudge_floor()
_test_brush_paint_mode()
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
# --------------------------------------------------- Stubs (main / builder) --
## Minimales main-Doppel: interaction braucht doc/history/manifest + Status-
## und Sync-Aufrufe. Zählt Commits mit, damit „EIN Undo-Schritt" beweisbar ist.
class ProbeMain:
var doc: Dictionary = {}
var manifest: Dictionary = {}
var grid_snap := false
var dirty := false
var history: ActionHistory = ActionHistory.new()
var statuses: Array = []
func object_label(_oid: String) -> String:
return "Objekt"
func _fmt_int(v: int) -> String:
return str(v)
func _set_status(text: String, _err := false) -> void:
statuses.append(text)
func _selection_status(_idx: int) -> void:
pass
func _sync_selection_panel() -> void:
pass
func _mark_tree_selection(_idx: int) -> void:
pass
func _mark_tree_selection_multi(_indices: Array) -> void:
pass
func _after_structure_change() -> void:
pass
func _refresh_history_buttons() -> void:
pass
func _refresh_stats() -> void:
pass
func _refresh_file_label() -> void:
pass
## Builder-Doppel: interaction setzt nur set_selected_multi / liest Marker.
class ProbeBuilder extends Node3D:
var object_markers: Array = []
var last_selection: Array = []
var last_gebaeude_selection := -99
func set_selected_multi(indices: Array) -> void:
last_selection = indices.duplicate()
func set_gebaeude_selected(idx: int) -> void:
last_gebaeude_selection = idx
# ------------------------------------------------------------ InputMap -------
func _test_inputmap_actions() -> void:
print("Testgruppe: InputMap-Actions (ShortcutKeys)")
# Idempotenz: doppeltes Registrieren darf keine Doppel-Events erzeugen.
ShortcutKeys.register()
var fwd_count := 0
for ev in InputMap.action_get_events("editor_fly_forward"):
fwd_count += 1
_check(fwd_count == 1, "register() ist idempotent (keine Doppel-Events)")
var expected_keys := {
"editor_fly_forward": KEY_W,
"editor_fly_back": KEY_S,
"editor_fly_left": KEY_A,
"editor_fly_right": KEY_D,
"editor_fly_up": KEY_E,
"editor_fly_down": KEY_Q,
"editor_fly_fast": KEY_SHIFT,
"editor_fly_slow": KEY_ALT,
"editor_focus_selection": KEY_F,
"editor_cancel": KEY_ESCAPE,
"editor_delete_selection": KEY_DELETE,
}
for action in expected_keys:
if not InputMap.has_action(action):
_check(false, "Action existiert: %s" % action)
continue
var ok := false
for ev in InputMap.action_get_events(action):
if ev is InputEventKey and (ev as InputEventKey).physical_keycode == int(expected_keys[action]):
ok = true
_check(ok, "%s → physische Taste %s" % [action, OS.get_keycode_string(int(expected_keys[action]))])
# Modifier-Kombos
var toggle_shift := false
for ev in InputMap.action_get_events("editor_freelook_toggle"):
if ev is InputEventKey and (ev as InputEventKey).physical_keycode == KEY_F \
and (ev as InputEventKey).shift_pressed:
toggle_shift = true
_check(toggle_shift, "editor_freelook_toggle → Umschalt+F")
var dup_ctrl := false
for ev in InputMap.action_get_events("editor_duplicate_selection"):
if ev is InputEventKey and (ev as InputEventKey).physical_keycode == KEY_D \
and (ev as InputEventKey).ctrl_pressed:
dup_ctrl = true
_check(dup_ctrl, "editor_duplicate_selection → Strg+D")
var multi := false
for ev in InputMap.action_get_events("editor_multi_select"):
if ev is InputEventMouseButton and (ev as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT \
and (ev as InputEventMouseButton).ctrl_pressed:
multi = true
_check(multi, "editor_multi_select → Strg+LMB")
var freelook_rmb := false
for ev in InputMap.action_get_events("editor_freelook"):
if ev is InputEventMouseButton and (ev as InputEventMouseButton).button_index == MOUSE_BUTTON_RIGHT:
freelook_rmb = true
_check(freelook_rmb, "editor_freelook → Rechte Maustaste")
# Karte 2/3 (t_55257792): neue Actions mit physischen Tasten.
var expected_keys_2 := {
"editor_rotate_90": KEY_R,
"editor_nudge_up": KEY_UP,
"editor_nudge_down": KEY_DOWN,
"editor_nudge_left": KEY_LEFT,
"editor_nudge_right": KEY_RIGHT,
"editor_grab": KEY_G,
"editor_brush_smaller": KEY_BRACKETLEFT,
"editor_brush_larger": KEY_BRACKETRIGHT,
"editor_brush_raise": KEY_1,
"editor_brush_lower": KEY_2,
"editor_brush_smooth": KEY_3,
"editor_brush_paint": KEY_4,
}
for action in expected_keys_2:
if not InputMap.has_action(action):
_check(false, "Action existiert: %s" % action)
continue
var ok := false
for ev in InputMap.action_get_events(action):
if ev is InputEventKey and (ev as InputEventKey).physical_keycode == int(expected_keys_2[action]):
ok = true
_check(ok, "%s → physische Taste %s" % [action, OS.get_keycode_string(int(expected_keys_2[action]))])
var lift_floor := false
for ev in InputMap.action_get_events("editor_lift_to_floor"):
if ev is InputEventKey and (ev as InputEventKey).physical_keycode == KEY_PAGEUP \
and (ev as InputEventKey).ctrl_pressed:
lift_floor = true
_check(lift_floor, "editor_lift_to_floor → Strg+Bild↑")
# ------------------------------------------------------------ FlyMath --------
func _test_fly_math() -> void:
print("Testgruppe: Freelook-Mathe (FlyMath)")
_check(absf(FlyMath.speed_for(30.0, false, false) - 30.0) < 0.0001, "Tempo normal = Grund")
_check(absf(FlyMath.speed_for(30.0, true, false) - 90.0) < 0.0001, "Umschalt = ×3")
_check(absf(FlyMath.speed_for(30.0, false, true) - 9.0) < 0.0001, "Alt = ×0,3")
_check(absf(FlyMath.speed_for(30.0, true, true) - 90.0) < 0.0001, "Umschalt dominiert bei beiden")
_check(absf(FlyMath.wheel_speed(30.0, true) - 34.5) < 0.0001, "Mausrad hoch: ×1,15 dauerhaft")
_check(absf(FlyMath.wheel_speed(30.0, false) - 30.0 / 1.15) < 0.0001, "Mausrad runter: ÷1,15 dauerhaft")
_check(FlyMath.wheel_speed(299.0, true) == FlyMath.SPEED_MAX_MPS, "Mausrad: Tempo-Grenze oben (300 m/s)")
_check(FlyMath.wheel_speed(2.0, false) == FlyMath.SPEED_MIN_MPS, "Mausrad: Tempo-Grenze unten (2 m/s)")
# Bewegung: W fliegt in Blickrichtung (3D), A/D horizontal, E/Q weltachsig.
var fwd := FlyMath.forward_from_euler(0.0, 30.0) # Blick nach -Z, leicht abwärts
var right := FlyMath.forward_from_euler(0.0, 0.0).cross(Vector3.UP).normalized()
var d := FlyMath.move_delta(fwd, right, 1.0, 0.0, 0.0, 10.0, 1.0)
_check(d.length() < 10.0001 and absf(d.y) > 0.1, "W fliegt 3D in Blickrichtung (mit Pitch-Anteil)")
var side := FlyMath.move_delta(fwd, right, 0.0, 1.0, 0.0, 10.0, 1.0)
_check(absf(side.y) < 0.0001 and absf(side.x - 10.0) < 0.0001, "D straft horizontal rechts (kein Höhen-Anteil)")
var up := FlyMath.move_delta(fwd, right, 0.0, 0.0, 1.0, 10.0, 1.0)
_check(absf((up - Vector3.UP * 10.0).length()) < 0.0001, "E hebt weltachsig (+10 m)")
var diag := FlyMath.move_delta(fwd, right, 1.0, 1.0, 0.0, 10.0, 1.0)
_check(absf(diag.length() - 10.0) < 0.0001, "Diagonale normalisiert (√2-Falle umgangen)")
_check(FlyMath.move_delta(fwd, right, 0.0, 0.0, 0.0, 10.0, 1.0) == Vector3.ZERO, "keine Eingabe → kein Delta")
# Euler ↔ Forward-Rundtrip (Orbit-Konvention: pitch + = von oben).
var ep := FlyMath.euler_from_forward(FlyMath.forward_from_euler(45.0, 35.0))
_check(absf(ep.x - 45.0) < 0.0001 and absf(ep.y - 35.0) < 0.0001, "Euler↔Forward-Rundtrip (45°/35°)")
var down := FlyMath.euler_from_forward(Vector3(0.0, -1.0, 0.0))
_check(absf(down.y - 90.0) < 0.0001, "Blick senkrecht nach unten → pitch +90 (Orbit-Clamp übernimmt)")
var flat := FlyMath.forward_from_euler(0.0, 0.0)
_check(absf((flat - Vector3(0.0, 0.0, -1.0)).length()) < 0.0001, "yaw 0/pitch 0 → Blick -Z")
# ------------------------------------------------------------ Esc-Policy -----
func _test_esc_policy() -> void:
print("Testgruppe: Esc-Zustandswechsel (ShortcutPolicy)")
_check(ShortcutPolicy.esc_action("terrain", false, false) == "exit_terrain", "Esc im Terrain-Werkzeug → Werkzeug verlassen")
_check(ShortcutPolicy.esc_action("spawns", false, false) == "exit_spawns", "Esc im Spawn-Werkzeug → Werkzeug verlassen")
_check(ShortcutPolicy.esc_action("bauen", false, false) == "exit_bauen", "Esc im Bauen-Modus → Werkzeug verlassen")
_check(ShortcutPolicy.esc_action("bauen", true, true) == "exit_bauen", "Werkzeug hat VORRANG vor Platzieren")
_check(ShortcutPolicy.esc_action("objects", true, true) == "cancel_placement", "Esc beim Platzieren → Ghost-Preview abbrechen")
_check(ShortcutPolicy.esc_action("objects", false, true) == "clear_selection", "Esc mit Auswahl → Auswahl aufheben")
_check(ShortcutPolicy.esc_action("objects", false, false) == "none", "Esc ohne nichts → kein Zustandswechsel")
# ------------------------------------------------------ Duplizieren (+2 m) ---
func _test_duplicate_offset() -> void:
print("Testgruppe: Strg+D — Duplizieren +2 m X, Füßpunkt unverändert")
var doc := {"objects": [
{"id": "crate_wood", "pos": [10.0, 5.0, 10.0], "scale": 1.0, "rot_y_deg": 30.0},
{"id": "tree_pine", "pos": [30.0, 3.0, 20.0], "scale": 1.0, "rot_y_deg": 0.0},
]}
var hist := ActionHistory.new()
hist.begin(doc)
var before := hist.snapshot(["objects"])
var ni: Array = ObjectOps.duplicate_objects(doc, [0, 1], false)
_check(ni == [2, 3], "Mehrfach-Duplizieren: neue Indizes in Quell-Reihenfolge")
var objects: Array = doc["objects"]
_check(float(objects[2]["pos"][0]) == 12.0 and float(objects[2]["pos"][2]) == 10.0,
"Kopie 1: +2 m in X, Z unverändert")
_check(float(objects[2]["pos"][1]) == 5.0, "Kopie 1: Füßpunkt-Y unverändert")
_check(absf(float(objects[2]["rot_y_deg"]) - 30.0) < 0.0001, "Kopie 1: Drehung übernommen")
_check(float(objects[3]["pos"][0]) == 32.0 and float(objects[3]["pos"][1]) == 3.0,
"Kopie 2: +2 m in X, Höhe unverändert")
# Raster-Snap: 11,6 rastet auf 12,2 m? Nein: auf ganze Meter → 14 (11.6+2=13.6→14).
objects[0]["pos"] = [11.6, 2.0, 9.2]
var idx := ObjectOps.duplicate_object(doc, 0, true)
_check(float(objects[idx]["pos"][0]) == 14.0 and float(objects[idx]["pos"][2]) == 9.0,
"Raster-Snap an: Kopie rastet auf ganze Meter (nach +2 m)")
# EIN Undo-Schritt für ALLE Kopien:
hist.commit("3 Objekte duplizieren", before, hist.snapshot(["objects"]))
hist.undo()
_check((doc["objects"] as Array).size() == 2, "Strg+Z: ALLE Kopien auf einmal weg")
hist.redo()
_check((doc["objects"] as Array).size() == 5, "Strg+Y: ALLE Kopien auf einmal zurück")
# ---------------------------------------------------- Del-Mehrfachauswahl ----
func _test_multi_delete() -> void:
print("Testgruppe: Entf auf Mehrfachauswahl (echte interaction.gd)")
var fake := ProbeMain.new()
fake.doc = {"objects": [
{"id": "crate_wood", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "tree_pine", "pos": [10.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "crate_wood", "pos": [20.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "tree_pine", "pos": [30.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0},
]}
fake.history.begin(fake.doc)
var vp := SubViewport.new()
vp.size = Vector2i(1280, 720)
root.add_child(vp)
var cam := Camera3D.new()
vp.add_child(cam)
cam.current = true
var builder := ProbeBuilder.new()
vp.add_child(builder)
var interaction := Node3D.new()
interaction.set_script(load("res://src/editor/interaction.gd"))
vp.add_child(interaction)
interaction.setup(fake, cam, builder)
# Strg+Klick-Mehrfachauswahl-Semantik (select_toggle = add/remove):
interaction.select_object(0)
interaction.select_toggle(2)
_check(interaction.get("selected_indices") == [0, 2], "Umschalt/Strg+Klick: Objekt zur Auswahl hinzugefügt")
interaction.select_toggle(2)
_check(interaction.get("selected_indices") == [0], "Umschalt/Strg+Klick: wieder entfernt")
interaction.select_toggle(2)
_check(interaction.get("selected_indices") == [0, 2], "Mehrfachauswahl [0, 2] steht")
# Entf: beide gelöscht, EIN Undo-Schritt holt beide zurück.
interaction.delete_selected()
var objects: Array = fake.doc["objects"]
_check(objects.size() == 2, "Entf: beide ausgewählten Objekte weg (2 bleiben)")
_check(str(objects[0]["id"]) == "tree_pine" and str(objects[1]["id"]) == "tree_pine",
"Entf: die RICHTIGEN Objekte (A und C) sind weg")
_check(interaction.get("selected_indices").is_empty(), "Entf: Auswahl geleert")
_check(builder.last_selection.is_empty(), "Entf: auch Viewport-Markierung geleert")
_check(fake.history.can_undo(), "Entf: genau EIN History-Eintrag")
fake.history.undo()
_check((fake.doc["objects"] as Array).size() == 4, "Strg+Z: beide Objekte auf einmal zurück")
# Entf ohne Auswahl: hilfreiche Meldung, kein Crash, keine History-Änderung.
var can_undo_before := fake.history.can_undo()
var status_before: int = fake.statuses.size()
interaction.clear_selection(true)
interaction.delete_selected()
_check(fake.statuses.size() > status_before, "Entf ohne Auswahl: hilfreiche Meldung statt Stille")
_check(fake.history.can_undo() == can_undo_before, "Entf ohne Auswahl: nichts committet")
# Mausrad-Block im Flugmodus: wheel_blocked meldet Kamera-Vorrang.
var orbit := Camera3D.new()
orbit.set_script(load("res://src/viewport/orbit_camera.gd"))
vp.add_child(orbit)
interaction.set("camera", orbit)
_check(not bool(interaction.call("wheel_blocked")), "Orbit + Auswahl: Mausrad dreht Objekt (normal)")
orbit.set("_flying", true)
_check(bool(interaction.call("wheel_blocked")), "Freelook-Flug: Mausrad gehört dem Flug-Tempo")
# ------------------------------------------------------------ Focus (F) ------
func _test_focus_center() -> void:
print("Testgruppe: F — Focus-Selection-Mittelpunkt (SelectionMath)")
var m1 := Node3D.new()
m1.position = Vector3(10.0, 0.0, 0.0)
m1.set_meta("half_height", 2.0)
var m2 := Node3D.new()
m2.position = Vector3(30.0, 4.0, 20.0)
m2.set_meta("half_height", 2.0)
root.add_child(m1)
root.add_child(m2)
var center := SelectionMath.focus_center([m1, m2], [0, 1])
# Anker + halbe Mesh-Höhe: m1 → (10, 1, 0), m2 → (30, 5, 20); Mittel = (20, 3, 10).
_check((center - Vector3(20.0, 3.0, 10.0)).length() < 0.0001,
"Mittelpunkt = Mittel der Anker (+ halbe Mesh-Höhe)")
_check(SelectionMath.focus_center([m1, m2], [5, -1]) == Vector3.ZERO,
"ungültige Indizes → Ursprung (Guard sitzt beim Aufrufer)")
_check(SelectionMath.focus_center([null, m2], [0, 1]) == (m2.global_position \
+ Vector3.UP * 1.0), "kaputter Marker wird übersprungen")
# ------------------------------------------------- Kamera Flug-Roundtrip -----
func _test_camera_fly_roundtrip() -> void:
print("Testgruppe: Freelook-Flug an/aus (orbit_camera, kein Positions-Sprung)")
var cam := Camera3D.new()
cam.set_script(load("res://src/viewport/orbit_camera.gd"))
root.add_child(cam)
cam.set("target", Vector3.ZERO)
cam.set("distance", 100.0)
cam.set("yaw_deg", 30.0)
cam.set("pitch_deg", 40.0)
cam.call("_apply")
var pos_before: Vector3 = cam.position
_check(not bool(cam.call("fly_active")), "Orbit-Modus ist Default (kein Flug)")
cam.call("_enter_fly")
_check(bool(cam.call("fly_active")), "RMT gedrückt → Flugmodus aktiv")
cam.position += Vector3(25.0, -10.0, 5.0) # im Flug frei bewegt
cam.call("_exit_fly")
_check(not bool(cam.call("fly_active")), "RMT losgelassen → zurück zum Orbit")
var pos_after: Vector3 = cam.position
# Orbit-Ziel wurde aus der Flugposition zurückgerechnet — die KAMERA bleibt,
# nur Ziel/Winkel ändern sich: kein Teleport beim Umschalten.
_check((pos_after - (pos_before + Vector3(25.0, -10.0, 5.0))).length() < 0.01,
"Flug-Exit: Position bleibt (kein Sprung), Ziel wird zurückgerechnet")
# Focus in Orbit-Mode: Ziel wandert auf den Mittelpunkt.
cam.call("focus_on", Vector3(50.0, 2.0, -20.0))
_check((cam.get("target") as Vector3 - Vector3(50.0, 2.0, -20.0)).length() < 0.0001,
"focus_on: Orbit-Ziel = Auswahl-Mittelpunkt")
# Umschalt+F-Verriegelung: Dauerflug an → aus.
cam.call("toggle_fly_lock")
_check(bool(cam.call("fly_active")), "Umschalt+F: Dauerflug an (ohne RMT)")
cam.call("toggle_fly_lock")
_check(not bool(cam.call("fly_active")), "Umschalt+F nochmal: zurück zum Orbit")
# Blick-Yaw-Accessor (Karte t_55257792): Orbit → yaw_deg, Flug → Flug-Yaw.
_check(absf(float(cam.call("view_yaw_deg")) - 30.0) < 0.0001,
"view_yaw_deg im Orbit = Orbit-Yaw (30°)")
cam.set("_fly_yaw", -120.0)
cam.set("_flying", true)
_check(absf(float(cam.call("view_yaw_deg")) + 120.0) < 0.0001,
"view_yaw_deg im Flug = Flug-Yaw")
# --------------------------------------------------------- Esc + Greifen -----
func _test_esc_grab_policy() -> void:
print("Testgruppe: Esc-Priorität mit Greifen (ShortcutPolicy)")
_check(ShortcutPolicy.esc_action("objects", false, false, true) == "cancel_grab",
"Esc beim Greifen → Greifen zurückrollen")
_check(ShortcutPolicy.esc_action("objects", false, true, true) == "cancel_grab",
"Greifen hat VORRANG vor Auswahl-Aufheben")
_check(ShortcutPolicy.esc_action("objects", true, true, true) == "cancel_grab",
"Greifen hat VORRANG vor Platzieren (kann nicht koexistieren)")
_check(ShortcutPolicy.esc_action("terrain", false, false, true) == "exit_terrain",
"Werkzeug-Exit bleibt VOR Greifen (im Terrain gibt es kein Greifen)")
_check(ShortcutPolicy.esc_action("objects", true, true, false) == "cancel_placement",
"alter 3-Argument-Aufruf bleibt unverändert (Rückwärtskompatibel)")
# ------------------------------------------------------ MoveMath (Karte 2) ----
func _test_move_math() -> void:
print("Testgruppe: Pfeil-Raster + Pinsel-Presets (MoveMath)")
# Blick -Z (yaw 0): ↑ = -Z, ↓ = +Z, rechts = +X, links = -X.
_check(MoveMath.nudge_delta(0.0, 1, 0) == Vector3(0, 0, -2), "yaw 0: ↑ = 2 m nach -Z")
_check(MoveMath.nudge_delta(0.0, -1, 0) == Vector3(0, 0, 2), "yaw 0: ↓ = 2 m nach +Z")
_check(MoveMath.nudge_delta(0.0, 0, 1) == Vector3(2, 0, 0), "yaw 0: → = 2 m nach +X")
_check(MoveMath.nudge_delta(0.0, 0, -1) == Vector3(-2, 0, 0), "yaw 0: ← = 2 m nach -X")
# Blick -X (yaw 90): ↑ = -X, rechts = -Z (rechts vom Blick).
_check(MoveMath.nudge_delta(90.0, 1, 0) == Vector3(-2, 0, 0), "yaw 90: ↑ = 2 m nach -X")
_check(MoveMath.nudge_delta(90.0, 0, 1) == Vector3(0, 0, -2), "yaw 90: → = 2 m nach -Z")
# Blick +Z (yaw 180): ↑ = +Z.
_check(MoveMath.nudge_delta(180.0, 1, 0) == Vector3(0, 0, 2), "yaw 180: ↑ = 2 m nach +Z")
# Blick +X (yaw -90): ↑ = +X, → = +Z.
_check(MoveMath.nudge_delta(-90.0, 1, 0) == Vector3(2, 0, 0), "yaw -90: ↑ = 2 m nach +X")
_check(MoveMath.nudge_delta(-90.0, 0, 1) == Vector3(0, 0, 2), "yaw -90: → = 2 m nach +Z")
# Schräger Blick rastet auf die NÄCHSTE Weltachse (Editor-Konvention):
# yaw 20 → Blick überwiegend -Z → ↑ = -Z; yaw 70 → überwiegend -X → ↑ = -X.
_check(MoveMath.nudge_delta(20.0, 1, 0) == Vector3(0, 0, -2), "yaw 20 (schräg): ↑ rastet auf -Z")
_check(MoveMath.nudge_delta(70.0, 1, 0) == Vector3(-2, 0, 0), "yaw 70 (schräg): ↑ rastet auf -X")
# Pinsel-Presets: Zyklus Klein(4) → Mittel(12) → Groß(32) → Klein ...
_check(MoveMath.brush_next_preset(4.0, true) == 12.0, "[ ]-Zyklus: Klein → Mittel")
_check(MoveMath.brush_next_preset(12.0, true) == 32.0, "[ ]-Zyklus: Mittel → Groß")
_check(MoveMath.brush_next_preset(32.0, true) == 4.0, "[ ]-Zyklus: Groß → Klein (wrap)")
_check(MoveMath.brush_next_preset(32.0, false) == 12.0, "[ ]-Zyklus zurück: Groß → Mittel")
_check(MoveMath.brush_next_preset(4.0, false) == 32.0, "[ ]-Zyklus zurück: Klein → Groß (wrap)")
# Freier Slider-Wert: nächstes Preset ÜBER bzw. UNTER dem Wert.
_check(MoveMath.brush_next_preset(20.0, true) == 32.0, "freier Wert 20: ] → nächstes Preset darüber (32)")
_check(MoveMath.brush_next_preset(20.0, false) == 12.0, "freier Wert 20: [ → nächstes Preset darunter (12)")
# --------------------------------------------------------- R = 90°-Drehen ----
func _make_interaction(doc: Dictionary, markers: int) -> Array:
var fake := ProbeMain.new()
fake.doc = doc
fake.history.begin(fake.doc)
var vp := SubViewport.new()
vp.size = Vector2i(1280, 720)
root.add_child(vp)
var cam := Camera3D.new()
vp.add_child(cam)
cam.current = true
var builder := ProbeBuilder.new()
vp.add_child(builder)
for i in markers:
var m := Node3D.new()
m.set_meta("half_height", 1.0)
builder.object_markers.append(m)
var interaction := Node3D.new()
interaction.set_script(load("res://src/editor/interaction.gd"))
vp.add_child(interaction)
interaction.setup(fake, cam, builder)
return [fake, interaction, builder]
func _test_rotate_90() -> void:
print("Testgruppe: R = Auswahl 90° drehen (byte-genau)")
var doc := {"objects": [
{"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 30.0},
{"id": "tree_pine", "pos": [20.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "crate_wood", "pos": [10.0, 0.0, 20.0], "scale": 1.0, "rot_y_deg": 0.0},
]}
var pair := _make_interaction(doc, 3)
var fake: ProbeMain = pair[0]
var interaction = pair[1]
# Einzelnes Objekt: R dreht rot_y_deg exakt +90, Position bleibt (Drehung
# um die eigene Mitte = Fußpunkt bleibt an Ort und Stelle).
interaction.select_object(0)
interaction.rotate_selected(90.0)
var objects: Array = doc["objects"]
_check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001, "R: 30° → 120° (exakt +90)")
_check(float(objects[0]["pos"][0]) == 10.0 and float(objects[0]["pos"][2]) == 10.0,
"R: Einzelobjekt bleibt auf seinem Fußpunkt stehen")
_check(float(objects[0]["pos"][1]) == 0.0, "R: Füßpunkt-Y byte-genau unverändert")
_check(fake.history.can_undo(), "R: EIN History-Eintrag pro Drehung")
# Mehrfachauswahl: beide drehen um die GEMEINSAME Mitte — beide rotieren
# +90, die Mitte bleibt (byte-genau: gleicher Code-Pfad wie Mausrad).
interaction.select_many([0, 2])
var c_before := GroupOps.centroid(doc, [0, 2])
interaction.rotate_selected(90.0)
var c_after := GroupOps.centroid(doc, [0, 2])
_check(absf(float(objects[0]["rot_y_deg"]) - 210.0) < 0.0001
and absf(float(objects[2]["rot_y_deg"]) - 90.0) < 0.0001,
"R bei Mehrfachauswahl: beide Objekte +90°")
_check((c_after - c_before).length() < 0.0001,
"R: Auswahl-Mittelpunkt bleibt exakt (Drehung um die Mitte)")
# EIN History-Eintrag pro Operation: das erste Undo nimmt nur die
# Mehrfach-Drehung zurück (210°→120°, Fußpunkte wieder am alten Ort),
# das zweite die Einzel-Drehung davor (120°→30°).
fake.history.undo()
_check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001
and absf(float(objects[2]["rot_y_deg"])) < 0.0001
and float(objects[0]["pos"][0]) == 10.0 and float(objects[0]["pos"][2]) == 10.0
and float(objects[2]["pos"][0]) == 10.0 and float(objects[2]["pos"][2]) == 20.0,
"Strg+Z: Mehrfach-Drehung komplett zurück (Rotation + Fußpunkte byte-genau)")
fake.history.undo()
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001,
"Strg+Z: zweite Ebene — Einzel-Drehung auch zurück (30°)")
# Bauen-Modus: Bauteil rotiert um seinen Fußpunkt (Position unverändert).
var build_doc := {"gebaeude": [
{"id": "wand", "pos": [4.0, 0.0, 6.0], "rot": 0.0, "punkte": [[0, 0, 0], [2, 0, 0]]},
]}
var bfake := ProbeMain.new()
bfake.doc = build_doc
bfake.history.begin(bfake.doc)
var bbuilder := ProbeBuilder.new()
root.add_child(bbuilder)
var btool := Node3D.new()
btool.set_script(load("res://src/editor/build_tool.gd"))
root.add_child(btool)
btool.setup(bfake, Camera3D.new(), bbuilder, interaction)
btool.select_index(0)
btool.rotate_input()
var list: Array = BuildOps.pieces(build_doc)
_check(absf(float(list[0]["rot"]) - 90.0) < 0.0001, "Bauen: R dreht Bauteil 90°")
_check(float(list[0]["pos"][0]) == 4.0 and float(list[0]["pos"][2]) == 6.0,
"Bauen: Fußpunkt bleibt (Drehung um den Fußpunkt)")
# ---------------------------------------------------- Pfeiltasten 2-m-Raster --
func _test_nudge_grid() -> void:
print("Testgruppe: Pfeiltasten — 2-m-Raster, Höhe wandert mit, EIN Undo pro Druck")
var doc := {"objects": [
{"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "tree_pine", "pos": [10.0, 3.0, 12.0], "scale": 1.0, "rot_y_deg": 0.0},
]}
var pair := _make_interaction(doc, 2)
var fake: ProbeMain = pair[0]
var interaction = pair[1]
var objects: Array = doc["objects"]
interaction.select_object(0)
interaction.nudge_selection(Vector3(0, 0, -2))
_check(float(objects[0]["pos"][0]) == 10.0 and float(objects[0]["pos"][2]) == 8.0,
"↑: exakt 2 m in -Z, Fußpunkt bleibt im Raster")
_check(fake.history.can_undo(), "Pfeiltaste: EIN History-Eintrag")
fake.history.undo()
_check(float(objects[0]["pos"][2]) == 10.0, "Strg+Z: eine Zelle zurück")
# Höhe wandert mit (t_7567a06f-Semantik): angehobenes Objekt (y=3 über
# flachem Terrain) bleibt exakt auf 3 m.
interaction.select_object(1)
interaction.nudge_selection(Vector3(2, 0, 0))
_check(float(objects[1]["pos"][1]) == 3.0, "Pfeiltaste: Terrain-Abstand bleibt (3 m über flachem Boden)")
_check(float(objects[1]["pos"][0]) == 12.0, "Pfeiltaste: 2 m in +X")
# Mehrfachauswahl wandert gemeinsam, relative Anordnung exakt erhalten.
interaction.select_many([0, 1])
var d0: Array = [float(objects[0]["pos"][0]) - float(objects[1]["pos"][0]),
float(objects[0]["pos"][2]) - float(objects[1]["pos"][2])]
interaction.nudge_selection(Vector3(-2, 0, 0))
var d1: Array = [float(objects[0]["pos"][0]) - float(objects[1]["pos"][0]),
float(objects[0]["pos"][2]) - float(objects[1]["pos"][2])]
_check(absf(d0[0] - d1[0]) < 0.0001 and absf(d0[1] - d1[1]) < 0.0001,
"Mehrfachauswahl: relative Anordnung byte-genau erhalten")
# Map-Rand: außerhalb klemmt — komplett geklemmter Druck committet NICHT.
var small := {"terrain": {"size_x_m": 100.0, "size_z_m": 100.0, "resolution": 0},
"objects": [{"id": "crate_wood", "pos": [50.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}]}
var pair2 := _make_interaction(small, 1)
var fake2: ProbeMain = pair2[0]
var interaction2 = pair2[1]
var sobjects: Array = small["objects"]
interaction2.select_object(0)
var had_undo: bool = fake2.history.can_undo()
interaction2.nudge_selection(Vector3(2, 0, 0))
_check(float(sobjects[0]["pos"][0]) == 50.0 and fake2.history.can_undo() == had_undo,
"Map-Rand: komplett geklemmte Zelle → keine Bewegung, KEIN History-Eintrag")
# ------------------------------------------- Bild↑/↓ Mehrfach + Boden-Snap ----
func _test_lift_multi() -> void:
print("Testgruppe: Bild↑/↓ auf der GANZEN Auswahl (EIN Undo-Schritt)")
var doc := {"objects": [
{"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "tree_pine", "pos": [20.0, 0.5, 20.0], "scale": 1.0, "rot_y_deg": 0.0},
]}
var pair := _make_interaction(doc, 2)
var interaction = pair[1]
var objects: Array = doc["objects"]
interaction.select_many([0, 1])
interaction.lift_selected(ObjectOps.LIFT_STEP_M)
_check(absf(float(objects[0]["pos"][1]) - 0.25) < 0.0001,
"Bild↑: Objekt 1 +0,25 m")
_check(absf(float(objects[1]["pos"][1]) - 0.75) < 0.0001,
"Bild↑: Objekt 2 +0,25 m (0,5 → 0,75)")
var marker_y: float = (pair[2].object_markers[0] as Node3D).position.y
_check(absf(marker_y - 0.25) < 0.0001, "Bild↑: Marker wandert mit")
interaction.lift_selected(-ObjectOps.LIFT_STEP_M)
_check(absf(float(objects[0]["pos"][1])) < 0.0001, "Bild↓: wieder −0,25 m")
# Objekt am Boden: weiter absenken geht nicht — kein Commit, Meldung kommt.
var can_undo: bool = (pair[0].history as ActionHistory).can_undo()
interaction.lift_selected(-ObjectOps.LIFT_STEP_M)
_check(absf(float(objects[0]["pos"][1])) < 0.0001, "Bild↓ am Boden: y bleibt 0")
_check((pair[0].history as ActionHistory).can_undo() == can_undo,
"Bild↓ am Boden: nichts committet (Snapshot verworfen)")
func _test_snap_to_floor() -> void:
print("Testgruppe: Strg+Bild↑ — Auswahl auf den Boden setzen")
var doc := {"objects": [
{"id": "crate_wood", "pos": [10.0, 3.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "tree_pine", "pos": [20.0, 0.0, 20.0], "scale": 1.0, "rot_y_deg": 0.0},
]}
var pair := _make_interaction(doc, 2)
var fake: ProbeMain = pair[0]
var interaction = pair[1]
var objects: Array = doc["objects"]
interaction.select_many([0, 1])
interaction.snap_selection_to_floor()
_check(float(objects[0]["pos"][1]) == 0.0, "Strg+Bild↑: schwebendes Objekt auf Terrain-Höhe (0 m)")
_check(float(objects[0]["pos"][0]) == 10.0 and float(objects[0]["pos"][2]) == 10.0,
"Strg+Bild↑: X/Z bleiben unangetastet")
_check(fake.history.can_undo(), "Strg+Bild↑: EIN History-Eintrag")
# Zweiter Druck: alles steht schon am Boden → keine Aktion, kein Eintrag.
var can_undo: bool = fake.history.can_undo()
interaction.snap_selection_to_floor()
_check(fake.history.can_undo() == can_undo, "Strg+Bild↑ am Boden: kein leerer Commit")
# --------------------------------------------------------------- Greifen (G) --
func _test_grab() -> void:
print("Testgruppe: G — Greifen mit Esc-Rückabwicklung (byte-genau)")
var doc := {"objects": [
{"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "tree_pine", "pos": [12.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0},
]}
var pair := _make_interaction(doc, 2)
var fake: ProbeMain = pair[0]
var interaction = pair[1]
var builder: ProbeBuilder = pair[2]
var objects: Array = doc["objects"]
fake.grid_snap = true
interaction.select_many([0, 1])
var ok: bool = interaction.grab_begin()
_check(ok, "G mit Auswahl: Greifen startet")
# Linksklick-Setzen: Primär (letzter Auswahl-Index = 1) rastet auf ganze
# Meter, das andere Auswahl-Objekt wandert exakt mit demselben Delta mit.
interaction.grab_move(Vector3(20.3, 0.0, 8.6))
interaction.grab_confirm()
_check(float(objects[1]["pos"][0]) == 20.0 and float(objects[1]["pos"][2]) == 9.0,
"Greifen + LMB: Primär rastet aufs Raster (20, 9)")
_check(float(objects[0]["pos"][0]) == 18.0 and float(objects[0]["pos"][2]) == 9.0,
"Greifen: zweites Auswahl-Objekt wandert exakt mit (+8, −1)")
_check(float(objects[1]["pos"][1]) == 0.0, "Greifen: Füßpunkt auf Terrain-Höhe")
_check(fake.history.can_undo(), "Greifen: EIN History-Eintrag für den ganzen Zug")
fake.history.undo()
_check(float(objects[0]["pos"][0]) == 10.0 and float(objects[1]["pos"][0]) == 12.0,
"Strg+Z: Greifen-Zug komplett zurück")
# Esc-Rückabwicklung: Positionen byte-identisch zum Zustand vor dem Greifen.
interaction.grab_begin()
interaction.grab_move(Vector3(30.7, 0.0, 1.2))
_check(float(objects[1]["pos"][0]) == 31.0, "Greifen: live am Cursor (rastet)")
var can_undo: bool = fake.history.can_undo()
interaction.grab_cancel()
_check(float(objects[0]["pos"][0]) == 10.0 and float(objects[0]["pos"][1]) == 0.0
and float(objects[0]["pos"][2]) == 10.0,
"Esc: exakte Rückabwicklung auf die Ausgangsposition (byte-identisch)")
_check(float(objects[1]["pos"][0]) == 12.0 and float(objects[1]["pos"][2]) == 10.0,
"Esc: auch das zweite Objekt zurück")
_check(fake.history.can_undo() == can_undo, "Esc: KEIN History-Eintrag beim Abbruch")
_check(not bool(interaction.get("_grabbing")), "Esc: Greifen-Zustand beendet")
# Marker sind nach dem Abbruch wieder synchron (Viewport = Doc).
_check(absf((builder.object_markers[0] as Node3D).position.x - 10.0) < 0.0001,
"Esc: Viewport-Marker zurückgesetzt")
# G ohne Auswahl: hilfreiche Meldung, kein Crash.
interaction.clear_selection(true)
_check(not interaction.grab_begin(), "G ohne Auswahl: Greifen startet nicht")
_check(fake.statuses.size() > 0, "G ohne Auswahl: hilfreiche Meldung statt Stille")
# ------------------------------------------- Bauen: Pfeile + Strg+Bild↑ -------
func _test_build_nudge_floor() -> void:
print("Testgruppe: Bauen-Modus — Pfeiltasten (2-m-Raster) + Strg+Bild↑")
var doc := {"gebaeude": [
{"id": "wand", "pos": [4.0, 0.0, 6.0], "rot": 0.0, "punkte": [[0, 0, 0], [2, 0, 0]]},
]}
var bfake := ProbeMain.new()
bfake.doc = doc
bfake.history.begin(bfake.doc)
var bbuilder := ProbeBuilder.new()
root.add_child(bbuilder)
var btool := Node3D.new()
btool.set_script(load("res://src/editor/build_tool.gd"))
root.add_child(btool)
btool.setup(bfake, Camera3D.new(), bbuilder, null)
btool.select_index(0)
btool.nudge_selected(Vector3(0, 0, -2))
var list: Array = BuildOps.pieces(doc)
_check(float(list[0]["pos"][2]) == 4.0 and float(list[0]["pos"][0]) == 4.0,
"Bauen-Pfeil: exakt 2 m, Raster-Ausrichtung bleibt")
_check(bfake.history.can_undo(), "Bauen-Pfeil: EIN History-Eintrag")
bfake.history.undo()
_check(float(list[0]["pos"][2]) == 6.0, "Bauen-Pfeil: Strg+Z zurück")
# Strg+Bild↑: Bauteil auf Terrain-Höhe (flaches Terrain → 0 m, 5-cm-Raster).
list[0]["pos"] = [4.0, 2.5, 6.0]
btool.snap_selected_to_floor()
_check(absf(float(list[0]["pos"][1])) < 0.0001, "Bauen Strg+Bild↑: Bauteil auf dem Boden (0 m)")
# Erneut: liegt schon am Boden → Meldung, kein Commit.
var can_undo: bool = bfake.history.can_undo()
btool.snap_selected_to_floor()
_check(bfake.history.can_undo() == can_undo, "Bauen Strg+Bild↑ am Boden: kein leerer Commit")
# ------------------------------------------ Terrain-Ziffer 4 = Malen (Fix) ----
func _test_brush_paint_mode() -> void:
print("Testgruppe: Terrain-Pinsel — Modus 3 (Malen) kommt durch set_brush_mode")
var tool := Node3D.new()
tool.set_script(load("res://src/editor/terrain_tool.gd"))
root.add_child(tool)
tool.call("set_brush_mode", TerrainField.Mode.PAINT)
_check(int(tool.get("brush_mode")) == TerrainField.Mode.PAINT,
"set_brush_mode(Malen) bleibt Malen (alter Clamp 0..2 degradierte still zu Glätten)")
tool.call("set_brush_mode", TerrainField.Mode.RAISE)
_check(int(tool.get("brush_mode")) == TerrainField.Mode.RAISE, "set_brush_mode(Heben) bleibt Heben")
tool.call("set_brush_mode", 99)
_check(int(tool.get("brush_mode")) == TerrainField.Mode.PAINT, "Out-of-Range clampet auf den höchsten Modus")