scopedvr-map-editor/tests/test_orbit_camera.gd
Hermes (scopedvr-dev) 2c5b58f04c t_950fddaa K1: Kamera-Drehen fixen — Drehpunkt unter den Mauszeiger, Map beim Laden passend einpassen
orbit_camera.gd: set_pivot(hit) rechnet aus unveränderter Position/Basis
target/yaw/pitch/distance neu (kein Sprung); MMT-Druck pivoted auf den
Punkt unterm Cursor — Objekt-Treffer zuerst (interaction._pick_object),
sonst Terrain (interaction._ground_point), sonst alter Ziel-Punkt.
ORBIT_DEG_PER_PX = 0.3 gekapselt, Zoom-Minimum DISTANCE_MIN = 5.
fit_to_bounds(mn, mx) ersetzt das tote frame_map(): target = Kartemitte
aus bounds, Abstand über Umkugel-Formel (alle 8 Ecken sicher im Frustum —
reiner Kanten-Faktor 0,65 reicht bei flachen Maps nicht), Mindestabstand
30 m; Pos1/Home = ganze Karte ins Bild (Bounds bleiben gemerkt).
main.gd: Laden/Neu + Resize rufen fit_to_bounds mit den echten doc-Bounds.
Neu: tests/test_orbit_camera.gd (30 checks: kein Sprung, Drehpunkt wandert,
fit, gleichmäßiges Ziehen, Flug-Übergang, Pos1).
2026-10-20 07:09:11 +00:00

308 lines
No EOL
11 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-Test (headless): Karte t_950fddaa (K1) — Kamera-Drehen fixen.
## Ausführen: godot4 --headless --path . --script res://tests/test_orbit_camera.gd
## Exit 0 = alles grün.
##
## Instanziert die ECHTE Main-UI (wie test_rmb_vorrang) und prüft:
## 1. FIT: Laden/Neu → fit_to_bounds — target = Kartemitte aus bounds,
## distance = 0,65 × größte Kante, alle 8 Map-Ecken im Frustum.
## 2. KEIN SPRUNG: MMT-Druck ändert Position/Basis NICHT (1e-4), aber
## target = Trefferpunkt; Projektion des target ≈ Cursor (1 px).
## Vergleichszeite dokumentiert, wie weit das ALTE Verhalten weg war.
## 3. GLEICHMÄSSIG: 100 px ziehen in 10 Schritten → jede Schritt dreht
## gleichmäßig (max 1,3× Mittel), kein Vorzeichenwechsel.
## 4. FLUG-ÜBERGANG: _enter_fly → Bewegung → _exit_fly ohne Positionssprung.
## 5. POS1: ganze Karte wieder im Bild (fit_to_bounds).
var failures := 0
var checks := 0
var main: Control
var camera: Camera3D
var subvp: SubViewport
var interaction: Node
var view_builder: Node
const ORBIT_DEG_PER_PX := 0.3
func _initialize() -> void:
_run_all.call_deferred()
func _run_all() -> void:
var scene: PackedScene = load("res://scenes/main.tscn")
_check(scene != null, "scenes/main.tscn laedt")
if scene == null:
_finish()
return
main = scene.instantiate()
root.add_child(main)
await process_frame
await process_frame
camera = main.get("camera")
interaction = main.get("interaction")
view_builder = main.get("view_builder")
_check(camera != null, "Kamera-Referenz gefunden")
_check(interaction != null, "interaction-Referenz gefunden")
_check(view_builder != null, "view_builder-Referenz gefunden")
if camera == null or interaction == null or view_builder == null:
_finish()
return
var container := _find_viewport_container(main)
_check(container != null, "SubViewportContainer gefunden")
if container == null:
_finish()
return
subvp = container.get_child(0) as SubViewport
_check(subvp != null, "SubViewport gefunden")
if subvp == null:
_finish()
return
# Headless-Fenster ist degeneriert klein (SubViewport ~951×2 px) — für
# aussagekräftige Projektionen/Frustum-Prüfungen Editor-Größe erzwingen.
container.stretch = false
subvp.size = Vector2i(1600, 900)
await process_frame
_check(subvp.size == Vector2i(1600, 900), "SubViewport auf 1600×900 (headless)")
# Test-Map anlegen (gleicher Pfad wie „Neue Map“): _refresh_all löst
# fit_to_bounds in _refresh_viewport aus (Karte K1).
var doc: Dictionary = SvmapDocument.create_new("K1-Test", 1024.0, "mittag")
doc["objects"] = [
{"id": "crate_wood", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "crate_wood", "pos": [450.0, 0.0, 450.0], "scale": 1.0, "rot_y_deg": 0.0},
]
main.set("doc", doc)
main.call("_refresh_all")
await process_frame
interaction.call("refresh_terrain_cache")
main.call("_set_tool_mode", "objects")
await process_frame
_test_fit()
await _test_pivot_no_jump()
await _test_uniform_drag()
await _test_fly_transition()
await _test_home_key()
_finish()
# ------------------------------------------------------------- 1. FIT ------
func _test_fit() -> void:
print("Testgruppe: fit_to_bounds (Laden/Neu)")
var target: Vector3 = camera.get("target")
var dist: float = float(camera.get("distance"))
_check(target.distance_to(Vector3(0.0, 35.0, 0.0)) < 1e-4,
"target = Kartemitte aus bounds (0, 35, 0) — nicht Weltmitte y=0")
var radius := Vector3(1024.0, 90.0, 1024.0).length() * 0.5
_check(dist >= radius / sin(deg_to_rad(37.5)) - 1.0,
"distance = Umkugel-Formel (ganze Map sichtbar): %.1f m" % dist)
_check(absf(float(camera.get("yaw_deg")) - 45.0) < 1e-4, "yaw = 45 Grad")
_check(absf(float(camera.get("pitch_deg")) - 35.0) < 1e-4, "pitch = 35 Grad")
var mn := Vector3(-512.0, -10.0, -512.0)
var mx := Vector3(512.0, 80.0, 512.0)
var corners: Array[Vector3] = _corners(mn, mx)
var alle_drin := true
for c in corners:
if not camera.is_position_in_frustum(c):
alle_drin = false
_check(alle_drin, "alle 8 Map-Ecken im Frustum")
var editor := camera.unproject_position(target)
var mitte := subvp.size * 0.5
_check(editor.distance_to(mitte) < 1.0,
"Kartemitte projiziert ≈ Bildmitte (%.1f px Abweichung)" % editor.distance_to(mitte))
# -------------------------------------------- 2. PIVOT UNTER DEM CURSOR -----
func _test_pivot_no_jump() -> void:
print("Testgruppe: Drehpunkt unter dem Mauszeiger (kein Sprung)")
var markers: Array = view_builder.get("object_markers")
var marker: Node3D = markers[1] # Objekt WEIT abseits der Kartenmitte
_check(marker != null and is_instance_valid(marker),
"Objekt-Marker vorhanden (crate_wood bei 450, 450)")
if marker == null:
return
var center: Vector3 = marker.global_position \
+ Vector3.UP * float(marker.get_meta("half_height", 0.0))
var press: Vector2 = camera.unproject_position(center)
# Altes Verhalten dokumentieren: Drehpunkt war der Fit-Ziel-Punkt — seine
# Projektion lag weit weg vom Cursor.
var alt_target: Vector3 = Vector3(0.0, 35.0, 0.0)
var alt_px: Vector2 = camera.unproject_position(alt_target)
print(" ALT (Vergleich, alter Code): Drehpunkt-Projektion lag %.0f px vom Cursor entfernt" % alt_px.distance_to(press))
var pos_before: Vector3 = camera.position
var basis_before: Basis = camera.global_transform.basis
_motion(press)
_mouse(MOUSE_BUTTON_MIDDLE, true, press)
await process_frame
var pos_after: Vector3 = camera.position
var basis_after: Basis = camera.global_transform.basis
_check(pos_after.distance_to(pos_before) < 1e-4,
"KEIN Sprung: Kameraposition identisch (Abweichung %.12f)" % pos_after.distance_to(pos_before))
_check(_basis_diff(basis_before, basis_after) < 1e-4,
"KEIN Sprung: Basis identisch")
var new_target: Vector3 = camera.get("target")
_check(new_target.distance_to(center) < 1e-4, "target = Trefferpunkt (Objekt-Mitte)")
var proj: Vector2 = camera.unproject_position(new_target)
_check(proj.distance_to(press) <= 1.0,
"target-Projektion ≈ Cursor (Abweichung %.12f px)" % proj.distance_to(press))
var new_dist: float = float(camera.get("distance"))
_check(absf(new_dist - pos_before.distance_to(center)) < 1e-3,
"distance = |Kamera − Treffer| (%.1f m)" % new_dist)
_mouse(MOUSE_BUTTON_MIDDLE, false, press)
await process_frame
# ------------------------------------------------- 3. GLEICHMÄSSIG ZIEHEN --
func _test_uniform_drag() -> void:
print("Testgruppe: 100 px ziehen in 10 Schritten — gleichmäßig")
var start := subvp.size * 0.5
_motion(start)
_mouse(MOUSE_BUTTON_MIDDLE, true, start)
await process_frame
var yaw0: float = float(camera.get("yaw_deg"))
var steps: Array[float] = []
var p := start
for i in range(10):
p.x += 10.0
_motion_rel(p, Vector2(10.0, 0.0))
await process_frame
var yaw_i: float = float(camera.get("yaw_deg"))
steps.append(yaw0 - yaw_i)
yaw0 = yaw_i
_mouse(MOUSE_BUTTON_MIDDLE, false, p)
await process_frame
var mean := 0.0
for s in steps:
mean += s
mean /= float(steps.size())
_check(absf(mean - 3.0) < 1e-3, "Mittel: %.3f Grad/Schritt (= 10 px × %.1f)" % [mean, ORBIT_DEG_PER_PX])
var max_ratio := 0.0
var sign_ok := true
for s in steps:
if mean > 0.0:
max_ratio = maxf(max_ratio, s / mean)
if s <= 0.0:
sign_ok = false
_check(max_ratio <= 1.3, "kein Nachlaufen/Sprung: max Schritt %.3f× Mittel" % max_ratio)
_check(sign_ok, "kein Vorzeichenwechsel beim Ziehen")
# ------------------------------------------------------ 4. FLUG-ÜBERGANG ---
func _test_fly_transition() -> void:
print("Testgruppe: Flug-Übergang nahtlos")
_mouse(MOUSE_BUTTON_RIGHT, true, subvp.size * 0.5)
await process_frame
_check(bool(camera.call("fly_active")), "Flug aktiv")
# Zwischenbewegung (wie im Flug: Position + Blickrichtung ändern).
camera.position = camera.position + Vector3(12.0, -4.0, -9.0)
camera.set("_fly_yaw", -30.0)
camera.set("_fly_pitch", 20.0)
camera.call("_apply_fly_orientation")
await process_frame
var pos_before: Vector3 = camera.position
_mouse(MOUSE_BUTTON_RIGHT, false, subvp.size * 0.5)
await process_frame
_check(not bool(camera.call("fly_active")), "Flug aus → Orbit")
var pos_after: Vector3 = camera.position
_check(pos_after.distance_to(pos_before) < 1e-3,
"kein Positionssprung beim Übergang (%.12f)" % pos_after.distance_to(pos_before))
# ------------------------------------------------------------- 5. POS1 -----
func _test_home_key() -> void:
print("Testgruppe: Pos1 = ganze Karte ins Bild")
var key := InputEventKey.new()
key.keycode = KEY_HOME
key.physical_keycode = KEY_HOME
key.pressed = true
subvp.push_input(key)
await process_frame
var target: Vector3 = camera.get("target")
_check(target.distance_to(Vector3(0.0, 35.0, 0.0)) < 1e-4, "Pos1: target = Kartemitte")
var radius := Vector3(1024.0, 90.0, 1024.0).length() * 0.5
_check(float(camera.get("distance")) >= radius / sin(deg_to_rad(37.5)) - 1.0,
"Pos1: distance = Umkugel-Formel (ganze Map sichtbar)")
_check(absf(float(camera.get("yaw_deg")) - 45.0) < 1e-4, "Pos1: yaw = 45 Grad")
_check(absf(float(camera.get("pitch_deg")) - 35.0) < 1e-4, "Pos1: pitch = 35 Grad")
var alle_drin := true
for c in _corners(Vector3(-512.0, -10.0, -512.0), Vector3(512.0, 80.0, 512.0)):
if not camera.is_position_in_frustum(c):
alle_drin = false
_check(alle_drin, "Pos1: alle 8 Map-Ecken im Frustum")
# ------------------------------------------------------------- Helpers -----
func _corners(mn: Vector3, mx: Vector3) -> Array[Vector3]:
var out: Array[Vector3] = []
for x in [mn.x, mx.x]:
for y in [mn.y, mx.y]:
for z in [mn.z, mx.z]:
out.append(Vector3(x, y, z))
return out
func _basis_diff(a: Basis, b: Basis) -> float:
var d := 0.0
d = maxf(d, (a.x - b.x).length())
d = maxf(d, (a.y - b.y).length())
d = maxf(d, (a.z - b.z).length())
return d
func _find_viewport_container(node: Node) -> SubViewportContainer:
if node is SubViewportContainer:
return node as SubViewportContainer
for child in node.get_children():
var found := _find_viewport_container(child)
if found != null:
return found
return null
## Headless-Besonderheit (wie test_rmb_vorrang): direkt in den SubViewport
## pushen, LOKALE Koordinaten.
func _mouse(button: MouseButton, pressed: bool, pos: Vector2, ctrl := false, shift := false) -> void:
var mb := InputEventMouseButton.new()
mb.button_index = button
mb.pressed = pressed
mb.position = pos
mb.global_position = pos
mb.ctrl_pressed = ctrl
mb.shift_pressed = shift
subvp.push_input(mb)
func _motion(pos: Vector2) -> void:
_motion_rel(pos, Vector2.ZERO)
func _motion_rel(pos: Vector2, rel: Vector2) -> void:
var mm := InputEventMouseMotion.new()
mm.position = pos
mm.global_position = pos
mm.relative = rel
subvp.push_input(mm)
func _finish() -> void:
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)