From b20dff2b5d7af6740595832e373286f13b1bcc87 Mon Sep 17 00:00:00 2001 From: "Hermes (godot-vr)" Date: Fri, 16 Oct 2026 17:36:30 +0000 Subject: [PATCH] =?UTF-8?q?Terrain=20C=20(t=5F4092e3a0):=20Objekt-Platzier?= =?UTF-8?q?ung=20=3D=3D=20Ghost-Preview=20=E2=80=94=20EIN=20Platzier-Pfad,?= =?UTF-8?q?=20Fusspunkt=20auf=20Terrainhoehe.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/editor/interaction.gd | 74 +++++++++++---- src/editor/object_ops.gd | 25 +++++ src/ui/main.gd | 2 + tests/test_library.gd | 189 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 274 insertions(+), 16 deletions(-) diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 72941dc..9760f94 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -31,9 +31,21 @@ var view_builder # script-erweitert, Variant var placing := false var place_oid := "" 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 +# 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 var selected_index := -1 # PRIMÄR-Objekt (letzter Klick) — Grab/Status-Anker 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 camera = p_camera 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 @@ -69,14 +82,35 @@ func wheel_blocked() -> bool: 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: - # 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", {}) if terrain.is_empty(): return null - var plane_y := float(terrain.get("min_height_m", 0.0)) var origin := camera.project_ray_origin(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: return null var t := (plane_y - origin.y) / dir.y @@ -127,6 +161,7 @@ func start_placement(oid: String) -> void: placing = true place_rot_deg = 0.0 _ghost_active = false + refresh_terrain_cache() # Cursor trifft das GELÄNDE — Cache muss frisch sein _ensure_ghost() _ensure_ghost_visible() var label: String = main.object_label(oid) @@ -147,13 +182,18 @@ func cancel_placement() -> void: func _ensure_ghost() -> void: if _ghost != null and is_instance_valid(_ghost): 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() mat.albedo_color = Color(0.6, 0.9, 1.0, 0.45) mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.emission_enabled = true 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) _ghost.visible = false @@ -163,8 +203,9 @@ func _ghost_refresh_mesh() -> void: var entry: Dictionary = lib.get(place_oid, {}) var col: Dictionary = entry.get("collision", {}) var mesh := MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0) - _ghost.mesh = mesh - _ghost.position.y = MeshShapes.half_height(mesh) + _ghost_mi.mesh = mesh + # Gleicher Lift wie beim platzierten Objekt: Mesh-Unterseite = Fußpunkt. + _ghost_mi.position.y = MeshShapes.half_height(mesh) func _ensure_ghost_visible() -> void: @@ -190,12 +231,10 @@ func _ghost_update(viewport_pos: Vector2) -> void: _ghost.visible = false return _ghost_active = true - var pos := _apply_grid(p as Vector3) - var half := _terrain_half() - pos.x = clampf(pos.x, -half.x, half.x) - pos.z = clampf(pos.z, -half.y, half.y) - pos.y = SvmapDocument.terrain_height_at(main.doc, pos.x, pos.z) - _ghost.position = pos + # EIN Pfad für Ghost UND Platzierung (Karte t_4092e3a0): Fußpunkt = + # Terrain-Höhe am gerasteten Punkt. Der sichtbare Lift hängt am Kind-Mesh. + _ghost.position = ObjectOps.placement_foot( + main.doc, p as Vector3, bool(main.get("grid_snap")), _terrain_half(), true) _ghost.visible = true @@ -204,9 +243,10 @@ func _place_object(viewport_pos: Vector2) -> void: if p == null: main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true) return - var pos: Vector3 = _apply_grid(p as Vector3) 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) return @@ -223,7 +263,9 @@ func _place_object(viewport_pos: Vector2) -> void: return 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({ "id": place_oid, "pos": pos_f, diff --git a/src/editor/object_ops.gd b/src/editor/object_ops.gd index 4d0929c..798a36d 100644 --- a/src/editor/object_ops.gd +++ b/src/editor/object_ops.gd @@ -129,3 +129,28 @@ static func lift_offset(doc: Dictionary, index: int) -> float: if (pos as Array).size() != 3: return 0.0 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 diff --git a/src/ui/main.gd b/src/ui/main.gd index 696f4cd..bba4a47 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -1687,6 +1687,8 @@ func _after_structure_change() -> void: _sync_grid_to_builder() _refresh_tree() _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: spawn_tool.call("refresh_from_doc") if tool_mode == "bauen" and build_tool != null: diff --git a/tests/test_library.gd b/tests/test_library.gd index 85cbf5c..1151d70 100644 --- a/tests/test_library.gd +++ b/tests/test_library.gd @@ -12,6 +12,15 @@ var checks := 0 func _init() -> void: _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: print("ALL OK: %d checks" % checks) quit(0) @@ -38,6 +47,8 @@ func _run_tests() -> void: _test_snap_to_ground_undo() _test_crud_undo_all_objects() _test_svmap_roundtrip_objects() + _test_placement_foot_convention() + _test_mesh_foot_point_registry() # ------------------------------------------------------- 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]) DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip.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()