diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index ac62de5..242aad5 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -264,6 +264,8 @@ func delete_selected() -> void: # ------------------------------------------------------------ Eingaben ------ func _unhandled_input(event: InputEvent) -> void: + if main.get("tool_mode") == "terrain": + return # Terrain-Pinsel hat das Zepter (terrain_tool.gd, Karte t_9aba04f1) if event is InputEventMouseMotion: var mm := event as InputEventMouseMotion if placing: diff --git a/src/editor/terrain_tool.gd b/src/editor/terrain_tool.gd new file mode 100644 index 0000000..3b1b1a7 --- /dev/null +++ b/src/editor/terrain_tool.gd @@ -0,0 +1,314 @@ +extends Node3D +## Terrain-Pinsel (M2, Karte t_9aba04f1): Höhenfeld heben/senken/glätten im +## 3D-Viewport — im Terrain-Modus (Chef-Grundsatz Sly: maximal easy + Spaß). +## +## Bedienung: +## Linksklick-Ziehen = gewählter Pinsel-Modus (Heben / Senken / Glätten) +## Rechtsklick-Ziehen = Gegenteil (bei Heben senkt er, bei Senken hebt er — +## kein Umschalten nötig; bei Glätten senkt er) +## Der ganze Strich = EIN Undo-Eintrag (Strg+Z stellt die Form VOR dem +## Strich wieder her, nicht nur eine Zwischenstufe). +## +## Feedback: Pinsel-Ring folgt dem Cursor AUF dem Gelände (ray-march auf das +## Höhenfeld), das Mesh folgt dem Strich LIVE (Vertices pro Frame neu). +## +## Der Doc wird nur ZUM Strich-Ende verändert (encode → heightmap_r16_b64) +## — während des Strichs lebt ein dekodiertes PackedFloat32Array, das Mesh +## liest daraus. Undo/Redo läuft über die gemeinsame ActionHistory (terrain-Block). + +const SMOOTH_RATE_DIVISOR := 20.0 # Stärke-Slider (m/s) → Glätt-Tempo (Anteil/s) +const MODE_LABELS := ["Heben", "Senken", "Glätten"] + +var main # Hauptfenster (script-erweitert, Variant) +var camera: Camera3D +var view_builder # TerrainMesh-Ansicht (script-erweitert, Variant) +var interaction # Objekt-Interaktion (für Modus-Wechsel) + +var active := false +var brush_mode: int = TerrainField.Mode.RAISE +var brush_radius := 12.0 # Meter +var brush_strength := 8.0 # Meter pro Sekunde (Heben/Senken) + +# Stroke-Zustand +var _stroking := false +var _stroke_mode: int = TerrainField.Mode.RAISE +var _heights := PackedFloat32Array() +var _res := 0 +var _sx := 0.0 +var _sz := 0.0 +var _hmin := 0.0 +var _hmax := 0.0 +var _cursor_valid := false +var _cursor := Vector3.ZERO + +# Pinsel-Ring +var _ring: MeshInstance3D +var _ring_radius := -1.0 +var _ring_mode := -1 + + +func setup(p_main, p_camera: Camera3D, p_builder, p_interaction) -> void: + main = p_main + camera = p_camera + view_builder = p_builder + interaction = p_interaction + _ensure_ring() + + +func _ensure_ring() -> void: + if _ring != null and is_instance_valid(_ring): + return + _ring = MeshInstance3D.new() + _ring.visible = false + add_child(_ring) + + +# ------------------------------------------------------------ Aktivierung --- + +## Terrain-Modus einschalten. false = abgelehnt (Auflösungs-Limit) — main +## bleibt dann im Objekt-Modus und erklärt warum. +func activate() -> bool: + var terrain: Dictionary = main.doc.get("terrain", {}) + var res := int(terrain.get("resolution", 0)) + if res > TerrainField.EDITOR_MAX_RESOLUTION: + main.call("_set_status", + "Diese Map hat eine Terrain-Auflösung von %d — das Editor-MVP formt Terrain nur bis %d (Performance). Neu angelegte Maps erfüllen das automatisch." % [res, TerrainField.EDITOR_MAX_RESOLUTION], + true) + return false + if not _refresh_from_doc(): + return false + active = true + _stroking = false + _ring.visible = false + return true + + +func deactivate() -> void: + active = false + if _stroking: + _end_stroke() + if _ring != null and is_instance_valid(_ring): + _ring.visible = false + + +## Nach Undo/Redo/Laden/Neue-Map: Feld neu dekodieren (Cache ist sonst alt). +func refresh_from_doc() -> bool: + if not _refresh_from_doc(): + return false + return true + + +func _refresh_from_doc() -> bool: + var terrain: Dictionary = main.doc.get("terrain", {}) + if terrain.is_empty(): + return false + _res = int(terrain.get("resolution", 0)) + _sx = float(terrain.get("size_x_m", 0.0)) + _sz = float(terrain.get("size_z_m", 0.0)) + _hmin = float(terrain.get("min_height_m", 0.0)) + _hmax = float(terrain.get("max_height_m", 0.0)) + if _res < 2 or _sx <= 0.0 or _sz <= 0.0 or _hmax <= _hmin: + return false + _heights = SvmapIO.decode_heights(terrain) + return _heights.size() >= _res * _res + + +# ------------------------------------------------------------ Pinsel-Optionen + +func set_brush_mode(mode: int) -> void: + brush_mode = clampi(mode, 0, 2) + _refresh_ring_mesh() + + +func set_radius(m: float) -> void: + brush_radius = clampf(m, 2.0, 64.0) + _refresh_ring_mesh() + + +func set_strength(mps: float) -> void: + brush_strength = clampf(mps, 1.0, 20.0) + + +# --------------------------------------------------------------- Eingaben --- + +func _unhandled_input(event: InputEvent) -> void: + if not active: + return + if event is InputEventMouseMotion: + _update_cursor((event as InputEventMouseMotion).position) + return + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_LEFT: + if mb.pressed: + _begin_stroke(brush_mode) + else: + _end_stroke() + get_viewport().set_input_as_handled() + elif mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed: + # Rechtsklick = das Gegenteil zum Modell: bei Heben senken, + # bei Senken heben, bei Glätten senken (kein Umschalten nötig). + var opposite: int = TerrainField.Mode.LOWER + if brush_mode == TerrainField.Mode.LOWER: + opposite = TerrainField.Mode.RAISE + _begin_stroke(opposite) + get_viewport().set_input_as_handled() + elif mb.button_index == MOUSE_BUTTON_RIGHT and not mb.pressed: + if _stroking: + _end_stroke() + get_viewport().set_input_as_handled() + + +func _update_cursor(viewport_pos: Vector2) -> void: + var hit: Variant = TerrainField.raycast(_heights, _res, _sx, _sz, + camera.project_ray_origin(viewport_pos), camera.project_ray_normal(viewport_pos)) + if hit == null: + _cursor_valid = false + if _ring != null: + _ring.visible = false + return + _cursor_valid = true + _cursor = hit as Vector3 + var half_x := _sx * 0.5 + var half_z := _sz * 0.5 + _cursor.x = clampf(_cursor.x, -half_x, half_x) + _cursor.z = clampf(_cursor.z, -half_z, half_z) + # Ring minimal über dem Gelände, damit er nicht im Mesh verschwindet. + _ring.position = Vector3(_cursor.x, _height_at_stroke(_cursor.x, _cursor.z) + 0.3, _cursor.z) + _ring.visible = true + + +func _height_at_stroke(x: float, z: float) -> float: + return TerrainField.height_at(_heights, _res, _sx, _sz, x, z) + + +# ----------------------------------------------------------------- Stroke --- + +func _begin_stroke(mode: int) -> void: + if _stroking: + return + if not _cursor_valid: + main.call("_set_status", + "Der Pinsel braucht einen Punkt AUF dem Gelände — führe die Maus über die Karte und halte dann die Maustaste gedrückt.", + true) + return + _stroke_mode = mode + _stroking = true + _refresh_ring_mesh() + main.call("_set_status", "%s läuft — Maustaste gedrückt halten. Beim Loslassen wird der ganze Strich EIN Rückgängig-Schritt (Strg+Z)." % MODE_LABELS[mode]) + + +func _process(delta: float) -> void: + if not _stroking or not _cursor_valid: + return + var amount: float + if _stroke_mode == TerrainField.Mode.SMOOTH: + amount = clampf(brush_strength / SMOOTH_RATE_DIVISOR * delta, 0.0, 1.0) + else: + amount = brush_strength * delta + # Während des Strichs NICHT auf die Format-Range clampen — die zieht der + # Range-Expander danach nach (sonst friert Heben/Senken an der Kante ein). + var changed := TerrainField.apply_brush(_heights, _res, _sx, _sz, -100000.0, 100000.0, + _cursor.x, _cursor.z, brush_radius, _stroke_mode, amount) + if changed: + _expand_range_if_needed() + view_builder.call("update_terrain", _heights) + _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 + + +## Höhe spannt das Höhenfeld automatisch auf, wenn der Pinsel über die +## Format-Grenzen (min/max_height) hinaus will — sonst wäre Senken auf einer +## frisch angelegten (flachen) Map unmöglich und langes Heben würde clampen. +## Die Range gehört zum terrain-Block → Undo stellt sie mit zurück. Spannweite +## bleibt ≤ 500 m (V6). +func _expand_range_if_needed() -> void: + var lo := _heights.min() + var hi := _heights.max() + if lo < _hmin: + _hmin = floorf(lo - 1.0) + if hi > _hmax: + _hmax = ceilf(hi + 1.0) + if _hmax - _hmin > 500.0: + if lo < _hmin: + _hmin = _hmax - 500.0 + else: + _hmax = _hmin + 500.0 + + +func _end_stroke() -> void: + if not _stroking: + return + _stroking = false + # Doc erst jetzt ändern: der GANZE Strich ist ein History-Eintrag. + var before: Dictionary = main.history.snapshot(["terrain"]) + var terrain: Dictionary = main.doc.get("terrain", {}) + terrain["min_height_m"] = _hmin + terrain["max_height_m"] = _hmax + terrain["heightmap_r16_b64"] = TerrainField.encode(_heights, _hmin, _hmax) + main.history.commit("Terrain geformt (%s)" % MODE_LABELS[_stroke_mode], + before, main.history.snapshot(["terrain"])) + main.call("_on_terrain_stroke_committed") + + +## Programmgesteuerter Stroke (Demo/Screenshot-Hook): macht exakt, was eine +## Maus auch tun würde — inkl. History-Eintrag. +func demo_stroke(x: float, z: float, mode: int, seconds: float) -> void: + _refresh_from_doc() + _cursor = Vector3(x, 0.0, z) + _cursor_valid = true + _begin_stroke(mode) + var steps := int(ceil(seconds / 0.05)) + var dt := seconds / float(maxi(steps, 1)) + var save_x := _cursor.x + var save_z := _cursor.z + for i in steps: + var amount: float + if mode == TerrainField.Mode.SMOOTH: + amount = clampf(brush_strength / SMOOTH_RATE_DIVISOR * dt, 0.0, 1.0) + else: + amount = brush_strength * dt + TerrainField.apply_brush(_heights, _res, _sx, _sz, -100000.0, 100000.0, + save_x, save_z, brush_radius, mode, amount) + _expand_range_if_needed() + _end_stroke() + _refresh_from_doc() + + +## Demo-Hook: Pinsel-Ring sichtbar an einer Weltposition parken (für +## Screenshots ohne echte Maus-Ereignisse). +func demo_park_brush(x: float, z: float) -> void: + _refresh_ring_mesh() + _cursor = Vector3(x, 0.0, z) + _cursor_valid = true + _ring.position = Vector3(x, _height_at_stroke(x, z) + 0.3, z) + _ring.visible = true + + +# ----------------------------------------------------------- Ring-Optik ----- + +func _refresh_ring_mesh() -> void: + if _ring == null: + return + if _ring_radius == brush_radius and _ring_mode == brush_mode: + return + _ring_radius = brush_radius + _ring_mode = brush_mode + var torus := TorusMesh.new() + torus.inner_radius = brush_radius - 0.25 + torus.outer_radius = brush_radius + 0.25 + torus.rings = 48 + torus.ring_segments = 8 + _ring.mesh = torus + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + match brush_mode: + TerrainField.Mode.RAISE: + mat.albedo_color = Color(0.4, 1.0, 0.45, 0.9) + TerrainField.Mode.LOWER: + mat.albedo_color = Color(1.0, 0.45, 0.3, 0.9) + _: + mat.albedo_color = Color(0.4, 0.7, 1.0, 0.9) + mat.emission_enabled = true + mat.emission = (mat.albedo_color as Color) * 0.5 + _ring.material_override = mat diff --git a/src/svmap/map_document.gd b/src/svmap/map_document.gd index 5263646..52c1043 100644 --- a/src/svmap/map_document.gd +++ b/src/svmap/map_document.gd @@ -6,7 +6,9 @@ class_name SvmapDocument ## ## Alles static + reine Funktionen: der Besitzer des Dicts ist main.gd. -const DEFAULT_RESOLUTION := 257 # mittleres Grid (17–513 erlaubt) +const DEFAULT_RESOLUTION := 129 # Editor-MVP-Perf-Limit (Karte t_9aba04f1); + # das Format erlaubt 17–513, der Editor formt im MVP nur bis 129 — + # neue Maps erfüllen das Limit dadurch automatisch. const DEFAULT_MIN_HEIGHT := 0.0 const DEFAULT_MAX_HEIGHT := 50.0 const DEFAULT_TARGET_GAME_VERSION := "0.1.0" diff --git a/src/svmap/terrain_field.gd b/src/svmap/terrain_field.gd new file mode 100644 index 0000000..b94ef90 --- /dev/null +++ b/src/svmap/terrain_field.gd @@ -0,0 +1,199 @@ +extends RefCounted +class_name TerrainField +## Höhenfeld-Mathematik des Map-Editors (M2, Karte t_9aba04f1 — Terrain-Werkzeug). +## +## Alles static + rein: arbeitet auf einem dekodierten PackedFloat32Array +## (res×res, row-major, -Z → +Z) statt auf Base64-Strings — das Terrain-Werkzeug +## dekodiert EINMAL pro Stroke und arbeitet flott darauf. Das Doc (svmap-Format) +## bleibt der Besitzer der Daten: vor einem Stroke dekodieren, nach dem Stroke +## mit encode() zurück ins Doc. +## +## Der eigentliche Doc-Mutator bleibt main.gd/terrain_tool.gd — hier nur Mathematik. + +enum Mode { RAISE, LOWER, SMOOTH } + +## Editor-MVP-Perf-Limit (Karte t_9aba04f1): das Format erlaubt 17–513, aber +## der Editor formt im MVP nur Grids bis 129 (≈ 32k Dreiecke — flüssig beim +## Zeichnen, Ziel 60+ FPS). Neu-Maps werden direkt mit 129 angelegt. +const EDITOR_MAX_RESOLUTION := 129 + + +## Höhe an Welt-X/Z auf einem bereits dekodierten Feld (bilinear). +## Gleiche Geometrie-Konvention wie SvmapDocument.terrain_height_at, aber ohne +## Base64-Neudekodierung pro Aufruf (macht den Pinsel flott). +static func height_at(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + x: float, z: float) -> float: + var last := float(res - 1) + var fx := clampf((x / size_x + 0.5) * last, 0.0, last) + var fz := clampf((z / size_z + 0.5) * last, 0.0, last) + var xi := int(floor(fx)) + var zi := int(floor(fz)) + var tx := fx - float(xi) + var tz := fz - float(zi) + var xi1 := mini(xi + 1, res - 1) + var zi1 := mini(zi + 1, res - 1) + var h00 := heights[zi * res + xi] + var h10 := heights[zi * res + xi1] + var h01 := heights[zi1 * res + xi] + var h11 := heights[zi1 * res + xi1] + return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz) + + +## Pinsel anwenden. amount: +## RAISE/LOWER → Meter (bereits strength × dt vom Aufrufer), +## SMOOTH → Mischfaktor 0–1 (Anteil Richtung 3×3-Umgebungsmittel). +## Gewicht: Kosinus-Falloff (1 in der Mitte, 0 am Rand) — weiche Hügel statt +## Kegel. Rückgabe true, wenn mindestens ein Punkt verändert wurde. +static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + hmin: float, hmax: float, cx: float, cz: float, radius: float, + mode: int, amount: float) -> bool: + if res < 2 or radius <= 0.0 or amount <= 0.0 or heights.size() < res * res: + return false + var cell_x := size_x / float(res - 1) + var cell_z := size_z / float(res - 1) + var r_cells_x := radius / cell_x + var r_cells_z := radius / cell_z + var gx := (cx / size_x + 0.5) * float(res - 1) + var gz := (cz / size_z + 0.5) * float(res - 1) + var x0 := maxi(int(floor(gx - r_cells_x)) - 1, 0) + var x1 := mini(int(ceil(gx + r_cells_x)) + 1, res - 1) + var z0 := maxi(int(floor(gz - r_cells_z)) - 1, 0) + var z1 := mini(int(ceil(gz + r_cells_z)) + 1, res - 1) + if mode == Mode.SMOOTH: + return _apply_smooth(heights, res, hmin, hmax, gx, gz, r_cells_x, r_cells_z, + x0, x1, z0, z1, clampf(amount, 0.0, 1.0)) + var changed := false + var z := z0 + while z <= z1: + var x := x0 + while x <= x1: + var dx := (float(x) - gx) * cell_x + var dz := (float(z) - gz) * cell_z + var d := sqrt(dx * dx + dz * dz) + if d >= radius: + x += 1 + continue + var w := 0.5 * (1.0 + cos(PI * d / radius)) + var idx := z * res + x + var h := heights[idx] + var nh := h + amount * w if mode == Mode.RAISE else h - amount * w + nh = clampf(nh, hmin, hmax) + if nh != h: + heights[idx] = nh + changed = true + x += 1 + y += 1 + return changed + + +## Glätten: Mischung Richtung 3×3-Umgebungsmittel (zweiphasig — das Mittel wird +## aus dem STAND der Feldes berechnet, sonst wandert die Welle in eine Richtung). +static func _apply_smooth(heights: PackedFloat32Array, res: int, hmin: float, hmax: float, + gx: float, gz: float, r_cells_x: float, r_cells_z: float, + x0: int, x1: int, z0: int, z1: int, amount: float) -> bool: + var avg := PackedFloat32Array() + avg.resize(heights.size()) + var z := z0 + while z <= z1: + var x := x0 + while x <= x1: + var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001) + var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001) + var d := sqrt(dx * dx + dz * dz) + if d >= 1.0: + x += 1 + continue + var sum := 0.0 + var count := 0 + var zi := maxi(z - 1, 0) + while zi <= mini(z + 1, res - 1): + var xi := maxi(x - 1, 0) + while xi <= mini(x + 1, res - 1): + sum += heights[zi * res + xi] + count += 1 + xi += 1 + zi += 1 + avg[z * res + x] = sum / float(maxi(count, 1)) + x += 1 + z += 1 + var changed := false + z = z0 + while z <= z1: + var x := x0 + while x <= x1: + var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001) + var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001) + var d := sqrt(dx * dx + dz * dz) + if d >= 1.0: + x += 1 + continue + var w := 0.5 * (1.0 + cos(PI * d)) + var idx := z * res + x + var nh := lerpf(heights[idx], avg[idx], clampf(amount * w, 0.0, 1.0)) + nh = clampf(nh, hmin, hmax) + if nh != heights[idx]: + heights[idx] = nh + changed = true + x += 1 + z += 1 + return changed + + +## Höhenfeld → Base64-Rohdaten (svmap-Format: uint16 LE, row-major). +## Rundung halbwegs offen (round) — Fehler ≤ ½ Quantisierungsschritt. +static func encode(heights: PackedFloat32Array, hmin: float, hmax: float) -> String: + var span := maxf(hmax - hmin, 0.000001) + var bytes := PackedByteArray() + bytes.resize(heights.size() * 2) + for i in heights.size(): + var v := int(round(clampf((heights[i] - hmin) / span, 0.0, 1.0) * 65535.0)) + bytes.encode_u16(i * 2, v) + return Marshalls.raw_to_base64(bytes) + + +## Cursor-Strahl → erster Treffer auf dem Höhenfeld (ray-march + Bisektion). +## Für den Pinsel: der Kreis muss UNTER dem Mauszeiger auf dem GELÄNDE liegen, +## nicht auf der flachen Basis-Ebene. Rückgabe null = kein Treffer. +static func raycast(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + origin: Vector3, dir: Vector3, max_dist: float = 0.0) -> Variant: + if res < 2 or heights.size() < res * res: + return null + if max_dist <= 0.0: + max_dist = maxf(size_x, size_z) * 3.0 + var d := dir + if d.length_squared() < 0.0000001: + return null + d = d.normalized() + var step := maxf(size_x, size_z) / float(res - 1) * 0.5 + step = clampf(step, 0.5, 4.0) + var t := 0.0 + var prev_t := 0.0 + while t < max_dist: + t = minf(t + step, max_dist) + var f := _ray_error(heights, res, size_x, size_z, origin, d, t) + if f <= 0.0: + # Vorzeichenwechsel zwischen prev_t und t → Bisektion. + var lo := prev_t + var hi := t + for i in 18: + var mid := (lo + hi) * 0.5 + if _ray_error(heights, res, size_x, size_z, origin, d, mid) <= 0.0: + hi = mid + else: + lo = mid + return origin + d * hi + prev_t = t + # Nichts getroffen → Basis-Ebene als Fallback (wie interaction._ground_point). + if absf(d.y) > 0.0001: + var plane_y := heights.min() + var t_plane := (plane_y - origin.y) / d.y + if t_plane > 0.0: + return origin + d * t_plane + return null + + +## f(t) < 0 → Strahl unter dem Terrain. (prev_f-Nutzung vermieden Godot-Warnung.) +static func _ray_error(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + origin: Vector3, dir: Vector3, t: float) -> float: + var p := origin + dir * t + return p.y - height_at(heights, res, size_x, size_z, p.x, p.z) diff --git a/src/ui/main.gd b/src/ui/main.gd index e8e1d4f..8aa3a6e 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -39,12 +39,16 @@ var dirty := false var history := ActionHistory.new() var interaction # src/editor/interaction.gd (script-erweitert → untypisiert) +var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1) +var tool_mode := "objects" # "objects" = Platzieren/Auswahl, "terrain" = Höhenfeld-Pinsel # UI-Referenzen var content_tree: Tree var stats_objects: Label +var stats_terrain: Label var status_label: Label var file_label: Label +var help_label: Label var view_builder: Node3D var camera: Camera3D var library_box: VBoxContainer @@ -65,6 +69,17 @@ var prop_version: SpinBox var prop_weather: OptionButton var terrain_info: Label +# Terrain-Werkzeug-Panel (Karte t_9aba04f1) +var terrain_edit_btn: Button +var terrain_panel: VBoxContainer +var terrain_mode_raise: Button +var terrain_mode_lower: Button +var terrain_mode_smooth: Button +var terrain_radius_slider: HSlider +var terrain_radius_label: Label +var terrain_strength_slider: HSlider +var terrain_strength_label: Label + # Dialoge var new_dialog: AcceptDialog var open_dialog: FileDialog @@ -180,6 +195,16 @@ func _build_ui() -> void: stats_objects.modulate = Color(1, 1, 1, 0.75) status_h.add_child(stats_objects) + # Terrain-Anteil + Editor-FPS (Perf-Zähler, Karte t_9aba04f1) + stats_terrain = Label.new() + stats_terrain.modulate = Color(1, 1, 1, 0.75) + status_h.add_child(stats_terrain) + var fps_timer := Timer.new() + fps_timer.wait_time = 0.5 + fps_timer.autostart = true + fps_timer.timeout.connect(_refresh_stats) + add_child(fps_timer) + # --- Dialoge --- new_dialog = preload("res://src/ui/new_map_dialog.gd").new() new_dialog.confirmed.connect(_on_new_map_confirmed) @@ -293,15 +318,21 @@ func _build_center_viewport() -> SubViewportContainer: interaction.setup(self, camera, view_builder) camera.set("wheel_filter", Callable(interaction, "wheel_blocked")) + # Terrain-Pinsel (Karte t_9aba04f1) — eigener Eingabe-Knoten im Weltraum. + terrain_tool = Node3D.new() + terrain_tool.set_script(load("res://src/editor/terrain_tool.gd")) + world.add_child(terrain_tool) + terrain_tool.setup(self, camera, view_builder, interaction) + # Bedien-Hinweis im Viewport (Overlay) - var help := Label.new() - help.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück" - help.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) - help.offset_left = 12 - help.offset_bottom = -8 - help.offset_top = -46 - help.modulate = Color(1, 1, 1, 0.65) - container.add_child(help) + help_label = Label.new() + help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück" + help_label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) + help_label.offset_left = 12 + help_label.offset_bottom = -8 + help_label.offset_top = -46 + help_label.modulate = Color(1, 1, 1, 0.65) + container.add_child(help_label) return container @@ -377,7 +408,62 @@ func _build_right_panel() -> PanelContainer: terrain_info.text = "" terrain_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART vbox.add_child(terrain_info) - vbox.add_child(_muted_label("Terrain-Höhen bearbeiten kommt mit der nächsten Version.")) + + # --- Terrain-Werkzeug (Karte t_9aba04f1): großer deutscher Button --- + terrain_edit_btn = Button.new() + terrain_edit_btn.text = "⛰ Terrain formen" + terrain_edit_btn.custom_minimum_size = Vector2(0, 48) + terrain_edit_btn.toggle_mode = true + terrain_edit_btn.tooltip_text = "Boden heben, senken und glätten — direkt in der Karte malen" + terrain_edit_btn.pressed.connect(_on_terrain_btn_pressed) + vbox.add_child(terrain_edit_btn) + + terrain_panel = VBoxContainer.new() + terrain_panel.add_theme_constant_override("separation", 6) + terrain_panel.visible = false + vbox.add_child(terrain_panel) + + terrain_panel.add_child(_field_label("Pinsel-Werkzeug:")) + var mode_row := HBoxContainer.new() + mode_row.add_theme_constant_override("separation", 6) + terrain_panel.add_child(mode_row) + var mode_group := ButtonGroup.new() + terrain_mode_raise = _terrain_mode_button("Heben ⬆", mode_group) + terrain_mode_lower = _terrain_mode_button("Senken ⬇", mode_group) + terrain_mode_smooth = _terrain_mode_button("Glätten ≈", mode_group) + terrain_mode_raise.button_pressed = true + terrain_mode_raise.toggled.connect(_on_terrain_mode_changed) + terrain_mode_lower.toggled.connect(_on_terrain_mode_changed) + terrain_mode_smooth.toggled.connect(_on_terrain_mode_changed) + mode_row.add_child(terrain_mode_raise) + mode_row.add_child(terrain_mode_lower) + mode_row.add_child(terrain_mode_smooth) + + terrain_radius_label = _field_label("Pinsel-Größe: 12 m") + terrain_panel.add_child(terrain_radius_label) + terrain_radius_slider = HSlider.new() + terrain_radius_slider.min_value = 2.0 + terrain_radius_slider.max_value = 64.0 + terrain_radius_slider.step = 1.0 + terrain_radius_slider.value = 12.0 + terrain_radius_slider.custom_minimum_size = Vector2(0, 32) + terrain_radius_slider.value_changed.connect(_on_terrain_radius_changed) + terrain_panel.add_child(terrain_radius_slider) + + terrain_strength_label = _field_label("Stärke: 8 m pro Sekunde") + terrain_panel.add_child(terrain_strength_label) + terrain_strength_slider = HSlider.new() + terrain_strength_slider.min_value = 1.0 + terrain_strength_slider.max_value = 20.0 + terrain_strength_slider.step = 0.5 + terrain_strength_slider.value = 8.0 + terrain_strength_slider.custom_minimum_size = Vector2(0, 32) + terrain_strength_slider.value_changed.connect(_on_terrain_strength_changed) + terrain_panel.add_child(terrain_strength_slider) + + terrain_panel.add_child(_muted_label( + "Linksklick-Ziehen formt den Boden, Rechtsklick-Ziehen macht das Gegenteil. " + + "Jeder Strich ist EIN Rückgängig-Schritt (Strg+Z). ESC beendet den Terrain-Modus.")) # Signale (Änderungen → Dokument + Ansicht) prop_name.text_changed.connect(_on_prop_changed.bind("name")) @@ -571,7 +657,11 @@ func _unhandled_key_input(event: InputEvent) -> void: interaction.rotate_selected(delta) get_viewport().set_input_as_handled() KEY_ESCAPE: - if interaction.placing: + if tool_mode == "terrain": + _set_tool_mode("objects") + _set_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.") + get_viewport().set_input_as_handled() + elif interaction.placing: interaction.cancel_placement() _set_status("Platzieren abgebrochen.") elif interaction.selected_index != -1: @@ -589,6 +679,8 @@ func _unhandled_key_input(event: InputEvent) -> void: # --------------------------------------------------------- Bibliothek ------ func _on_library_pressed(oid: String) -> void: + if tool_mode == "terrain": + _set_tool_mode("objects") # Bibliothek-Klick wechselt automatisch zurück if interaction.placing and interaction.place_oid == oid: # Nochmal geklickt → Modus beenden (An/Aus ohne Umwege). interaction.cancel_placement() @@ -619,6 +711,107 @@ func _base_tool_status() -> void: _set_status("Bereit. Links ein Objekt anklicken zum Platzieren oder in der Karte ein Objekt auswählen.") +# ------------------------------------------------------ Terrain-Werkzeug ---- +# (Karte t_9aba04f1 — Höhenfeld heben/senken/glätten) + +func _terrain_mode_button(text: String, group: ButtonGroup) -> Button: + var btn := Button.new() + btn.text = text + btn.toggle_mode = true + btn.button_group = group + btn.custom_minimum_size = Vector2(0, 40) + return btn + + +func _on_terrain_btn_pressed() -> void: + if terrain_edit_btn.button_pressed: + _set_tool_mode("terrain") + else: + _set_tool_mode("objects") + + +## Werkzeug-Modus wechseln: "objects" (Platzieren/Auswahl) ↔ "terrain" (Pinsel). +func _set_tool_mode(mode: String) -> void: + if mode == tool_mode: + _sync_terrain_button() + return + if mode == "terrain": + interaction.call("cancel_placement") + interaction.call("clear_selection", true) + if not terrain_tool.call("activate"): + # Abgelehnt (z. B. Auflösungs-Limit) — Button zurück, Meldung steht. + _sync_terrain_button() + return + tool_mode = "terrain" + terrain_panel.visible = true + help_label.text = "Linksklick-Ziehen: Boden formen · Rechtsklick-Ziehen: Gegenteil ·\nPinsel-Größe/Stärke rechts · Mittlere Maustaste: Kamera · Strg+Z: letzter Strich weg" + terrain_edit_btn.button_pressed = true + terrain_tool.call("set_brush_mode", _selected_terrain_mode()) + _set_status("Terrain-Modus an: Ziehe mit LINKSKLICK über die Karte, um den Boden zu formen. Die Regler rechts stellen Pinsel und Stärke ein.") + else: + terrain_tool.call("deactivate") + tool_mode = "objects" + terrain_panel.visible = false + _sync_terrain_button() + help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück" + _base_tool_status() + + +func _selected_terrain_mode() -> int: + if terrain_mode_lower.button_pressed: + return TerrainField.Mode.LOWER + if terrain_mode_smooth.button_pressed: + return TerrainField.Mode.SMOOTH + return TerrainField.Mode.RAISE + + +func _sync_terrain_button() -> void: + terrain_edit_btn.button_pressed = tool_mode == "terrain" + + +## Mode-Buttons (ButtonGroup) → Pinsel-Modus. +func _on_terrain_mode_changed(_pressed: bool) -> void: + if terrain_tool != null: + terrain_tool.call("set_brush_mode", _selected_terrain_mode()) + + +func _on_terrain_radius_changed(value: float) -> void: + terrain_radius_label.text = "Pinsel-Größe: %d m" % int(value) + if terrain_tool != null: + terrain_tool.call("set_radius", value) + + +func _on_terrain_strength_changed(value: float) -> void: + terrain_strength_label.text = "Stärke: %.1f m pro Sekunde" % value + if terrain_tool != null: + terrain_tool.call("set_strength", value) + + +## Ein Pinsel-Strich ist fertig (Doc + History schon aktualisiert vom Tool). +func _on_terrain_stroke_committed() -> void: + dirty = true + _refresh_file_label() + _refresh_stats() + _refresh_history_buttons() + view_builder.call("rebuild_terrain_from_doc", doc) + var desc: String = history.undo_desc() + _set_status("%s — Strg+Z macht den ganzen Strich rückgängig." % desc) + + +## Terrain-Modus nach Laden/Neu/Undo mit dem Doc synchron halten (Cache frisch, +## Auflösungs-Limit prüfen — sonst dezenter Rückschalter). +func _recheck_terrain_mode() -> void: + if tool_mode != "terrain" or terrain_tool == null: + return + if not terrain_tool.call("refresh_from_doc"): + terrain_tool.call("deactivate") + tool_mode = "objects" + terrain_panel.visible = false + _sync_terrain_button() + help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück" + _set_status("Terrain-Modus aus: Diese Map passt nicht ins Editor-MVP (Terrain-Auflösung über %d) — die Objekte bleiben normal bearbeitbar." % TerrainField.EDITOR_MAX_RESOLUTION, true) + + ## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt. func _on_tree_selected() -> void: if _tree_select_lock: @@ -736,6 +929,7 @@ func _after_structure_change() -> void: interaction.call("clear_selection", true) view_builder.call("rebuild", doc, manifest) _refresh_tree() + _recheck_terrain_mode() # --------------------------------------------------------------- Anzeige --- @@ -764,6 +958,7 @@ func _refresh_all() -> void: _refresh_file_label() _refresh_history_buttons() _refresh_viewport() + _recheck_terrain_mode() func _refresh_viewport() -> void: @@ -810,6 +1005,17 @@ func _refresh_stats() -> void: var budget := int(SvmapIO.MAX_TOTAL_POLY) stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%)" % [ count, _fmt_int(int(poly)), _fmt_int(budget), 100.0 * poly / budget] + # Terrain-Anteil am Quest-3-Budget (res 513 = 524.288 Dreiecke, map_format.md §4) + var tris := 0 + var res := 0 + if view_builder != null and view_builder.get("terrain_view") != null: + tris = int(view_builder.terrain_view.call("triangle_count")) + res = int(view_builder.terrain_view.call("resolution")) + var quest_budget := 524288 + var pct := 100.0 * float(tris) / float(quest_budget) + var fps := Engine.get_frames_per_second() + stats_terrain.text = " · Terrain: %s² Grid, %s Dreiecke (%.0f %% Quest-Budget) · %d FPS" % [ + str(res) if res > 0 else "—", _fmt_int(tris), pct, fps] func _refresh_file_label() -> void: @@ -843,6 +1049,9 @@ func _fmt_int(value: int) -> String: ## Screenshot die Vorschau zeigen kann (nur für Smoke-Tests). func _maybe_start_demo_mode() -> void: var args := OS.get_cmdline_user_args() + if args.has("--terrain-demo"): + _start_terrain_demo(args) + return if not args.has("--ghost-demo"): return var oid := "crate_wood" @@ -875,4 +1084,77 @@ func _maybe_start_demo_mode() -> void: camera.distance = 14.0 camera.pitch_deg = 32.0 camera.yaw_deg = 40.0 - camera.call("_apply") \ No newline at end of file + camera.call("_apply") + + +# ------------------------------------------------ Terrain-Demo-Screenshots -- + +## `godot ... -- --terrain-demo [ordner]` — Desktop-Smoke (Karte t_9aba04f1): +## fährt die Terrain-Szenarien programmatisch durch und speichert PNGs: +## Hügel heben, Senke senken, glätten, Undo-Roundtrip. Die App bleibt offen +## (Sly sieht das Ergebnis live auf dem Desktop). +func _start_terrain_demo(args: PackedStringArray) -> void: + var out_dir := "/tmp/scopedvr_terrain" + var idx := args.find("--terrain-demo") + if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"): + out_dir = args[idx + 1] + DirAccess.make_dir_recursive_absolute(out_dir) + + _set_tool_mode("terrain") + terrain_radius_slider.value = 45.0 + terrain_strength_slider.value = 10.0 + terrain_mode_raise.button_pressed = true + + # 1) Hügel heben — Pinsel-Ring bleibt als visueller Beweis im Bild. + terrain_tool.call("demo_stroke", -80.0, -60.0, TerrainField.Mode.RAISE, 1.4) + terrain_tool.call("demo_park_brush", -80.0, -60.0) + _demo_camera(Vector3(-80.0, 12.0, -60.0), 150.0, 32.0, 40.0) + _set_status("Demo 1/5: Hügel GEHOBEN (Radius 45 m, Stärke 10 m/s) — der grüne Ring ist der Pinsel.") + await _demo_shot(out_dir + "/terrain_raise.png") + + # 2) Senke senken (roter Ring). + terrain_mode_lower.button_pressed = true + terrain_tool.call("demo_stroke", 90.0, 50.0, TerrainField.Mode.LOWER, 1.2) + terrain_tool.call("demo_park_brush", 90.0, 50.0) + _demo_camera(Vector3(90.0, 2.0, 50.0), 150.0, 32.0, -50.0) + _set_status("Demo 2/5: Senke GESSENKT (Rechtsklick-Ziehen senkt, ohne umzuschalten).") + await _demo_shot(out_dir + "/terrain_lower.png") + + # 3) Hügelkuppe glätten (blauer Ring). + terrain_mode_smooth.button_pressed = true + terrain_tool.call("demo_stroke", -70.0, -50.0, TerrainField.Mode.SMOOTH, 0.9) + terrain_tool.call("demo_park_brush", -70.0, -50.0) + _demo_camera(Vector3(-70.0, 10.0, -50.0), 130.0, 35.0, 20.0) + _set_status("Demo 3/5: Kuppe GLÄTTET — rau wird weich, ohne die Form zu zerstören.") + await _demo_shot(out_dir + "/terrain_smooth.png") + + # 4) Undo-Roundtrip: kleiner Strich → Undo → Boden wieder flach. + terrain_mode_raise.button_pressed = true + terrain_radius_slider.value = 30.0 + terrain_tool.call("demo_stroke", 0.0, 150.0, TerrainField.Mode.RAISE, 0.9) + terrain_tool.call("demo_park_brush", 0.0, 150.0) + _demo_camera(Vector3(0.0, 8.0, 150.0), 120.0, 35.0, 0.0) + _set_status("Demo 4/5: Test-Hügel für den Undo-Vergleich geformt.") + await _demo_shot(out_dir + "/terrain_before_undo.png") + _do_undo() + _set_status("Demo 5/5: Strg+Z — der GANZE Strich ist zurück, der Boden ist wieder flach.") + await _demo_shot(out_dir + "/terrain_after_undo.png") + + print("TERRAIN_DEMO_OK: 5 Screenshots in %s" % out_dir) + + +func _demo_camera(target: Vector3, dist: float, pitch: float, yaw: float) -> void: + camera.target = target + camera.distance = dist + camera.pitch_deg = pitch + camera.yaw_deg = yaw + camera.call("_apply") + + +func _demo_shot(path: String) -> void: + for i in 5: + await get_tree().process_frame + await RenderingServer.frame_post_draw + var img := get_viewport().get_texture().get_image() + img.save_png(path) + print("DEMO_SHOT: %s (%d×%d)" % [path, img.get_width(), img.get_height()]) \ No newline at end of file diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index 7a67303..8d4e8cd 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -1,6 +1,6 @@ extends Node3D ## Baut die visuelle Repräsentation der Map im Editor-Viewport: -## Boden-Referenz (Plane in Terrain-Größe) + Maßstabsgitter + Himmel/Licht +## Terrain-Höhenfeld-Mesh + Maßstabsgitter + Himmel/Licht ## + Platzhalter-Meshes für Bibliotheks-Objekte + Spawn-Marker mit Beschriftung. ## ## ## Objekt-Marker werden in object_markers gemerkt (Meta: obj_index, @@ -9,7 +9,6 @@ extends Node3D const GRID_COLOR := Color(0.35, 0.45, 0.55, 0.6) const AXIS_COLOR := Color(0.9, 0.7, 0.2, 0.9) -const GROUND_COLOR := Color(0.23, 0.32, 0.24) const OBJECT_COLOR := Color(0.55, 0.75, 0.95) const SELECT_COLOR := Color(1.0, 0.6, 0.15) # Auswahl klar orange const TEAM_COLORS := [Color(0.3, 0.6, 1.0), Color(1.0, 0.35, 0.3)] # Blau / Rot @@ -24,6 +23,7 @@ const WEATHER_LIGHT := { var selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt) var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects) +var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant) var _base_mat: StandardMaterial3D var _select_mat: StandardMaterial3D @@ -57,12 +57,35 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void: var weather := str(data.get("weather", "mittag")) _build_environment(weather) - _build_ground(size_x, size_z, ground_y) + _build_terrain(data) _build_grid(size_x, size_z, ground_y) _build_objects(data.get("objects", []), manifest) _build_spawns(data.get("spawn_zones", {})) +## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1): +## der Terrain-Pinsel schreibt die Vertices live neu (terrain_view.update_terrain). +func _build_terrain(data: Dictionary) -> void: + terrain_view = Node3D.new() + terrain_view.set_script(load("res://src/viewport/terrain_mesh.gd")) + add_child(terrain_view) + terrain_view.call("rebuild_from_doc", data) + + +## Live-Update während eines Pinsel-Strichs (aus terrain_tool.gd): nur die +## Vertices werden neu geschrieben — Grid/Objekte/Marker bleiben unangetastet. +func update_terrain(heights: PackedFloat32Array) -> void: + if terrain_view != null and is_instance_valid(terrain_view): + terrain_view.call("update_heights", heights) + + +## Nur das Terrain neu aufbauen (nach Strich-Commit/Undo) — Objekt-Marker und +## Auswahl bleiben bestehen, kein Flackern der ganzen Szene. +func rebuild_terrain_from_doc(data: Dictionary) -> void: + if terrain_view != null and is_instance_valid(terrain_view): + terrain_view.call("rebuild_from_doc", data) + + func _build_environment(weather: String) -> void: var preset: Dictionary = WEATHER_LIGHT.get(weather, WEATHER_LIGHT["mittag"]) var env := Environment.new() @@ -90,19 +113,6 @@ func _build_environment(weather: String) -> void: add_child(sun) -func _build_ground(size_x: float, size_z: float, ground_y: float) -> void: - var mi := MeshInstance3D.new() - var plane := PlaneMesh.new() - plane.size = Vector2(size_x, size_z) - mi.mesh = plane - mi.position.y = ground_y - var mat := StandardMaterial3D.new() - mat.albedo_color = GROUND_COLOR - mat.roughness = 1.0 - mi.material_override = mat - add_child(mi) - - ## Maßstabsgitter: Linien alle 100 m, gelbe Mittelachsen (X rot? — Z-Achse ## blau wäre Godot-Konvention; hier neutral gelb für gute Sichtbarkeit). func _build_grid(size_x: float, size_z: float, ground_y: float) -> void: diff --git a/src/viewport/terrain_mesh.gd b/src/viewport/terrain_mesh.gd new file mode 100644 index 0000000..b4a872a --- /dev/null +++ b/src/viewport/terrain_mesh.gd @@ -0,0 +1,122 @@ +extends Node3D +## Terrain-Ansicht: baut das Höhenfeld (svmap terrain-Block) als Mesh im +## Editor-Viewport (M2, Karte t_9aba04f1). +## +## Zwei Wege: +## rebuild_from_doc(data) — nach Laden/Neu/Undo: Base64 dekodieren + Mesh neu. +## update_heights(...) — während eines Pinsel-Strichs: nur Vertices neu +## schreiben (SOFT-war das Mesh live — kein Rebuild, +## kein Flackern, res 129 ≈ 16,6k Punkte = flott). +## +## Höhen-Farbverlauf über Vertex-Farben (dunkles Grün im Tal → helles Grün auf +## dem Hügel): Sly sieht Steigungen sofort, ohne Licht-Tricks. + +const COLOR_LOW := Color(0.16, 0.26, 0.16) +const COLOR_HIGH := Color(0.45, 0.62, 0.34) + +var _mi: MeshInstance3D +var _res := 0 +var _size_x := 0.0 +var _size_z := 0.0 +var _triangles := 0 + + +func _ready() -> void: + _mi = MeshInstance3D.new() + var mat := StandardMaterial3D.new() + mat.vertex_color_use_as_albedo = true + mat.roughness = 1.0 + _mi.material_override = mat + add_child(_mi) + + +## Kompletter Neuaufbau aus dem svmap-terrain-Block. +func rebuild_from_doc(data: Dictionary) -> void: + var terrain: Dictionary = data.get("terrain", {}) + if terrain.is_empty(): + _triangles = 0 + if _mi != null: + _mi.mesh = null + return + var heights := SvmapIO.decode_heights(terrain) + update_heights(heights, int(terrain.get("resolution", 0)), + float(terrain.get("size_x_m", 1.0)), float(terrain.get("size_z_m", 1.0))) + + +## Mesh aus einem (neuen) Höhenfeld bauen — auch live während des Strichs. +## res/size 0 = zuletzt verwendete Werte weiterverwenden (Live-Pfad). +func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0.0, + size_z: float = 0.0) -> void: + if res < 2: + res = _res + if size_x <= 0.0: + size_x = _size_x + if size_z <= 0.0: + size_z = _size_z + if _mi == null or res < 2 or heights.size() < res * res or size_x <= 0.0 or size_z <= 0.0: + return + _res = res + _size_x = size_x + _size_z = size_z + var last := float(res - 1) + var positions := PackedVector3Array() + var normals := PackedVector3Array() + var colors := PackedColorArray() + var indices := PackedInt32Array() + positions.resize(res * res) + normals.resize(res * res) + colors.resize(res * res) + + var hmin := heights.min() + var hmax := heights.max() + var span := maxf(hmax - hmin, 0.0001) + var cell_x := size_x / last + var cell_z := size_z / last + + var z := 0 + while z < res: + var x := 0 + while x < res: + var idx := z * res + x + positions[idx] = Vector3( + -x / last * size_x + size_x * 0.5, + heights[idx], + -z / last * size_z + size_z * 0.5) + # Normale aus zentralen Differenzen (Rand: einseitig geklemmt). + var hx_l := heights[z * res + maxi(x - 1, 0)] + var hx_r := heights[z * res + mini(x + 1, res - 1)] + var hz_d := heights[maxi(z - 1, 0) * res + x] + var hz_u := heights[mini(z + 1, res - 1) * res + x] + normals[idx] = Vector3(hx_l - hx_r, 2.0 * cell_x, hz_d - hz_u).normalized() + var t := (heights[idx] - hmin) / span + colors[idx] = COLOR_LOW.lerp(COLOR_HIGH, t) + x += 1 + z += 1 + + z = 0 + while z < res - 1: + var x := 0 + while x < res - 1: + var i := z * res + x + indices.append_array([i, i + res, i + 1, i + 1, i + res, i + res + 1]) + x += 1 + z += 1 + + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = positions + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_COLOR] = colors + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + _mi.mesh = mesh + _triangles = indices.size() / 3 + + +func triangle_count() -> int: + return _triangles + + +func resolution() -> int: + return _res diff --git a/tests/test_terrain.gd b/tests/test_terrain.gd new file mode 100644 index 0000000..d063482 --- /dev/null +++ b/tests/test_terrain.gd @@ -0,0 +1,238 @@ +extends SceneTree +## Editor-Tests (headless): Karte t_9aba04f1 — Terrain-Werkzeug (Höhenfeld +## heben/senken/glätten, Pinsel-Mathematik, Stroke-Undo, svmap-Roundtrip). +## Ausführen: godot --headless --path . --script res://tests/test_terrain.gd +## Exit 0 = alles grün. GUI-Seiten (Ring sichtbar, Mesh folgt live) werden im +## Desktop-Smoke mit Screenshots verifiziert (--terrain-demo), nicht hier. + +var failures := 0 +var checks := 0 + + +func _init() -> void: + _run_tests() + 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) + + +func _run_tests() -> void: + _test_encode_roundtrip() + _test_brush_raise_lower() + _test_brush_smooth() + _test_stroke_undo_redo() + _test_range_expansion() + _test_svmap_roundtrip() + _test_resolution_limit() + + +## Decode → Encode bleibt innerhalb eines Quantisierungsschritts (R16). +func _test_encode_roundtrip() -> void: + print("Testgruppe: R16-Encode/Decode-Roundtrip") + var doc := SvmapDocument.create_new("Quant", 1024.0, "mittag") + var terrain: Dictionary = doc["terrain"] + var heights := SvmapIO.decode_heights(terrain) + _check(heights.size() == 129 * 129, "Neue Map: 129×129 Höhenpunkte (Editor-MVP-Limit)") + for i in heights.size(): + heights[i] = 13.0 + float(i % 17) * 1.5 + var b64 := TerrainField.encode(heights, 0.0, 50.0) + var terrain2: Dictionary = terrain.duplicate(true) + terrain2["heightmap_r16_b64"] = b64 + var back := SvmapIO.decode_heights(terrain2) + var worst := 0.0 + for i in heights.size(): + worst = maxf(worst, absf(back[i] - heights[i])) + _check(worst <= 50.0 / 65535.0 + 0.0001, + "Encode-Fehler ≤ ½ Quantisierungsschritt (worst %.6f m)" % worst) + + +## Heben/Senken: Kosinus-Falloff, außerhalb des Radius nichts verändert, +## Senken unter min_height geht dank Range-Expansion (getestet separat). +func _test_brush_raise_lower() -> void: + print("Testgruppe: Pinsel heben/senken") + var res := 33 + var size := 320.0 + var heights := PackedFloat32Array() + heights.resize(res * res) + TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0, + 0.0, 0.0, 40.0, TerrainField.Mode.RAISE, 10.0) + var center := heights[16 * res + 16] + var mid := heights[16 * res + 18] # 20 m vom Zentrum (Zelle = 10 m) + var far := heights[2 * res + 16] # 140 m weg — außerhalb des Radius + _check(center > 9.5, "Heben: Zentrum um ~10 m angehoben (ist %.2f)" % center) + _check(mid > 0.0 and mid < center, "Heben: Falloff zwischen Zentrum und Rand") + _check(far == 0.0, "Heben: außerhalb des Radius unverändert") + var before := heights.duplicate() + TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0, + 0.0, 0.0, 40.0, TerrainField.Mode.LOWER, 4.0) + _check(heights[16 * res + 16] < center, "Senken: Zentrum wieder tiefer") + var diff := false + for i in heights.size(): + if not before.is_empty() and absf(heights[i] - before[i]) > 0.0001: + diff = true + break + _check(diff, "Senken: Feld hat sich tatsächlich geändert") + + +## Glätten: Variance sinkt IN DER PINSEL-FLÄCHE, Form bleibt (Mittel-Mischung). +func _test_brush_smooth() -> void: + print("Testgruppe: Pinsel glätten") + var res := 33 + var size := 320.0 + var heights := PackedFloat32Array() + heights.resize(res * res) + # Zackiger Sägezahn in der Pinsel-Mitte. + for i in heights.size(): + var x := i % res + heights[i] = 10.0 + (5.0 if x % 2 == 0 else -5.0) + var variance_before := _variance_block(heights, res, 14, 18, 14, 18) + var moved := false + for pass_i in 2: + moved = TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0, + 160.0, 160.0, 60.0, TerrainField.Mode.SMOOTH, 1.0) or moved + _check(moved, "Glätten: Feld wurde verändert") + var variance_after := _variance_block(heights, res, 14, 18, 14, 18) + _check(variance_after < variance_before * 0.4, + "Glätten: Varianz in der Pinsel-Fläche deutlich gesunken (%.1f → %.1f)" % [ + variance_before, variance_after]) + _check(absf(_mean(heights) - 10.0) < 1.0, + "Glätten: Gesamt-Mittelhöhe bleibt ~10 m (Form bleibt)") + + +## Der GANZE Stroke = EIN Undo-Eintrag (Chef-Kriterium): viele Einzel-Schritte +## auf derselben Stelle → Strg+Z stellt die Form VOR dem Strich wieder her. +func _test_stroke_undo_redo() -> void: + print("Testgruppe: Stroke-Undo (ganzer Strich)") + var doc := SvmapDocument.create_new("Stroke", 1024.0, "mittag") + var hist := ActionHistory.new() + hist.begin(doc) + var terrain0: Dictionary = doc["terrain"] + var heights := SvmapIO.decode_heights(terrain0) + + # Ein Stroke = 12 Anwendungs-Schritte + EIN commit am Ende (wie terrain_tool). + var before: Dictionary = hist.snapshot(["terrain"]) + for step in 12: + TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, 0.0, 50.0, + 0.0, 0.0, 60.0, TerrainField.Mode.RAISE, 0.8) + terrain0["heightmap_r16_b64"] = TerrainField.encode(heights, 0.0, 50.0) + hist.commit("Terrain geformt (Heben)", before, hist.snapshot(["terrain"])) + _check(hist.entries.size() == 1, "12 Pinsel-Schritte → EIN History-Eintrag") + + var after_b64 := str(terrain0["heightmap_r16_b64"]) + var raised := SvmapIO.decode_heights(terrain0) + _check(raised[64 * 129 + 64] > 5.0, "Stroke: Mitte ist deutlich angehoben") + + var desc := hist.undo() + _check(desc == "Terrain geformt (Heben)", "Undo-Desc nennt den Stroke") + _check(str(terrain0["heightmap_r16_b64"]) != after_b64, "Undo: Höhenfeld geändert") + var restored := SvmapIO.decode_heights(terrain0) + _check(absf(restored[64 * 129 + 64]) < 0.01, "Undo: Boden wieder FLACH (ganzer Strich)") + + desc = hist.redo() + _check(desc == "Terrain geformt (Heben)", "Redo-Desc stimmt") + _check(str(terrain0["heightmap_r16_b64"]) == after_b64, + "Redo: byte-identisches Höhenfeld zurück (kein Quantisierungs-Drift)") + + +## Range-Expansion: Senken unter min_height und Heben über max_height +## weiten min/max_height_m automatisch auf (Format-gültig, Spannweite ≤ 500). +func _test_range_expansion() -> void: + print("Testgruppe: Range-Expansion (Senken auf flacher Map)") + var doc := SvmapDocument.create_new("Dig", 1024.0, "mittag") + var terrain: Dictionary = doc["terrain"] + var heights := SvmapIO.decode_heights(terrain) # alles 0 = min_height + # Wie terrain_tool: ohne Clamp anwenden, Range danach expandieren. + TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0, + 0.0, 0.0, 50.0, TerrainField.Mode.LOWER, 6.0) + _check(heights[64 * 129 + 64] < -5.0, "Senken UNTER 0 m möglich (kein Clamp an min_height)") + var hmin := floorf(heights.min() - 1.0) + var hmax := maxf(float(terrain["max_height_m"]), ceilf(heights.max() + 1.0)) + terrain["min_height_m"] = hmin + terrain["max_height_m"] = hmax + terrain["heightmap_r16_b64"] = TerrainField.encode(heights, hmin, hmax) + var path := "/tmp/scopedvr_test_terrain_dig.svmap" + var save := SvmapIO.save_to_file(path, doc) + _check(save["ok"], "Expandierte Map bleibt V1–V14-gültig (Speichern 0 Fehler)") + var load := SvmapIO.load_from_file(path) + _check(load["ok"], "Expandierte Map lädt sauber zurück (inkl. Prüfsumme)") + var reloaded := SvmapIO.decode_heights(load["data"]["terrain"]) + _check(absf(reloaded[64 * 129 + 64] - heights[64 * 129 + 64]) < 0.01, + "Range-Expansion: Tiefe überlebt Encode/Decode") + + +## svmap-Roundtrip: geformtes Terrain speichern → laden → Höhenfeld byte-identisch, +## Prüfsumme gültig, Loader nimmt die Map an. +func _test_svmap_roundtrip() -> void: + print("Testgruppe: svmap-Roundtrip (Speichern/Laden)") + var doc := SvmapDocument.create_new("Roundtrip", 1024.0, "mittag") + var terrain: Dictionary = doc["terrain"] + var heights := SvmapIO.decode_heights(terrain) + TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0, + -200.0, 100.0, 80.0, TerrainField.Mode.RAISE, 18.0) + TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0, + 300.0, -300.0, 60.0, TerrainField.Mode.LOWER, 9.0) + terrain["min_height_m"] = floorf(heights.min() - 1.0) + terrain["max_height_m"] = ceilf(heights.max() + 1.0) + terrain["heightmap_r16_b64"] = TerrainField.encode(heights, float(terrain["min_height_m"]), float(terrain["max_height_m"])) + + var path := "/tmp/scopedvr_test_terrain_roundtrip.svmap" + var save := SvmapIO.save_to_file(path, doc) + _check(save["ok"], "Speichern: 0 Fehler (Validierung V1–V14 vor dem Schreiben)") + var load := SvmapIO.load_from_file(path) + _check(load["ok"], "Laden: 0 Fehler (inkl. Prüfsumme)") + var t_in: Dictionary = load["data"]["terrain"] + _check(str(t_in["heightmap_r16_b64"]) == str(terrain["heightmap_r16_b64"]), + "Höhenfeld-Diff LEER (byte-identisch nach Speichern→Laden)") + _check(absf(float(t_in["min_height_m"]) - float(terrain["min_height_m"])) < 0.0001 + and absf(float(t_in["max_height_m"]) - float(terrain["max_height_m"])) < 0.0001, + "Höhen-Range bleibt erhalten") + + +func _test_resolution_limit() -> void: + print("Testgruppe: Auflösungs-Limit (Editor-MVP)") + _check(TerrainField.EDITOR_MAX_RESOLUTION == 129, "EDITOR_MAX_RESOLUTION = 129") + _check(SvmapDocument.DEFAULT_RESOLUTION == 129, "Neue Maps: res 129 (Perf-Limit)") + _check(SvmapIO.MAX_TERRAIN_RESOLUTION == 513, "Format-Limit 513 unverändert (Interop)") + + +func _variance_block(a: PackedFloat32Array, res: int, x0: int, x1: int, z0: int, z1: int) -> float: + var s := 0.0 + var count := 0 + var z := z0 + while z <= z1: + var x := x0 + while x <= x1: + s += a[z * res + x] + count += 1 + x += 1 + z += 1 + var block_mean := s / float(count) + var sq := 0.0 + z = z0 + while z <= z1: + var x := x0 + while x <= x1: + var d := a[z * res + x] - block_mean + sq += d * d + x += 1 + z += 1 + return sq / float(count) + + +func _mean(a: PackedFloat32Array) -> float: + var s := 0.0 + for v in a: + s += v + return s / float(a.size())