M2-Karte t_9aba04f1: Terrain-Werkzeug — Höhenfeld heben/senken/glätten (Pinsel + Undo)
TerrainField (src/svmap/terrain_field.gd): Höhenfeld-Mathematik auf dekodiertem PackedFloat32Array — Pinsel mit Kosinus-Falloff (RAISE/LOWER/SMOOTH, zweiphasiges 3x3-Glättung), R16-Encode (uint16 LE row-major), ray-march+Bisektion-Cursor auf dem GELÄNDE (nicht Basis-Ebene). EDITOR_MAX_RESOLUTION=129 (Editor-MVP-Perf-Limit, Format bleibt 17-513 für Interop). TerrainMeshView (src/viewport/terrain_mesh.gd): Höhenfeld als ArrayMesh mit Höhen-Farbverlauf (Vertex-Farben); update_heights schreibt Vertices live neu während des Strichs (res 129 = 16,6k Punkte, flott). Ersetzt die flache Boden-Plane in map_view_builder (rebuild + rebuild_terrain_from_doc + update_terrain). TerrainTool (src/editor/terrain_tool.gd): Pinsel-Modus im Viewport — Linksklick- Ziehen formt (Heben/Senken/Glättten nach Panel-Wahl), Rechtsklick-Ziehen das Gegenteil (kein Umschalten nötig). Pinsel-Ring (Torus) folgt dem Cursor auf dem Terrain, farbcodiert pro Modus. GANZER Strich = EIN ActionHistory-Eintrag (snapshot terrain VOR dem Strich, commit NACH dem Encode). Range-Expansion: min/max_height_m weiten sich automatisch auf, wenn der Pinsel drüber hinaus will (sonst wäre Senken auf flacher Map unmöglich) — Spannweite ≤ 500 m (V6), Undo stellt die Range mit zurück. Doc wird nur ZUM Strich-Ende verändert. main.gd: Werkzeug-Button 'Terrain formen' (groß, deutsch), Terrain-Panel mit Heben/Senken/Glättten-Buttons + Pinsel-Größe/Stärke-Slider mit Live-Werten, Modus-Wechsel objects<->terrain (Bibliothek-Klick wechselt zurück, ESC beendet), interaction.gd ruht im Terrain-Modus. Performance-Zähler in der Statusleiste: Terrain-Grid + Dreiecke (+ % vom Quest-3-Budget 524.288) + Editor-FPS. Demo-Hook --terrain-demo [ordner]: 5 Szenarien inkl. Undo-Roundtrip als PNGs. Neue Maps: res 129 (vorher 257) — erfüllt das Editor-MVP-Perf-Limit automatisch.
This commit is contained in:
parent
9b377a7720
commit
30379bcc7b
8 changed files with 1197 additions and 28 deletions
|
|
@ -264,6 +264,8 @@ func delete_selected() -> void:
|
||||||
# ------------------------------------------------------------ Eingaben ------
|
# ------------------------------------------------------------ Eingaben ------
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
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:
|
if event is InputEventMouseMotion:
|
||||||
var mm := event as InputEventMouseMotion
|
var mm := event as InputEventMouseMotion
|
||||||
if placing:
|
if placing:
|
||||||
|
|
|
||||||
314
src/editor/terrain_tool.gd
Normal file
314
src/editor/terrain_tool.gd
Normal file
|
|
@ -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
|
||||||
|
|
@ -6,7 +6,9 @@ class_name SvmapDocument
|
||||||
##
|
##
|
||||||
## Alles static + reine Funktionen: der Besitzer des Dicts ist main.gd.
|
## 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_MIN_HEIGHT := 0.0
|
||||||
const DEFAULT_MAX_HEIGHT := 50.0
|
const DEFAULT_MAX_HEIGHT := 50.0
|
||||||
const DEFAULT_TARGET_GAME_VERSION := "0.1.0"
|
const DEFAULT_TARGET_GAME_VERSION := "0.1.0"
|
||||||
|
|
|
||||||
199
src/svmap/terrain_field.gd
Normal file
199
src/svmap/terrain_field.gd
Normal file
|
|
@ -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)
|
||||||
302
src/ui/main.gd
302
src/ui/main.gd
|
|
@ -39,12 +39,16 @@ var dirty := false
|
||||||
|
|
||||||
var history := ActionHistory.new()
|
var history := ActionHistory.new()
|
||||||
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
|
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
|
# UI-Referenzen
|
||||||
var content_tree: Tree
|
var content_tree: Tree
|
||||||
var stats_objects: Label
|
var stats_objects: Label
|
||||||
|
var stats_terrain: Label
|
||||||
var status_label: Label
|
var status_label: Label
|
||||||
var file_label: Label
|
var file_label: Label
|
||||||
|
var help_label: Label
|
||||||
var view_builder: Node3D
|
var view_builder: Node3D
|
||||||
var camera: Camera3D
|
var camera: Camera3D
|
||||||
var library_box: VBoxContainer
|
var library_box: VBoxContainer
|
||||||
|
|
@ -65,6 +69,17 @@ var prop_version: SpinBox
|
||||||
var prop_weather: OptionButton
|
var prop_weather: OptionButton
|
||||||
var terrain_info: Label
|
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
|
# Dialoge
|
||||||
var new_dialog: AcceptDialog
|
var new_dialog: AcceptDialog
|
||||||
var open_dialog: FileDialog
|
var open_dialog: FileDialog
|
||||||
|
|
@ -180,6 +195,16 @@ func _build_ui() -> void:
|
||||||
stats_objects.modulate = Color(1, 1, 1, 0.75)
|
stats_objects.modulate = Color(1, 1, 1, 0.75)
|
||||||
status_h.add_child(stats_objects)
|
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 ---
|
# --- Dialoge ---
|
||||||
new_dialog = preload("res://src/ui/new_map_dialog.gd").new()
|
new_dialog = preload("res://src/ui/new_map_dialog.gd").new()
|
||||||
new_dialog.confirmed.connect(_on_new_map_confirmed)
|
new_dialog.confirmed.connect(_on_new_map_confirmed)
|
||||||
|
|
@ -293,15 +318,21 @@ func _build_center_viewport() -> SubViewportContainer:
|
||||||
interaction.setup(self, camera, view_builder)
|
interaction.setup(self, camera, view_builder)
|
||||||
camera.set("wheel_filter", Callable(interaction, "wheel_blocked"))
|
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)
|
# Bedien-Hinweis im Viewport (Overlay)
|
||||||
var help := Label.new()
|
help_label = 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_label.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_label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
||||||
help.offset_left = 12
|
help_label.offset_left = 12
|
||||||
help.offset_bottom = -8
|
help_label.offset_bottom = -8
|
||||||
help.offset_top = -46
|
help_label.offset_top = -46
|
||||||
help.modulate = Color(1, 1, 1, 0.65)
|
help_label.modulate = Color(1, 1, 1, 0.65)
|
||||||
container.add_child(help)
|
container.add_child(help_label)
|
||||||
return container
|
return container
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -377,7 +408,62 @@ func _build_right_panel() -> PanelContainer:
|
||||||
terrain_info.text = ""
|
terrain_info.text = ""
|
||||||
terrain_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
terrain_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
vbox.add_child(terrain_info)
|
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)
|
# Signale (Änderungen → Dokument + Ansicht)
|
||||||
prop_name.text_changed.connect(_on_prop_changed.bind("name"))
|
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)
|
interaction.rotate_selected(delta)
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
KEY_ESCAPE:
|
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()
|
interaction.cancel_placement()
|
||||||
_set_status("Platzieren abgebrochen.")
|
_set_status("Platzieren abgebrochen.")
|
||||||
elif interaction.selected_index != -1:
|
elif interaction.selected_index != -1:
|
||||||
|
|
@ -589,6 +679,8 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||||
# --------------------------------------------------------- Bibliothek ------
|
# --------------------------------------------------------- Bibliothek ------
|
||||||
|
|
||||||
func _on_library_pressed(oid: String) -> void:
|
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:
|
if interaction.placing and interaction.place_oid == oid:
|
||||||
# Nochmal geklickt → Modus beenden (An/Aus ohne Umwege).
|
# Nochmal geklickt → Modus beenden (An/Aus ohne Umwege).
|
||||||
interaction.cancel_placement()
|
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.")
|
_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.
|
## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt.
|
||||||
func _on_tree_selected() -> void:
|
func _on_tree_selected() -> void:
|
||||||
if _tree_select_lock:
|
if _tree_select_lock:
|
||||||
|
|
@ -736,6 +929,7 @@ func _after_structure_change() -> void:
|
||||||
interaction.call("clear_selection", true)
|
interaction.call("clear_selection", true)
|
||||||
view_builder.call("rebuild", doc, manifest)
|
view_builder.call("rebuild", doc, manifest)
|
||||||
_refresh_tree()
|
_refresh_tree()
|
||||||
|
_recheck_terrain_mode()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- Anzeige ---
|
# --------------------------------------------------------------- Anzeige ---
|
||||||
|
|
@ -764,6 +958,7 @@ func _refresh_all() -> void:
|
||||||
_refresh_file_label()
|
_refresh_file_label()
|
||||||
_refresh_history_buttons()
|
_refresh_history_buttons()
|
||||||
_refresh_viewport()
|
_refresh_viewport()
|
||||||
|
_recheck_terrain_mode()
|
||||||
|
|
||||||
|
|
||||||
func _refresh_viewport() -> void:
|
func _refresh_viewport() -> void:
|
||||||
|
|
@ -810,6 +1005,17 @@ func _refresh_stats() -> void:
|
||||||
var budget := int(SvmapIO.MAX_TOTAL_POLY)
|
var budget := int(SvmapIO.MAX_TOTAL_POLY)
|
||||||
stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%)" % [
|
stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%)" % [
|
||||||
count, _fmt_int(int(poly)), _fmt_int(budget), 100.0 * poly / budget]
|
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:
|
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).
|
## Screenshot die Vorschau zeigen kann (nur für Smoke-Tests).
|
||||||
func _maybe_start_demo_mode() -> void:
|
func _maybe_start_demo_mode() -> void:
|
||||||
var args := OS.get_cmdline_user_args()
|
var args := OS.get_cmdline_user_args()
|
||||||
|
if args.has("--terrain-demo"):
|
||||||
|
_start_terrain_demo(args)
|
||||||
|
return
|
||||||
if not args.has("--ghost-demo"):
|
if not args.has("--ghost-demo"):
|
||||||
return
|
return
|
||||||
var oid := "crate_wood"
|
var oid := "crate_wood"
|
||||||
|
|
@ -876,3 +1085,76 @@ func _maybe_start_demo_mode() -> void:
|
||||||
camera.pitch_deg = 32.0
|
camera.pitch_deg = 32.0
|
||||||
camera.yaw_deg = 40.0
|
camera.yaw_deg = 40.0
|
||||||
camera.call("_apply")
|
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()])
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
extends Node3D
|
extends Node3D
|
||||||
## Baut die visuelle Repräsentation der Map im Editor-Viewport:
|
## 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.
|
## + Platzhalter-Meshes für Bibliotheks-Objekte + Spawn-Marker mit Beschriftung.
|
||||||
## ##
|
## ##
|
||||||
## Objekt-Marker werden in object_markers gemerkt (Meta: obj_index,
|
## 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 GRID_COLOR := Color(0.35, 0.45, 0.55, 0.6)
|
||||||
const AXIS_COLOR := Color(0.9, 0.7, 0.2, 0.9)
|
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 OBJECT_COLOR := Color(0.55, 0.75, 0.95)
|
||||||
const SELECT_COLOR := Color(1.0, 0.6, 0.15) # Auswahl klar orange
|
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
|
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 selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt)
|
||||||
var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects)
|
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 _base_mat: StandardMaterial3D
|
||||||
var _select_mat: StandardMaterial3D
|
var _select_mat: StandardMaterial3D
|
||||||
|
|
@ -57,12 +57,35 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
|
||||||
var weather := str(data.get("weather", "mittag"))
|
var weather := str(data.get("weather", "mittag"))
|
||||||
|
|
||||||
_build_environment(weather)
|
_build_environment(weather)
|
||||||
_build_ground(size_x, size_z, ground_y)
|
_build_terrain(data)
|
||||||
_build_grid(size_x, size_z, ground_y)
|
_build_grid(size_x, size_z, ground_y)
|
||||||
_build_objects(data.get("objects", []), manifest)
|
_build_objects(data.get("objects", []), manifest)
|
||||||
_build_spawns(data.get("spawn_zones", {}))
|
_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:
|
func _build_environment(weather: String) -> void:
|
||||||
var preset: Dictionary = WEATHER_LIGHT.get(weather, WEATHER_LIGHT["mittag"])
|
var preset: Dictionary = WEATHER_LIGHT.get(weather, WEATHER_LIGHT["mittag"])
|
||||||
var env := Environment.new()
|
var env := Environment.new()
|
||||||
|
|
@ -90,19 +113,6 @@ func _build_environment(weather: String) -> void:
|
||||||
add_child(sun)
|
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
|
## Maßstabsgitter: Linien alle 100 m, gelbe Mittelachsen (X rot? — Z-Achse
|
||||||
## blau wäre Godot-Konvention; hier neutral gelb für gute Sichtbarkeit).
|
## 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:
|
func _build_grid(size_x: float, size_z: float, ground_y: float) -> void:
|
||||||
|
|
|
||||||
122
src/viewport/terrain_mesh.gd
Normal file
122
src/viewport/terrain_mesh.gd
Normal file
|
|
@ -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
|
||||||
238
tests/test_terrain.gd
Normal file
238
tests/test_terrain.gd
Normal file
|
|
@ -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())
|
||||||
Loading…
Reference in a new issue