diff --git a/src/ui/main.gd b/src/ui/main.gd index 7a05220..aa9b6ce 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -2903,13 +2903,14 @@ func _refresh_viewport() -> void: view_builder.call("rebuild", doc, manifest) _sync_grid_to_builder() _sync_contours_to_builder() - # Karte K1 (t_950fddaa): Laden/Neu = ganze Map ins Bild — Kartenmitte aus - # bounds (nicht Weltmitte), Abstand passend zur größten Kantenlänge. + # Karte K1b (t_eca890c5): Laden/Neu = Ansicht auf den Karten-INHALT + # (Objekte + Bauteile + Spawnpunkte; Terrain zählt nicht) — ohne Inhalt + # wie bisher die ganze Karte. Pos1 (Home) bleibt ganze Karte. var fb: Dictionary = doc.get("bounds", {}) var fmn: Array = fb.get("min", []) var fmx: Array = fb.get("max", []) if fmn.size() == 3 and fmx.size() == 3: - camera.call("fit_to_bounds", + camera.call("fit_to_document", doc, Vector3(fmn[0], fmn[1], fmn[2]), Vector3(fmx[0], fmx[1], fmx[2])) diff --git a/src/viewport/orbit_camera.gd b/src/viewport/orbit_camera.gd index 0d72378..a9805af 100644 --- a/src/viewport/orbit_camera.gd +++ b/src/viewport/orbit_camera.gd @@ -34,6 +34,8 @@ const SvmapIO := preload("res://src/svmap/svmap_io.gd") const ORBIT_DEG_PER_PX := 0.3 # Karte K1: Drehgeschwindigkeit gekapselt — kein Nachlaufen const DISTANCE_MIN := 5.0 const FIT_FLOOR_MIN := 30.0 # fit_to_bounds: nie näher als 30 m an die Map ran +const FIT_CONTENT_MIN := 40.0 # Karte K1b: Objekt-Ansicht nie näher als 40 m ran +const FIT_CONTENT_MARGIN := 1.3 # Karte K1b: Kantenlänge × 1,3 Rand var target := Vector3.ZERO var yaw_deg := 45.0 @@ -332,6 +334,94 @@ func fit_to_bounds(mn: Vector3, mx: Vector3) -> void: _apply() +## Karte K1b (t_eca890c5): Startansicht auf den Karten-INHALT — Bounding-Box +## aller Objekte + Bauteile + Spawnpunkte (Terrain zählt NICHT) bestimmt Mitte +## und Abstand. Ohne Inhalt gilt wie bisher die ganze Karte. Pos1 (Home) +## bleibt GANZE Karte: dafür werden die MAP-Bounds gemerkt, nicht die +## Content-Bounds. +func fit_to_document(doc: Dictionary, map_mn: Vector3, map_mx: Vector3) -> void: + var content := content_bounds(doc) + if int(content["count"]) == 0: + fit_to_bounds(map_mn, map_mx) + _status("Ansicht: ganze Karte — kein Inhalt (Objekte/Bauteile/Spawnpunkte) im Dokument.") + return + _fly_held = false + _fly_locked = false + _flying = false + # Pos1 = ganze Karte: MAP-Bounds merken (NICHT die Content-Bounds). + _fit_bounds_mn = Vector3(minf(map_mn.x, map_mx.x), minf(map_mn.y, map_mx.y), + minf(map_mn.z, map_mx.z)) + _fit_bounds_mx = Vector3(maxf(map_mn.x, map_mx.x), maxf(map_mn.y, map_mx.y), + maxf(map_mn.z, map_mx.z)) + _fit_bounds_valid = true + var cmn: Vector3 = content["mn"] + var cmx: Vector3 = content["mx"] + var span := cmx - cmn + target = cmn + span * 0.5 + # Abstand laut Karte K1b: größte Content-Kantenlänge × 1,3, mindestens + # 40 m, nie weiter als die GANZE Karte reicht (Karten-Fit-Abstand). + var edge := maxf(span.x, maxf(span.y, span.z)) + var d_map := _fit_distance((map_mx - map_mn).length() * 0.5) + distance = clampf(edge * FIT_CONTENT_MARGIN, FIT_CONTENT_MIN, d_map) + yaw_deg = 45.0 + pitch_deg = 35.0 + _apply() + _status("Ansicht: %s — Abstand %.0f m." % [_content_label(doc, int(content["count"])), distance]) + + +## Bounding-Box des Dokument-INHALTS: Objekte, Bauteile („gebaeude") und +## Spawnpunkte — Terrain und Map-Bounds zählen NICHT (Karte K1b). +## Rückgabe: {mn: Vector3, mx: Vector3, count: int}; count 0 = leeres Dokument. +static func content_bounds(doc: Dictionary) -> Dictionary: + var mn := Vector3(INF, INF, INF) + var mx := Vector3(-INF, -INF, -INF) + var count := 0 + for pts in [_content_points(doc.get("objects", []) as Array), + _content_points(doc.get("gebaeude", []) as Array)]: + for p: Vector3 in pts as Array: + mn = mn.min(p) + mx = mx.max(p) + count += 1 + var zones: Dictionary = doc.get("spawn_zones", {}) as Dictionary + for team_key in zones: + var zone: Dictionary = zones[team_key] if zones[team_key] is Dictionary else {} + for p: Vector3 in _content_points(zone.get("points", []) as Array): + mn = mn.min(p) + mx = mx.max(p) + count += 1 + if count == 0: + return {"mn": Vector3.ZERO, "mx": Vector3.ZERO, "count": 0} + return {"mn": mn, "mx": mx, "count": count} + + +## pos-Felder [x, y, z] aus einer Dokument-Liste lesen; defekte Einträge +## überspringen (stillschweigend, nie ein harter Fehler beim Laden). +static func _content_points(items: Array) -> Array: + var pts: Array = [] + for item in items: + if not (item is Dictionary): + continue + var pos: Variant = (item as Dictionary).get("pos") + if pos is Array and (pos as Array).size() >= 3: + pts.append(Vector3(float(pos[0]), float(pos[1]), float(pos[2]))) + return pts + + +## Klartext für die Statuszeile: welche Inhaltstypen die Ansicht bestimmt hat. +static func _content_label(doc: Dictionary, count: int) -> String: + var n_obj := _content_points(doc.get("objects", []) as Array).size() + var n_bau := _content_points(doc.get("gebaeude", []) as Array).size() + var n_spawn := count - n_obj - n_bau + var teile: Array[String] = [] + if n_obj > 0: + teile.append("%d Objekte" % n_obj) + if n_bau > 0: + teile.append("%d Bauteile" % n_bau) + if n_spawn > 0: + teile.append("%d Spawnpunkte" % n_spawn) + return ", ".join(teile) + + ## Abstand, damit die Umkugel (Radius um den Ziel-Punkt) komplett sichtbar ## bleibt: d = R / sin(halber FOV der SCHMALEREN Bildachse) + 2 % Reserve. func _fit_distance(radius: float) -> float: diff --git a/tests/probe_k1b_render.gd b/tests/probe_k1b_render.gd new file mode 100644 index 0000000..54a558c --- /dev/null +++ b/tests/probe_k1b_render.gd @@ -0,0 +1,135 @@ +extends SceneTree +## WEGWERF-Renderprobe (Karte t_eca890c5, K1b Startansicht = Karten-INHALT) — 3 Bilder 1600×900: +## (a) Map geladen → fit_to_document: INHALT (Kisten+Spawns) im Bild, Kisten gut sichtbar +## (b) MMT auf Objekt + 60 px ziehen → Objekt bleibt unterm Cursor, kein Sprung +## (c) Pos1 → GANZE Karte wieder im Bild (Statuszeile „Pos1: ganze Karte ins Bild.“) +## Ausführen: +## xvfb-run -a godot4 --path . --rendering-driver opengl3 -s res://tests/probe_k1b_render.gd +## Wegwerf-Probe (nicht im Repo-Commit — Datei-Grenzen der Karte), Beweise nach +## /opt/data/scopedvr/work/k1b_beweise/. + +var main: Control +var camera: Camera3D +var subvp: SubViewport +var step := 0 + +func _initialize() -> void: + _run.call_deferred() + +func _png(name: String) -> String: + return "/opt/data/scopedvr/work/k1b_beweise/" + name + +func _shot(name: String) -> void: + await process_frame + await process_frame + var img := subvp.get_texture().get_image() + img.save_png(_png(name)) + print("PROBE_PNG=%s (%dx%d)" % [_png(name), img.get_width(), img.get_height()]) + +func _find_subvp(node: Node) -> SubViewport: + if node is SubViewport: + return node as SubViewport + for c in node.get_children(): + var f := _find_subvp(c) + if f != null: + return f + return null + +func _mouse(btn: int, pressed: bool, pos: Vector2) -> void: + var ev := InputEventMouseButton.new() + ev.button_index = btn as MouseButton + ev.pressed = pressed + ev.position = pos + ev.global_position = pos + subvp.push_input(ev) + +func _motion(pos: Vector2) -> void: + var ev := InputEventMouseMotion.new() + ev.position = pos + ev.global_position = pos + subvp.push_input(ev) + +func _key(code: Key) -> void: + var key := InputEventKey.new() + key.keycode = code + key.physical_keycode = code + key.pressed = true + subvp.push_input(key) + +func _in_frustum(p: Vector3) -> bool: + return camera.is_position_in_frustum(p) + +func _run() -> void: + var scene: PackedScene = load("res://scenes/main.tscn") + main = scene.instantiate() + root.add_child(main) + await process_frame + await process_frame + camera = main.get("camera") + subvp = _find_subvp(main) + var container: SubViewportContainer = subvp.get_parent() + container.stretch = false + subvp.size = Vector2i(1600, 900) + await process_frame + + # Gleiche Test-Szene wie die Suite: 2 Kisten + Default-Spawns (1024er Map). + var doc: Dictionary = SvmapDocument.create_new("K1b-Probe", 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 + await process_frame + + var builder = main.get("view_builder") + var marker: Node3D = (builder.get("object_markers") as Array)[0] + var center: Vector3 = marker.global_position + Vector3.UP * float(marker.get_meta("half_height", 0.0)) + print("ZAHLEN (a) target=", camera.get("target"), " distance=", camera.get("distance")) + print("ZAHLEN (a) Objekt ", center, " → px ", camera.unproject_position(center)) + var ecken_sichtbar := 0 + var fehlende := 0 + for c in [Vector3(-512, -10, -512), Vector3(512, 80, -512), Vector3(-512, -10, 512), Vector3(512, 80, 512)]: + if _in_frustum(c): + ecken_sichtbar += 1 + else: + fehlende += 1 + print("ZAHLEN (a) Map-Ecken im Frustum: %d/4 (fehlend=%d — STARTANSICHT ist INHALT, nicht ganze Map)" % [ecken_sichtbar, fehlende]) + print("ZAHLEN (a) Kisten im Frustum: (0,0,0)=", _in_frustum(Vector3(0, 0.6, 0)), + " (450,0.6,450)=", _in_frustum(Vector3(450, 0.6, 450))) + print("ZAHLEN (a) Status: ", main.status_label.text) + await _shot("k1b_a_startansicht_inhalt.png") + + # --- (b) MMT auf Objekt + 60 px ziehen (K1-Prüfung bleibt intakt) -------- + var press: Vector2 = camera.unproject_position(center) + print("ZAHLEN (b) Objekt bei ", center, " → px ", press) + _motion(press) + _mouse(MOUSE_BUTTON_MIDDLE, true, press) + await process_frame + var dist_vor: float = float(camera.get("distance")) + _motion(press + Vector2(60, 0)) + await process_frame + _motion(press + Vector2(60, 0)) + _mouse(MOUSE_BUTTON_MIDDLE, false, press + Vector2(60, 0)) + await process_frame + var dist_nach: float = float(camera.get("distance")) + print("ZAHLEN (b) Abstand Kamera↔Objekt vor/nach 60px-Drehen: ", + camera.position.distance_to(center), " / ", camera.position.distance_to(center), + " (Δ=", absf(dist_nach - dist_vor), ") Objekt im Bild=", _in_frustum(center)) + await _shot("k1b_b_drehen_objekt.png") + + # --- (c) Pos1: GANZE Karte wieder ---------------------------------------- + _key(KEY_HOME) + await process_frame + await process_frame + var alle := true + for c in [Vector3(-512, -10, -512), Vector3(512, 80, -512), Vector3(-512, -10, 512), Vector3(512, 80, 512)]: + if not _in_frustum(c): + alle = false + print("ZAHLEN (c) nach Pos1: Map-Ecken im Frustum %s (8-Extrem-Punkte geprüft über 4 obere+4 … simpel: 4)", ["ALLE" if alle else "FEHLEND"]) + print("ZAHLEN (c) target=", camera.get("target"), " distance=", camera.get("distance")) + print("ZAHLEN (c) Status: ", main.status_label.text) + await _shot("k1b_c_pos1_ganze_karte.png") + print("PROBE_FERTIG") + quit(0) diff --git a/tests/probe_k1b_render.gd.uid b/tests/probe_k1b_render.gd.uid new file mode 100644 index 0000000..a918df8 --- /dev/null +++ b/tests/probe_k1b_render.gd.uid @@ -0,0 +1 @@ +uid://b7paxweowqjgx diff --git a/tests/test_orbit_camera.gd b/tests/test_orbit_camera.gd index 517f8da..ebe72c7 100644 --- a/tests/test_orbit_camera.gd +++ b/tests/test_orbit_camera.gd @@ -1,18 +1,25 @@ extends SceneTree -## Editor-Test (headless): Karte t_950fddaa (K1) — Kamera-Drehen fixen. +## 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. FIT: Laden/Neu → fit_to_bounds — target = Kartemitte aus bounds, -## distance = 0,65 × größte Kante, alle 8 Map-Ecken im Frustum. +## 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). -## Vergleichszeite dokumentiert, wie weit das ALTE Verhalten weg war. -## 3. GLEICHMÄSSIG: 100 px ziehen in 10 Schritten → jede Schritt dreht +## 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. -## 4. FLUG-ÜBERGANG: _enter_fly → Bewegung → _exit_fly ohne Positionssprung. -## 5. POS1: ganze Karte wieder im Bild (fit_to_bounds). +## 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 @@ -21,6 +28,7 @@ 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 @@ -67,12 +75,13 @@ func _run_all() -> void: _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). + # 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 @@ -80,8 +89,10 @@ func _run_all() -> void: main.call("_set_tool_mode", "objects") await process_frame - _test_fit() + _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() @@ -89,30 +100,160 @@ func _run_all() -> void: _finish() -# ------------------------------------------------------------- 1. FIT ------ -func _test_fit() -> void: - print("Testgruppe: fit_to_bounds (Laden/Neu)") +# ------------------------------------------------- 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")) - _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) + # 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 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 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: + 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 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)) + _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 -----