scopedvr-map-editor/tests/test_shortcuts.gd
slyphax 26bcbe0cdd Terrain-Pinsel: Kante-Regler (Falloff), Einebnen, Rampe, Druck-Stärke, Mal-Slot-Ersatz
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 09:52:03 +01:00

923 lines
No EOL
44 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_key_conflicts()
_test_inputmap_actions_3()
_test_contours_toggle()
_test_policy_3()
_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()
await _test_camera_fly_roundtrip() # coroutine seit Sly 17.10. (Flug-Vertikal-Livetest)
_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_SPACE,
"editor_fly_down": KEY_SHIFT,
"editor_fly_fast": KEY_CTRL,
"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,
"editor_brush_flatten": KEY_5,
"editor_brush_ramp": KEY_6,
}
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↑")
# -------------------------------- Flug-Tasten Konflikt-Check (Sly 17.10.) -----
## Sly-Wunsch 17.10.: Leertaste = hoch, Shift = runter, Strg = schneller.
## KONFLIKT-CHECK verpflichtend: die drei Tasten dürfen ALLEIN (ohne
## Kombos-Partner) KEINE Werkzeug-Aktion auslösen — weder über die InputMap
## (falsche Action) noch im Terrain-Werkzeug (Strg allein startet keinen
## Strich). Synthetische Events mit PHYSICAL keycode wie die echte Tastatur.
func _key_event(keycode: Key, shift := false, ctrl := false) -> InputEventKey:
var ev := InputEventKey.new()
ev.physical_keycode = keycode
ev.keycode = keycode
ev.shift_pressed = shift
ev.ctrl_pressed = ctrl
ev.pressed = true
return ev
func _matches(keycode: Key, action: String, shift := false, ctrl := false) -> bool:
return InputMap.event_is_action(_key_event(keycode, shift, ctrl), action, false)
func _test_fly_key_conflicts() -> void:
print("Testgruppe: Flug-Tasten Konflikt-Check (Leertaste/Shift/Strg)")
# Die neuen Flug-Actions feuern auf die EINZELNE Taste.
_check(_matches(KEY_SPACE, "editor_fly_up"), "Leertaste allein → editor_fly_up")
_check(_matches(KEY_SHIFT, "editor_fly_down"), "Shift allein → editor_fly_down")
_check(_matches(KEY_CTRL, "editor_fly_fast"), "Strg allein → editor_fly_fast")
# ... aber auf KEINE andere Editor-Action (alle verlangen Partner/andere Taste).
_check(not _matches(KEY_SPACE, "editor_fly_forward"), "Leertaste ≠ editor_fly_forward (W bleibt W)")
_check(not _matches(KEY_SPACE, "editor_help_overlay"), "Leertaste löst KEIN H aus")
_check(not _matches(KEY_SPACE, "editor_brush_raise"), "Leertaste schaltet KEINEN Pinselmodus")
_check(not _matches(KEY_SHIFT, "editor_freelook_toggle"), "Shift allein togglet NICHT den Flug (braucht F)")
_check(_matches(KEY_F, "editor_freelook_toggle", true), "Umschalt+F togglet den Flug weiter (unangetastet)")
_check(not _matches(KEY_SHIFT, "editor_save_as"), "Shift allein speichert NICHT (braucht Strg+S)")
_check(not _matches(KEY_SHIFT, "editor_redo_shift_z"), "Shift allein redoet NICHT (braucht Strg+Z)")
_check(not _matches(KEY_CTRL, "editor_undo"), "Strg allein macht KEIN Undo (braucht Z)")
_check(not _matches(KEY_CTRL, "editor_duplicate_selection"), "Strg allein dupliziert NICHT (braucht D)")
_check(not _matches(KEY_CTRL, "editor_lift_to_floor"), "Strg allein setzt NICHT auf den Boden (braucht Bild↑)")
_check(not _matches(KEY_CTRL, "editor_redo_y"), "Strg allein redoet NICHT (braucht Y)")
# Bestands-Combos unangetastet (Entf-Yolo etc.).
_check(_matches(KEY_Z, "editor_undo", false, true), "Strg+Z wirkt weiter (unangetastet)")
_check(_matches(KEY_DELETE, "editor_delete_selection"), "Entf wirkt weiter (unangetastet)")
# ECHTES Terrain-Werkzeug aktiv: Tasten-Events (Leertaste/Shift/Strg) dürfen
# keinen Strich starten — der Pinsel lebt nur von LMB (+Strg-Invert).
var res := 8
var flat := PackedFloat32Array()
flat.resize(res * res)
var tfake := ProbeMain.new()
tfake.doc = {"terrain": {
"resolution": res, "size_x_m": 100.0, "size_z_m": 100.0,
"min_height_m": 0.0, "max_height_m": 10.0,
"heightmap_r16_b64": TerrainField.encode(flat, 0.0, 10.0),
}}
var ttool := Node3D.new()
ttool.set_script(load("res://src/editor/terrain_tool.gd"))
root.add_child(ttool)
ttool.setup(tfake, Camera3D.new(), ProbeBuilder.new(), null)
_check(bool(ttool.call("activate")), "Terrain-Werkzeug aktiviert (Test-Map)")
for keycode in [KEY_SPACE, KEY_SHIFT, KEY_CTRL]:
ttool.call("_unhandled_input", _key_event(keycode))
_check(not bool(ttool.get("_stroking")), "Terrain: Leertaste/Shift/Strg ALLEIN starten KEINEN Strich")
# ------------------------------------------------- Karte 3/3 (t_11b5c25b) ----
## Karte 3/3: Datei & Undo, Werkzeugwechsel (F-Tasten), Hilfe + Test als
## physische KEY_*-Actions — gleiche Konvention wie Karte 1/2.
func _test_inputmap_actions_3() -> void:
print("Testgruppe: InputMap-Actions Karte 3/3 (Datei, F-Tasten, Hilfe)")
var mod_combos := {
"editor_undo": [KEY_Z, false, true], # Strg+Z
"editor_redo_y": [KEY_Y, false, true], # Strg+Y
"editor_redo_shift_z": [KEY_Z, true, true], # Strg+Umschalt+Z (2. Redo-Weg)
"editor_save": [KEY_S, false, true], # Strg+S
"editor_save_as": [KEY_S, true, true], # Strg+Umschalt+S
"editor_open_map": [KEY_O, false, true], # Strg+O
"editor_new_map": [KEY_N, false, true], # Strg+N
}
for action in mod_combos:
var combo: Array = mod_combos[action]
if not InputMap.has_action(action):
_check(false, "Action existiert: %s" % action)
continue
var ok := false
for ev in InputMap.action_get_events(action):
var kev := ev as InputEventKey
if ev is InputEventKey and kev.physical_keycode == int(combo[0]) \
and kev.shift_pressed == bool(combo[1]) and kev.ctrl_pressed == bool(combo[2]):
ok = true
_check(ok, "%s → korrekte Tastenkombi" % action)
var f_keys := {
"editor_tool_terrain": KEY_F1,
"editor_tool_objects": KEY_F2,
"editor_tool_bauen": KEY_F3,
"editor_tool_spawns": KEY_F4,
"editor_test_map": KEY_F5,
"editor_help_overlay": KEY_H,
}
for action in f_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):
var kev := ev as InputEventKey
if ev is InputEventKey and kev.physical_keycode == int(f_keys[action]):
ok = true
_check(ok, "%s → physische Taste %s" % [action, OS.get_keycode_string(int(f_keys[action]))])
## Karte t_89c0ec95: L = Höhenlinien fixieren — Action-Registrierung +
## Kollisions-Freiheit (die nackte Taste L wird von keiner Editor-Action
## genutzt; der Haupt-Handler verlangt zusätzlich „ohne Strg“).
func _test_contours_toggle() -> void:
print("Testgruppe: Höhenlinien-Fix (Karte t_89c0ec95, Taste L)")
_check(InputMap.has_action("editor_contours_toggle"),
"Action existiert: editor_contours_toggle")
var l_ok := false
for ev in InputMap.action_get_events("editor_contours_toggle"):
var kev := ev as InputEventKey
if ev is InputEventKey and kev.physical_keycode == KEY_L:
l_ok = true
_check(l_ok, "editor_contours_toggle → physische Taste L")
var collision := false
for action in ShortcutKeys.ACTIONS:
if action == "editor_contours_toggle":
continue
for entry in ShortcutKeys.ACTIONS[action]:
if entry is int and int(entry) == KEY_L:
collision = true
elif entry is Dictionary and int(entry.get("key", KEY_NONE)) == KEY_L:
collision = true
_check(not collision, "Kollisions-Check: L ist sonst UNBESetzt (keine andere Action nutzt KEY_L)")
_check(not _matches(KEY_L, "editor_tool_terrain"), "L löst KEINEN Werkzeugwechsel aus")
_check(not _matches(KEY_L, "editor_help_overlay"), "L löst KEIN H aus")
## Karte 3/3: Pure Policy — F-Tasten-Gate (Textfeld/Dialog) + Dirty-Confirm.
func _test_policy_3() -> void:
print("Testgruppe: ShortcutPolicy Karte 3/3 (F-Tasten-Gate, Dirty-Confirm)")
_check(ShortcutPolicy.tool_keys_allowed(false, false), "F-Tasten: frei ohne Textfeld/Dialog")
_check(not ShortcutPolicy.tool_keys_allowed(true, false), "F-Tasten: GESPERRT bei fokussiertem Textfeld")
_check(not ShortcutPolicy.tool_keys_allowed(false, true), "F-Tasten: GESPERRT bei offenem Dialog")
_check(not ShortcutPolicy.tool_keys_allowed(true, true), "F-Tasten: GESPERRT bei beidem")
_check(ShortcutPolicy.needs_dirty_confirm(true), "Dirty: Neu/Öffnen NACHFRAGEN (Dialog)")
_check(not ShortcutPolicy.needs_dirty_confirm(false), "Nicht-dirty: Neu/Öffnen direkt ausführen")
# ------------------------------------------------------------ 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, "Strg = ×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, "Strg 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,
# Leertaste/Shift 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, "Leertaste 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("teststart", false, false) == "exit_teststart", "Esc im Test-Start-Werkzeug → Werkzeug verlassen (Punkt bleibt)")
_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)")
# Konflikt-Check (Sly 17.10.): Flug-Einstieg gibt den GUI-Fokus frei —
# Leertaste darf keinen fokussierten Button auslösen (Space = ui_accept).
var fokus_btn := Button.new()
root.add_child(fokus_btn)
fokus_btn.grab_focus()
_check(root.gui_get_focus_owner() == fokus_btn, "Button hat GUI-Fokus (Ausgangslage)")
cam.call("_enter_fly")
_check(bool(cam.call("fly_active")), "RMT gedrückt → Flugmodus aktiv")
_check(root.gui_get_focus_owner() == null, "Flug-Einstieg gibt den GUI-Fokus frei (Leertaste gehört der Kamera)")
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")
# Neue Flug-Tasten (Sly 17.10.): Leertaste hebt, Shift senkt — live durch
# orbit_camera._process (Action-Strength-Poll, weltachsig).
cam.call("toggle_fly_lock")
var y0: float = cam.position.y
Input.action_press("editor_fly_up")
await process_frame
await process_frame
Input.action_release("editor_fly_up")
_check(cam.position.y > y0, "Leertaste hebt die Kamera im Flug (orbit_camera)")
var y1: float = cam.position.y
Input.action_press("editor_fly_down")
await process_frame
await process_frame
Input.action_release("editor_fly_down")
_check(cam.position.y < y1, "Shift senkt die Kamera im Flug (orbit_camera)")
cam.call("toggle_fly_lock")
# Blick-Yaw-Accessor (Karte t_55257792): Orbit → yaw_deg, Flug → Flug-Yaw.
# Toleranz 0,01°: jeder Flug-Exit rechnet yaw aus der Kamera-Basis zurück
# (look_at → euler_from_forward, float32) — der Sly-17.10.-Block oben macht
# einen Zyklus mehr, der Drift liegt bei ~2e-4° pro Roundtrip (unsichtbar).
_check(absf(float(cam.call("view_yaw_deg")) - 30.0) < 0.01,
"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")
# Karte t_e166e656: weiter absenken UNTER den Boden ist ERLAUBT (Versenken,
# z. B. Steine) — es wird committed, Untergrenze ist die Match-Grenze.
var can_undo2: bool = (pair[0].history as ActionHistory).can_undo()
interaction.lift_selected(-ObjectOps.LIFT_STEP_M)
_check(absf(float(objects[0]["pos"][1]) + 0.25) < 0.0001,
"Bild↓ unter Boden: y = −0,25 (Versenken erlaubt)")
_check((pair[0].history as ActionHistory).can_undo() != can_undo2
or (pair[0].history as ActionHistory).can_undo(),
"Bild↓ unter Boden: Schritt ist committet (undo möglich)")
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.RAMP, "Out-of-Range clampet auf den höchsten Modus")