scopedvr-map-editor/tests/test_orbit_camera.gd

449 lines
No EOL
18 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 —
## erweitert um Karte t_eca890c5 (K1b): Startansicht auf den Karten-INHALT.
## 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. STARTANSICHT (K1b): Laden/Neu → fit_to_document — Bounding-Box aus
## Objekten + Bauteilen + Spawnpunkten (Terrain NICHT), Mitte/Abstand
## laut Regel (Kantenlänge × 1,3, min 40 m, nie weiter als die ganze
## Karte), Klartext-Statuszeile; leeres Dokument → ganze Karte;
## 256-m- und 1024-m-Map; Mindestabstand; Karten-Deckel.
## 2. KEIN SPRUNG: MMT-Druck ändert Position/Basis NICHT (1e-4), aber
## target = Trefferpunkt; Projektion des target ≈ Cursor (1 px).
## Vergleichszeile dokumentiert, wie weit das ALTE Verhalten weg war.
## 3. DREHPUNKT AUF DEM TERRAIN (K1b): MMT auf Hügel-Flanke (höheres
## Heightfield) → target = Terrain-Treffer, y ≈ terrain_height_at —
## nicht die Basis-Ebene (y=0) und nicht der alte Ziel-Punkt.
## 4. GLEICHMÄSSIG: 100 px ziehen in 10 Schritten → jede Schritt dreht
## gleichmäßig (max 1,3× Mittel), kein Vorzeichenwechsel.
## 5. FLUG-ÜBERGANG: _enter_fly → Bewegung → _exit_fly ohne Positionssprung.
## 6. POS1: GANZE Karte wieder im Bild — auch nach Objekt-Ansicht (K1b).
var failures := 0
var checks := 0
var main: Control
var camera: Camera3D
var subvp: SubViewport
var interaction: Node
var view_builder: Node
var base_doc: Dictionary # Grund-Dokument der Suite (K1-Szene) für Rückkehr nach Spezial-Gruppen
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_document in _refresh_viewport aus (Karte K1b — Inhalts-Ansicht).
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},
]
base_doc = doc
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_start_view()
await _test_pivot_no_jump()
await _test_content_rules()
await _test_terrain_pivot()
await _test_uniform_drag()
await _test_fly_transition()
await _test_home_key()
_finish()
# ------------------------------------------------- 1. STARTANSICHT (K1b) ---
func _test_start_view() -> void:
print("Testgruppe: Startansicht = Karten-INHALT (K1b, Laden/Neu)")
var target: Vector3 = camera.get("target")
var dist: float = float(camera.get("distance"))
# Dokument: 2 Kisten (0,0,0) + (450,0,450) und 8 Default-Spawnpunkte
# (x ±409,6 / z ±307,2 … / y 0,2) → BBox x −409,6…450, y 0…0,2, z −307,2…450.
# Mitte (20,2 / 0,1 / 71,4); größte Kante 859,6 × 1,3 = 1117,48; der
# Karten-Deckel (Umkugel 1024er Map = 1215,6) wird NICHT erreicht.
_check(target.distance_to(Vector3(20.2, 0.1, 71.4)) < 0.01,
"target = Mitte der Inhalts-BBox (20,2 / 0,1 / 71,4) — Objekte+Spawns, NICHT Kartemitte")
_check(absf(dist - 1117.48) < 0.5,
"distance = Kantenlänge × 1,3 (859,6 × 1,3 = 1117,5 m): %.1f m" % dist)
_check(dist < 1215.56,
"Deckel greift nicht (Karte kleiner als ganze Map): %.1f < 1215,6 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 status: String = str((main.get("status_label") as Label).text)
_check(status.contains("Ansicht:") and status.contains("2 Objekte")
and status.contains("8 Spawnpunkte") and status.contains("1117"),
"Statuszeile Klartext: „%s“" % status)
var alle_drin := true
for c in _corners(Vector3(-409.6, 0.0, -307.2), Vector3(450.0, 0.2, 450.0)):
if not camera.is_position_in_frustum(c):
alle_drin = false
_check(alle_drin, "alle 8 Inhalts-Ecken im Frustum")
# --------------------------------- 1b. INHALTS-REGELN: Größen/leer (K1b) ---
func _test_content_rules() -> void:
print("Testgruppe: Inhalts-Ansicht — Regeln (256 m, leer, Min/Deckel)")
var mn256 := Vector3(-128.0, -10.0, -128.0)
var mx256 := Vector3(128.0, 80.0, 128.0)
# (a) 256-m-Map, keine Spawns, 3 Objekte → BBox 40×20×15, Kante 40 × 1,3 = 52 m
var doc256: Dictionary = SvmapDocument.create_new("K1b-256", 256.0, "mittag")
doc256["spawn_zones"] = {}
doc256["objects"] = [
{"id": "crate_wood", "pos": [0.0, 10.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "crate_wood", "pos": [40.0, 30.0, -10.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "crate_wood", "pos": [20.0, 20.0, 5.0], "scale": 1.0, "rot_y_deg": 0.0},
]
camera.call("fit_to_document", doc256, mn256, mx256)
var target: Vector3 = camera.get("target")
var dist: float = float(camera.get("distance"))
_check(target.distance_to(Vector3(20.0, 20.0, -2.5)) < 0.01,
"256-m-Map: target = Content-Mitte (20 / 20 / −2,5)")
_check(absf(dist - 52.0) < 0.01,
"256-m-Map: distance = 40 × 1,3 = 52 m (%.2f) — Terrain zählt NICHT (sonst 312,5)" % dist)
_check(String((main.get("status_label") as Label).text).contains("3 Objekte"),
"256-m-Map: Status meldet „3 Objekte“")
var alle_drin := true
for c in _corners(Vector3(0.0, 10.0, -10.0), Vector3(40.0, 30.0, 5.0)):
if not camera.is_position_in_frustum(c):
alle_drin = false
_check(alle_drin, "256-m-Map: alle 8 Inhalts-Ecken im Frustum")
# (b) Deckel: Content diagonal über die Karte → nie weiter als die ganze Karte
doc256["objects"] = [
{"id": "crate_wood", "pos": [-128.0, 0.0, -128.0], "scale": 1.0, "rot_y_deg": 0.0},
{"id": "crate_wood", "pos": [128.0, 0.0, 128.0], "scale": 1.0, "rot_y_deg": 0.0},
]
camera.call("fit_to_document", doc256, mn256, mx256)
dist = float(camera.get("distance"))
var d_map := Vector3(256.0, 90.0, 256.0).length() * 0.5 / sin(deg_to_rad(37.5)) * 1.02
_check(absf(dist - d_map) < 0.5,
"Deckel: distance = Karten-Fit (%.1f m), Kante×1,3 (332,8 m) wird GESCHNITTEN" % dist)
# (c) Mindestabstand: einzelnes Objekt (Kante 0) → 40 m
doc256["objects"] = [
{"id": "crate_wood", "pos": [10.0, 5.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0},
]
camera.call("fit_to_document", doc256, mn256, mx256)
target = camera.get("target")
dist = float(camera.get("distance"))
_check(target.distance_to(Vector3(10.0, 5.0, 10.0)) < 0.01,
"1 Objekt: target = Objekt-Position")
_check(absf(dist - 40.0) < 0.01,
"Mindestabstand: 1 Objekt → 40 m statt 0 × 1,3 = 0 (%.2f)" % dist)
# (d) leeres Dokument (ohne Default-Spawns!) → wie bisher: Kartenmitte, ganze Karte
var doc_leer: Dictionary = SvmapDocument.create_new("K1b-leer", 256.0, "mittag")
doc_leer["spawn_zones"] = {}
camera.call("fit_to_document", doc_leer, mn256, mx256)
target = camera.get("target")
dist = float(camera.get("distance"))
_check(target.distance_to(Vector3(0.0, 35.0, 0.0)) < 0.01,
"leeres Dokument: target = Kartemitte (0, 35, 0)")
_check(absf(dist - d_map) < 0.5,
"leeres Dokument: distance = Umkugel der GANZEN Karte (%.1f m)" % dist)
_check(String((main.get("status_label") as Label).text).contains("ganze Karte"),
"leeres Dokument: Status meldet „ganze Karte“")
# ------------------------------------------ 1c. DREHPUNKT AUF TERRAIN (K1b)
func _test_terrain_pivot() -> void:
print("Testgruppe: Drehpunkt trifft das TERRAIN (K1b)")
# Hügel-Map: Plateau 30 m (r ≤ 150 m), sonst Grundhöhe 5 m — Heightfield
# R16/LE direkt schreiben (gleiche Kodierung wie SvmapIO.decode_heights).
var doc: Dictionary = SvmapDocument.create_new("K1b-Huegel", 256.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var res := int(terrain["resolution"])
var hmin := float(terrain["min_height_m"])
var hmax := float(terrain["max_height_m"])
var bytes := PackedByteArray()
bytes.resize(res * res * 2)
for zi in res:
for xi in res:
var x := (float(xi) / float(res - 1) - 0.5) * 256.0
var z := (float(zi) / float(res - 1) - 0.5) * 256.0
var h := 30.0 if sqrt(x * x + z * z) <= 150.0 else 5.0
bytes.encode_u16((zi * res + xi) * 2, int(round((h - hmin) / (hmax - hmin) * 65535.0)))
terrain["heightmap_r16_b64"] = Marshalls.raw_to_base64(bytes)
doc["spawn_zones"] = {}
doc["objects"] = []
main.set("doc", doc)
main.call("_refresh_all")
await process_frame
interaction.call("refresh_terrain_cache")
await process_frame
var soll_y := SvmapDocument.terrain_height_at(doc, 120.0, 0.0)
print(" ZAHLEN: terrain_height_at(120, 0) = %.2f m (Plateau 30 m erwartet)" % soll_y)
var p_welt := Vector3(120.0, 30.0, 0.0) # auf dem Plateau, weit weg von Objekten
var press: Vector2 = camera.unproject_position(p_welt)
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: Position identisch")
_check(_basis_diff(basis_before, basis_after) < 1e-4, "KEIN Sprung: Basis identisch")
var tgt: Vector3 = camera.get("target")
_check(tgt.distance_to(p_welt) < 8.0,
"target = Terrain-Treffer (Abweichung %.2f m zum Sollpunkt)" % tgt.distance_to(p_welt))
_check(absf(tgt.y - SvmapDocument.terrain_height_at(doc, tgt.x, tgt.z)) < 1.0,
"target.y ≈ terrain_height_at(target.x, target.z): %.2f vs %.2f" % [
tgt.y, SvmapDocument.terrain_height_at(doc, tgt.x, tgt.z)])
_check(tgt.y > 20.0,
"target liegt AUF dem Hügel (y = %.2f), nicht auf der Basis-Ebene (0 m)" % tgt.y)
var proj: Vector2 = camera.unproject_position(tgt)
_check(proj.distance_to(press) <= 2.0,
"target-Projektion ≈ Cursor (%.2f px)" % proj.distance_to(press))
_mouse(MOUSE_BUTTON_MIDDLE, false, press)
await process_frame
# Grund-Dokument zurück (Pos1-Gruppe braucht die 1024er-Map-Bounds).
main.set("doc", base_doc)
main.call("_refresh_all")
await process_frame
interaction.call("refresh_terrain_cache")
await process_frame
# -------------------------------------------- 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)