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 ------
|
||||
|
||||
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:
|
||||
|
|
|
|||
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.
|
||||
|
||||
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"
|
||||
|
|
|
|||
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 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"
|
||||
|
|
@ -876,3 +1085,76 @@ func _maybe_start_demo_mode() -> void:
|
|||
camera.pitch_deg = 32.0
|
||||
camera.yaw_deg = 40.0
|
||||
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
|
||||
## 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:
|
||||
|
|
|
|||
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