k1b: Editor-Kamera mit Inhalt ausrichten (Startansicht auf Objekte/Bauteile, Pos1 ganzer Terrain-Klotz, 180-Grad-Schwenk um Inhaltsmitte)

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-20 07:42:50 +00:00
parent 2c5b58f04c
commit 969e928d74
5 changed files with 397 additions and 29 deletions

View file

@ -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]))

View file

@ -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:

135
tests/probe_k1b_render.gd Normal file
View file

@ -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)

View file

@ -0,0 +1 @@
uid://b7paxweowqjgx

View file

@ -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 -----