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, } 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("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") # 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")