Terrain C (t_4092e3a0): Objekt-Platzierung == Ghost-Preview — EIN Platzier-Pfad, Fusspunkt auf Terrainhoehe.

Root-Cause: Ghost-Preview und Platzierung nutzten zwei unterschiedliche
Pfade/Strukturen — der Ghost-Root trug den halben Mesh-Lift selbst
(Root=Y+half), das platzierte Objekt hebt nur das Kind-Mesh; die
Platzierung rastete X/Z und hob Y auf terrain_height_at des UNGERASTETEN
Punkts. Bei Raster-Snap und an Haengen wich die platzierte Position von
der Vorschau ab.

Fix — EIN Pfad (ObjectOps.placement_foot): Raster-Snap auf X/Z, dann
Y = Terrainhoehe am GERASTETEN Punkt (SvmapDocument.terrain_height_at).
Ghost-Root = Fusspunkt, Kind-Mesh um half_height gehoben — identische
Struktur wie map_view_builder. Objekt-Cursor trifft jetzt das GELAENDE
(TerrainField.raycast, gleicher Pfad wie Terrain-/Bauen-/Spawn-Werkzeug)
statt der flachen Basis-Ebene; Hoehen-Cache dekodiert einmal pro
Struktur-Aenderung (refresh_terrain_cache aus _after_structure_change).

Tests (test_library 40->94 Checks): Platzier-Fusspunkt (Rampe 0..20 m:
Fusspunkt == Terrainhoehe, Ghost-Pfad == Platzierungs-Pfad, Raster-Snap-Y
am gerasteten Punkt), Registry-Fusspunkt (alle 54 Objekte: Mesh-Unterseite
== pos, kein Schweben/Eingraben), Ghost-Preview == platziertes Objekt mit
simulierter Kamera (Treffer-Zelle == Cursor-Zelle, <5 cm). Editor-Suiten
501 Checks gruen, Builder-Export 21/0, GUI-Smoke 20 gruen.
This commit is contained in:
Hermes (godot-vr) 2026-10-16 17:36:30 +00:00
parent e505329a42
commit b20dff2b5d
4 changed files with 274 additions and 16 deletions

View file

@ -31,9 +31,21 @@ var view_builder # script-erweitert, Variant
var placing := false var placing := false
var place_oid := "" var place_oid := ""
var place_rot_deg := 0.0 var place_rot_deg := 0.0
var _ghost: MeshInstance3D var _ghost: Node3D # Ghost-ROOT: Position = Objekt-Fußpunkt —
# identisch zur platzierten Marker-Position
# (Karte t_4092e3a0: Ghost == Platzierung).
var _ghost_mi: MeshInstance3D # Kind-Mesh, gehoben um half_height —
# exakt wie der Viewport-Bauer (map_view_builder).
var _ghost_active := false # Ghost sichtbar weil Cursor über der Map ist var _ghost_active := false # Ghost sichtbar weil Cursor über der Map ist
# Terrain-Höhen-Cache (Karte t_4092e3a0): der Objekt-Cursor trifft das GELÄNDE
# (TerrainField.raycast, wie Terrain-/Bauen-/Spawn-Werkzeug), nicht mehr nur
# die flache Basis-Ebene. Dekodiert einmal pro Map-Änderung, nicht pro Mausbewegung.
var _heights := PackedFloat32Array()
var _res := 0
var _sx := 0.0
var _sz := 0.0
# Auswahl + Greifen # Auswahl + Greifen
var selected_index := -1 # PRIMÄR-Objekt (letzter Klick) — Grab/Status-Anker var selected_index := -1 # PRIMÄR-Objekt (letzter Klick) — Grab/Status-Anker
var selected_indices: Array = [] # komplette Auswahl in Klick-Reihenfolge (int-Indizes) var selected_indices: Array = [] # komplette Auswahl in Klick-Reihenfolge (int-Indizes)
@ -61,6 +73,7 @@ func setup(p_main, p_camera: Camera3D, p_builder) -> void:
main = p_main main = p_main
camera = p_camera camera = p_camera
view_builder = p_builder view_builder = p_builder
refresh_terrain_cache() # t_4092e3a0: Cursor trifft das Gelände ab der ersten Mausbewegung
## Kamera fragt: dreht das Mausrad gerade ein Objekt/eine Vorschau ## Kamera fragt: dreht das Mausrad gerade ein Objekt/eine Vorschau
@ -69,14 +82,35 @@ func wheel_blocked() -> bool:
return placing or selected_index >= 0 return placing or selected_index >= 0
## Höhen-Cache auffrischen (Karte t_4092e3a0): NACH Terrain-Edits, Undo/Redo,
## Laden/Neu — main ruft das in _after_structure_change. Ein Decode pro
## Änderung statt pro Mausbewegung (129×129-Quantisierung bleibt billig).
func refresh_terrain_cache() -> void:
var terrain: Dictionary = main.doc.get("terrain", {})
_res = int(terrain.get("resolution", 0))
_sx = float(terrain.get("size_x_m", 0.0))
_sz = float(terrain.get("size_z_m", 0.0))
if _res >= 2 and _sx > 0.0 and _sz > 0.0:
_heights = SvmapIO.decode_heights(terrain)
else:
_heights = PackedFloat32Array()
func _ground_point(viewport_pos: Vector2) -> Variant: func _ground_point(viewport_pos: Vector2) -> Variant:
# Cursor-Strahl → Punkt auf der Boden-Referenzebene (Terrain-Basis). # Cursor-Strahl → erster Treffer AUF DEM GELÄNDE (Karte t_4092e3a0): die
# Höhenform zählt, nicht die flache Basis-Ebene — sonst landet ein Objekt
# auf einem Hügel versetzt hinter dem Cursor. Gleicher Pfad wie Terrain-,
# Bauen- und Spawn-Werkzeug (TerrainField.raycast). Rückgabe null = kein
# Treffer (Strahl ins Leere / außerhalb der Map).
var terrain: Dictionary = main.doc.get("terrain", {}) var terrain: Dictionary = main.doc.get("terrain", {})
if terrain.is_empty(): if terrain.is_empty():
return null return null
var plane_y := float(terrain.get("min_height_m", 0.0))
var origin := camera.project_ray_origin(viewport_pos) var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos) var dir := camera.project_ray_normal(viewport_pos)
if _res >= 2 and _heights.size() >= _res * _res:
return TerrainField.raycast(_heights, _res, _sx, _sz, origin, dir)
# Fallback (Map ohne brauchbares Höhenfeld): Basis-Ebene wie bisher.
var plane_y := float(terrain.get("min_height_m", 0.0))
if absf(dir.y) < 0.0001: if absf(dir.y) < 0.0001:
return null return null
var t := (plane_y - origin.y) / dir.y var t := (plane_y - origin.y) / dir.y
@ -127,6 +161,7 @@ func start_placement(oid: String) -> void:
placing = true placing = true
place_rot_deg = 0.0 place_rot_deg = 0.0
_ghost_active = false _ghost_active = false
refresh_terrain_cache() # Cursor trifft das GELÄNDE — Cache muss frisch sein
_ensure_ghost() _ensure_ghost()
_ensure_ghost_visible() _ensure_ghost_visible()
var label: String = main.object_label(oid) var label: String = main.object_label(oid)
@ -147,13 +182,18 @@ func cancel_placement() -> void:
func _ensure_ghost() -> void: func _ensure_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost): if _ghost != null and is_instance_valid(_ghost):
return return
_ghost = MeshInstance3D.new() # Struktur WIE der Viewport-Bauer (map_view_builder._build_objects):
# Root auf dem Fußpunkt, Kind-Mesh um half_height gehoben. So kann Ghost
# und platziertes Objekt nie auseinanderfallen (Karte t_4092e3a0).
_ghost = Node3D.new()
_ghost_mi = MeshInstance3D.new()
var mat := StandardMaterial3D.new() var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.6, 0.9, 1.0, 0.45) mat.albedo_color = Color(0.6, 0.9, 1.0, 0.45)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.emission_enabled = true mat.emission_enabled = true
mat.emission = Color(0.2, 0.5, 0.8) * 0.4 mat.emission = Color(0.2, 0.5, 0.8) * 0.4
_ghost.material_override = mat _ghost_mi.material_override = mat
_ghost.add_child(_ghost_mi)
add_child(_ghost) add_child(_ghost)
_ghost.visible = false _ghost.visible = false
@ -163,8 +203,9 @@ func _ghost_refresh_mesh() -> void:
var entry: Dictionary = lib.get(place_oid, {}) var entry: Dictionary = lib.get(place_oid, {})
var col: Dictionary = entry.get("collision", {}) var col: Dictionary = entry.get("collision", {})
var mesh := MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0) var mesh := MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0)
_ghost.mesh = mesh _ghost_mi.mesh = mesh
_ghost.position.y = MeshShapes.half_height(mesh) # Gleicher Lift wie beim platzierten Objekt: Mesh-Unterseite = Fußpunkt.
_ghost_mi.position.y = MeshShapes.half_height(mesh)
func _ensure_ghost_visible() -> void: func _ensure_ghost_visible() -> void:
@ -190,12 +231,10 @@ func _ghost_update(viewport_pos: Vector2) -> void:
_ghost.visible = false _ghost.visible = false
return return
_ghost_active = true _ghost_active = true
var pos := _apply_grid(p as Vector3) # EIN Pfad für Ghost UND Platzierung (Karte t_4092e3a0): Fußpunkt =
var half := _terrain_half() # Terrain-Höhe am gerasteten Punkt. Der sichtbare Lift hängt am Kind-Mesh.
pos.x = clampf(pos.x, -half.x, half.x) _ghost.position = ObjectOps.placement_foot(
pos.z = clampf(pos.z, -half.y, half.y) main.doc, p as Vector3, bool(main.get("grid_snap")), _terrain_half(), true)
pos.y = SvmapDocument.terrain_height_at(main.doc, pos.x, pos.z)
_ghost.position = pos
_ghost.visible = true _ghost.visible = true
@ -204,9 +243,10 @@ func _place_object(viewport_pos: Vector2) -> void:
if p == null: if p == null:
main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true) main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
return return
var pos: Vector3 = _apply_grid(p as Vector3)
var half := _terrain_half() var half := _terrain_half()
if absf(pos.x) > half.x or absf(pos.z) > half.y: var foot: Vector3 = ObjectOps.placement_foot(
main.doc, p as Vector3, bool(main.get("grid_snap")), half, false)
if absf(foot.x) > half.x or absf(foot.z) > half.y:
main.call("_set_status", "Das wäre außerhalb der Map gelandet — klicke innerhalb der grünen Fläche.", true) main.call("_set_status", "Das wäre außerhalb der Map gelandet — klicke innerhalb der grünen Fläche.", true)
return return
@ -223,7 +263,9 @@ func _place_object(viewport_pos: Vector2) -> void:
return return
var before: Dictionary = main.history.snapshot(["objects"]) var before: Dictionary = main.history.snapshot(["objects"])
var pos_f := [float(pos.x), float(SvmapDocument.terrain_height_at(main.doc, pos.x, pos.z)), float(pos.z)] # EIN Pfad für Ghost UND Platzierung (Karte t_4092e3a0): derselbe Fußpunkt
# wie die Vorschau — Raster-Snap + Terrain-Höhe am gerasteten Punkt.
var pos_f := [foot.x, foot.y, foot.z]
(main.doc["objects"] as Array).append({ (main.doc["objects"] as Array).append({
"id": place_oid, "id": place_oid,
"pos": pos_f, "pos": pos_f,

View file

@ -129,3 +129,28 @@ static func lift_offset(doc: Dictionary, index: int) -> float:
if (pos as Array).size() != 3: if (pos as Array).size() != 3:
return 0.0 return 0.0
return float(pos[1]) - SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) return float(pos[1]) - SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2]))
# ------------------------------------------------- Platzier-Fußpunkt (C) ----
# Karte t_4092e3a0 (Gerätebefund: „Ghost saß an der Unterlage, platziert saß
# das Objekt HÖHER"): Ghost-Preview und Platzierung MÜSSEN denselben Pfad
# nutzen. Diese eine Funktion ist der Pfad:
# 1. Raster-Snap auf X/Z (wenn an),
# 2. Y = Terrain-Höhe am GERASTETEN Punkt (SvmapDocument.terrain_height_at)
# — das ist der Objekt-Fußpunkt (Konvention, README §Konventionen):
# „pos.y = Terrainhöhe an (x, z)“. Der sichtbare Lift (halbe Mesh-Höhe,
# aus der Registry-Kollision via MeshShapes.half_height) ist Aufgabe der
# Darstellung (Viewport-Bauer UND Ghost-Kind-Mesh machen es identisch).
# clamp_to_map: true = Ghost clampft still auf die Map (Vorschau bleibt
# sichtbar am Rand); false = Platzierung bekommt den ungeclampten Punkt und
# die UI meldet „außerhalb der Map“.
static func placement_foot(doc: Dictionary, hit: Vector3, grid_enabled: bool,
half: Vector2, clamp_to_map: bool) -> Vector3:
var pos := hit
pos.x = snap_coord(pos.x, grid_enabled)
pos.z = snap_coord(pos.z, grid_enabled)
if clamp_to_map:
pos.x = clampf(pos.x, -half.x, half.x)
pos.z = clampf(pos.z, -half.y, half.y)
pos.y = SvmapDocument.terrain_height_at(doc, pos.x, pos.z)
return pos

View file

@ -1687,6 +1687,8 @@ func _after_structure_change() -> void:
_sync_grid_to_builder() _sync_grid_to_builder()
_refresh_tree() _refresh_tree()
_recheck_terrain_mode() _recheck_terrain_mode()
if interaction != null:
interaction.call("refresh_terrain_cache") # t_4092e3a0: Objekt-Cursor trifft das Gelände
if tool_mode == "spawns" and spawn_tool != null: if tool_mode == "spawns" and spawn_tool != null:
spawn_tool.call("refresh_from_doc") spawn_tool.call("refresh_from_doc")
if tool_mode == "bauen" and build_tool != null: if tool_mode == "bauen" and build_tool != null:

View file

@ -12,6 +12,15 @@ var checks := 0
func _init() -> void: func _init() -> void:
_run_tests() _run_tests()
_finish()
## Die Ghost-Gruppe braucht einen Viewport im Baum (Kamera-Projektion) —
## erst zwei Frames warten (wie test_terrain/_gui_smoke), dann fahren.
func _finish() -> void:
await process_frame
await process_frame
_test_ghost_matches_placement()
if failures == 0: if failures == 0:
print("ALL OK: %d checks" % checks) print("ALL OK: %d checks" % checks)
quit(0) quit(0)
@ -38,6 +47,8 @@ func _run_tests() -> void:
_test_snap_to_ground_undo() _test_snap_to_ground_undo()
_test_crud_undo_all_objects() _test_crud_undo_all_objects()
_test_svmap_roundtrip_objects() _test_svmap_roundtrip_objects()
_test_placement_foot_convention()
_test_mesh_foot_point_registry()
# ------------------------------------------------------- Bibliothek laden ---- # ------------------------------------------------------- Bibliothek laden ----
@ -323,3 +334,181 @@ func _test_svmap_roundtrip_objects() -> void:
"Poly-Budget aus Katalog: %.0f (erwartet %.0f)" % [SvmapDocument.poly_usage(doc, manifest), expected]) "Poly-Budget aus Katalog: %.0f (erwartet %.0f)" % [SvmapDocument.poly_usage(doc, manifest), expected])
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip.svmap")) DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip.svmap"))
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip2.svmap")) DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip2.svmap"))
# --------------------------------------------- Platzier-Fußpunkt (t_4092e3a0) —
## Probe-Terrain mit bekannter Höhenform: Rampe entlang +X (an der −X-Kante 0,
## an der +X-Kante 20 m). Linear → bilinear interpolierte terrain_height_at
## liefert die exakte Höhe (R16-Quantisierung ~0,2 mm bei 20 m Spannweite).
func _ramp_doc() -> Dictionary:
var doc := SvmapDocument.create_new("Fusspunkt-Probe", 320.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var res := 33
var size := 320.0
terrain["resolution"] = res
terrain["size_x_m"] = size
terrain["size_z_m"] = size
terrain["min_height_m"] = 0.0
terrain["max_height_m"] = 20.0
var heights := PackedFloat32Array()
heights.resize(res * res)
var cell := size / float(res - 1)
for zi in res:
for xi in res:
var x := -size * 0.5 + float(xi) * cell
heights[zi * res + xi] = 20.0 * (x / size + 0.5)
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, 0.0, 20.0)
return doc
## Der EINE Platzier-Pfad (ObjectOps.placement_foot): Fußpunkt == Terrainhöhe
## am (gerasteten) Punkt; Ghost-Pfad (geclampt) und Platzierungs-Pfad fallen
## innerhalb der Map auf DENSSELBEN Wert. Erhöhtes Terrain: Y folgt der Form.
func _test_placement_foot_convention() -> void:
print("Testgruppe: Platzier-Fußpunkt (Ghost == Platzierung == Terrainhöhe)")
var doc := _ramp_doc()
var half := Vector2(160.0, 160.0)
var probes := [Vector3(-120.0, 7.0, 0.0), Vector3(0.0, 7.0, 0.0),
Vector3(80.0, 7.0, 40.0), Vector3(-80.0, 7.0, -120.0)]
for p in probes:
var h := SvmapDocument.terrain_height_at(doc, p.x, p.z)
var foot: Vector3 = ObjectOps.placement_foot(doc, p, false, half, false)
_check(absf(foot.y - h) < 0.001,
"Fußpunkt == Terrainhöhe an (%.0f, %.0f): %.3f m" % [p.x, p.z, foot.y])
var ghost: Vector3 = ObjectOps.placement_foot(doc, p, false, half, true)
_check(ghost.distance_to(foot) < 0.0001,
"Ghost-Pfad == Platzierungs-Pfad (in der Map) bei (%.0f, %.0f)" % [p.x, p.z])
# Erhöhtes Terrain (Karte A): Platzierung folgt der Form, nicht Y=0.
var mid := ObjectOps.placement_foot(doc, Vector3(0.0, 0.0, 0.0), false, half, false)
_check(absf(mid.y - 10.0) < 0.01, "Rampe in der Mitte: Fußpunkt ≈ 10 m (ist %.3f)" % mid.y)
# Raster-Snap: X/Z ganze Meter, Y = Terrainhöhe am GERASTETEN Punkt.
var snap := ObjectOps.placement_foot(doc, Vector3(10.6, 0.0, 0.6), true, half, true)
_check(snap.x == 11.0 and snap.z == 1.0, "Raster-Snap rastet X/Z (10.6/0.6 → 11/1)")
_check(absf(snap.y - SvmapDocument.terrain_height_at(doc, 11.0, 1.0)) < 0.001,
"Y = Terrainhöhe am gerasteten Punkt")
# Außerhalb der Map: Platzierung bekommt den ungeclampten Punkt (die UI
# meldet „außerhalb“), der Ghost clampft still auf die Grenze.
var outp := ObjectOps.placement_foot(doc, Vector3(500.0, 0.0, 0.0), false, half, false)
_check(absf(outp.x - 500.0) < 0.001, "Platzierung: ungeclampt außerhalb erkennbar")
var clamped := ObjectOps.placement_foot(doc, Vector3(500.0, 0.0, 0.0), false, half, true)
_check(absf(clamped.x - 160.0) < 0.001, "Ghost: clampft still auf die Map-Grenze")
## Regression über ALLE Registry-Objekte (Fußpunkt-Konvention): die
## Platzhalter-Meshes sind so gebaut, dass ihre UNTERSEITE auf dem Objekt-
## Fußpunkt (pos.y) sitzt — kein Schweben, kein Eingraben. Der Lift, den
## Ghost UND Viewport-Bauer nutzen, kommt aus derselben Registry-Kollision
## (MeshShapes.half_height).
func _test_mesh_foot_point_registry() -> void:
print("Testgruppe: Registry-Fußpunkt — Mesh-Unterseite == pos (alle 54 Objekte)")
var manifest := _load_manifest()
var lib: Dictionary = manifest.get("objects", {})
var bad_primitiv: Array = []
var bad_verbund: Array = []
for oid in lib.keys():
var entry: Dictionary = lib[oid]
var col: Dictionary = entry.get("collision", {})
var shape := str(col.get("shape", "box"))
var mesh := MeshShapes.make_placeholder(shape, col, 1.0)
if mesh == null:
bad_verbund.append(str(oid) + " (mesh null)")
continue
var hh := MeshShapes.half_height(mesh)
var bottom := mesh.get_aabb().position.y + hh
if shape == "compound":
# Struktur-Objekte sind in Fußpunkt-Koordinaten GEBACKEN (hh = 0):
# nichts schwebt ÜBER dem Fußpunkt; Rampenenden dürfen den Boden
# knapp berühren (bruecke_holz ≈ −0,31 m an der Rampenkante).
if absf(hh) > 0.0001 or bottom > 0.05 or bottom < -0.4:
bad_verbund.append("%s (bottom %.3f)" % [oid, bottom])
else:
if absf(bottom) > 0.05:
bad_primitiv.append("%s (bottom %.3f)" % [oid, bottom])
_check(bad_primitiv.is_empty(),
"alle Grundformen: Mesh-Unterseite == Fußpunkt (±5 cm) — %s" % str(bad_primitiv))
_check(bad_verbund.is_empty(),
"alle Verbund-Objekte: fußpunkt-gebacken (kein Schweben) — %s" % str(bad_verbund))
var crate := MeshShapes.make_placeholder("box", {"size": [1.2, 1.2, 1.2]}, 1.0)
_check(absf(MeshShapes.half_height(crate) - 0.6) < 0.0001,
"half_height(Box 1.2 m) = 0.6 — Registry-Kollision ist der Lift")
## Minimales main-Doppel für die Ghost-Gruppe (interaction braucht nur doc,
## manifest und grid_snap — keine UI).
class ProbeMain:
var doc: Dictionary = {}
var manifest: Dictionary = {}
var grid_snap := false
## Akzeptanz-Test (Karte t_4092e3a0): die ECHTE Ghost-Preview-Logik
## (interaction._ghost_update mit simulierter Kamera über der Rampe) muss
## denselben Fußpunkt liefern wie die Platzierung — Objekt-Unterseite auf
## Terrainhöhe (< 5 cm), Ghost-Y == platzierte Y.
func _test_ghost_matches_placement() -> void:
print("Testgruppe: Ghost-Preview == platziertes Objekt (simulierte Kamera)")
var doc := _ramp_doc()
var fake := ProbeMain.new()
fake.doc = doc
fake.manifest = _load_manifest()
var size := 320.0
var half := Vector2(160.0, 160.0)
var cell := size / 32.0
var vp := SubViewport.new()
vp.size = Vector2i(1280, 720)
root.add_child(vp)
var cam := Camera3D.new()
vp.add_child(cam)
cam.current = true
cam.look_at_from_position(Vector3(-95.0, 115.0, 135.0), Vector3.ZERO, Vector3.UP)
var interaction := Node3D.new()
interaction.set_script(load("res://src/editor/interaction.gd"))
vp.add_child(interaction)
interaction.setup(fake, cam, null)
interaction.set("place_oid", "crate_wood")
interaction.set("placing", true)
interaction.call("_ensure_ghost")
interaction.call("_ghost_refresh_mesh")
var targets := [Vector2(-120.0, 0.0), Vector2(0.0, 0.0),
Vector2(80.0, 40.0), Vector2(-40.0, -80.0)]
for tp in targets:
var h := SvmapDocument.terrain_height_at(doc, tp.x, tp.y)
var screen := cam.unproject_position(Vector3(tp.x, h, tp.y))
interaction.call("_ghost_update", screen)
var ghost: Node3D = interaction.get("_ghost")
var visible := ghost != null and is_instance_valid(ghost) and ghost.visible
_check(visible, "Ghost sichtbar bei Ziel %s (Cursor trifft Gelände)" % tp)
if not visible:
continue
_check(absf(ghost.position.x - tp.x) < cell and absf(ghost.position.z - tp.y) < cell,
"Ghost-Treffer == Cursor-Zelle bei %s (Abweichung %.2f/%.2f m, Zelle %.1f m)" % [
tp, absf(ghost.position.x - tp.x), absf(ghost.position.z - tp.y), cell])
var th := SvmapDocument.terrain_height_at(doc, ghost.position.x, ghost.position.z)
_check(absf(ghost.position.y - th) < 0.05,
"Ghost-Fußpunkt == Terrainhöhe (<5 cm) bei %s (Δ %.4f m)" % [tp, absf(ghost.position.y - th)])
var place_foot: Vector3 = ObjectOps.placement_foot(doc, ghost.position, false, half, false)
_check(place_foot.distance_to(ghost.position) < 0.0001,
"Platzierung an derselben Stelle == Ghost-Position (Δ %.5f m)" % place_foot.distance_to(ghost.position))
var mi: MeshInstance3D = ghost.get_child(0)
var mesh: Mesh = mi.mesh
# Welt-Unterseite = Fußpunkt (Root) + Lift (Kind) + AABB-Min (Mesh).
var bottom := ghost.position.y + mi.position.y + mesh.get_aabb().position.y
_check(absf(bottom - ghost.position.y) < 0.05,
"Ghost-Mesh-Unterseite sitzt auf dem Fußpunkt (Δ %.4f m)" % absf(bottom - ghost.position.y))
# Raster-Snap am Ghost: gerastete X/Z, Y folgt der GERASTETEN Position.
fake.grid_snap = true
var h_snap := SvmapDocument.terrain_height_at(doc, 10.0, 0.0)
var screen_snap := cam.unproject_position(Vector3(10.0, h_snap, 0.0))
interaction.call("_ghost_update", screen_snap)
var g2: Node3D = interaction.get("_ghost")
if g2 != null and g2.visible:
_check(absf(g2.position.x - roundf(g2.position.x)) < 0.0001
and absf(g2.position.z - roundf(g2.position.z)) < 0.0001,
"Ghost mit Raster-Snap: X/Z ganze Meter (%.2f/%.2f)" % [g2.position.x, g2.position.z])
var th2 := SvmapDocument.terrain_height_at(doc, g2.position.x, g2.position.z)
_check(absf(g2.position.y - th2) < 0.05,
"Ghost mit Raster-Snap: Y = Terrainhöhe am gerasteten Punkt (Δ %.4f m)" % absf(g2.position.y - th2))
else:
_check(false, "Ghost mit Raster-Snap sichtbar")
vp.free()