Terrain-Pinsel: Kante-Regler (Falloff), Einebnen, Rampe, Druck-Stärke, Mal-Slot-Ersatz

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-10-25 09:52:03 +01:00
parent b1d8f7626d
commit 26bcbe0cdd
9 changed files with 469 additions and 61 deletions

View file

@ -60,6 +60,8 @@ const ACTIONS := {
"editor_brush_lower": [KEY_2], "editor_brush_lower": [KEY_2],
"editor_brush_smooth": [KEY_3], "editor_brush_smooth": [KEY_3],
"editor_brush_paint": [KEY_4], "editor_brush_paint": [KEY_4],
"editor_brush_flatten": [KEY_5],
"editor_brush_ramp": [KEY_6],
# --- Datei & Undo (Karte t_11b5c25b, Karte 3/3) -------------------------- # --- Datei & Undo (Karte t_11b5c25b, Karte 3/3) --------------------------
# Strg+Z/Strg+Y laufen seit dem M2-UX-Fundament; hier NACHGEBUGGEN als # Strg+Z/Strg+Y laufen seit dem M2-UX-Fundament; hier NACHGEBUGGEN als
# dokumentierte Actions (alle 3 Shortcut-Karten: ACTIONS-Dict = Source of # dokumentierte Actions (alle 3 Shortcut-Karten: ACTIONS-Dict = Source of

View file

@ -20,7 +20,8 @@ extends Node3D
## liest daraus. Undo/Redo läuft über die gemeinsame ActionHistory (terrain-Block). ## 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 SMOOTH_RATE_DIVISOR := 20.0 # Stärke-Slider (m/s) → Glätt-Tempo (Anteil/s)
const MODE_LABELS := ["Heben", "Senken", "Glätten", "Malen"] const MODE_LABELS := ["Heben", "Senken", "Glätten", "Malen", "Einebnen", "Rampe"]
const FLATTEN_RATE_DIVISOR := 8.0 # Stärke-Slider → Einebnen-Tempo (Anteil/s)
var main # Hauptfenster (script-erweitert, Variant) var main # Hauptfenster (script-erweitert, Variant)
var camera: Camera3D var camera: Camera3D
@ -32,6 +33,10 @@ var brush_mode: int = TerrainField.Mode.RAISE
var brush_radius := 12.0 # Meter var brush_radius := 12.0 # Meter
## Kante beim Malen: 0 = hart, 1 = ganz weich (Karte 25.10.). ## Kante beim Malen: 0 = hart, 1 = ganz weich (Karte 25.10.).
var brush_weichheit := 0.4 var brush_weichheit := 0.4
## Pinsel-Falloff (Karte 25.10.): 0 = hart (linear), 1 = weich (quadratisch).
var brush_falloff := 0.5
## Druck 0.05–1 (Pen-Druck / Controller-Trigger / Maus = 1.0), skaliert die Stärke.
var pressure := 1.0
var brush_strength := 8.0 # Meter pro Sekunde (Heben/Senken) var brush_strength := 8.0 # Meter pro Sekunde (Heben/Senken)
var paint_material: int = 0 # Material-ID im Malen-Modus (0=Gras, 1=Sand, 2=Fels) var paint_material: int = 0 # Material-ID im Malen-Modus (0=Gras, 1=Sand, 2=Fels)
@ -39,6 +44,10 @@ var paint_material: int = 0 # Material-ID im Malen-Modus (0=Gras, 1=Sand, 2=Fels
var _stroking := false var _stroking := false
var _stroke_mode: int = TerrainField.Mode.RAISE var _stroke_mode: int = TerrainField.Mode.RAISE
var _stroke_material: int = 0 var _stroke_material: int = 0
var _flatten_target := 0.0 # Einebnen: Höhe unter dem Cursor beim Strichstart
var _ramp_a := Vector2.ZERO # Rampe: Startpunkt (Welt-X/Z) + Höhe
var _ramp_ha := 0.0
var _ramp_b := Vector2.ZERO # Rampe: aktueller Endpunkt
var _heights := PackedFloat32Array() var _heights := PackedFloat32Array()
var _mats := PackedByteArray() # Material-Schicht (res²) — seit _refresh_ var _mats := PackedByteArray() # Material-Schicht (res²) — seit _refresh_
# _from_doc immer dekodiert (t_55ed041a), # _from_doc immer dekodiert (t_55ed041a),
@ -56,6 +65,7 @@ var _cursor := Vector3.ZERO
# Pinsel-Ring # Pinsel-Ring
var _ring: MeshInstance3D var _ring: MeshInstance3D
var _ramp_line: MeshInstance3D # Rampen-Vorschau: Linie Start → Cursor
var _ring_radius := -1.0 var _ring_radius := -1.0
var _ring_mode := -1 var _ring_mode := -1
@ -74,6 +84,15 @@ func _ensure_ring() -> void:
_ring = MeshInstance3D.new() _ring = MeshInstance3D.new()
_ring.visible = false _ring.visible = false
add_child(_ring) add_child(_ring)
_ramp_line = MeshInstance3D.new()
_ramp_line.visible = false
_ramp_line.mesh = ImmediateMesh.new()
var lm := StandardMaterial3D.new()
lm.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
lm.albedo_color = Color(1.0, 0.85, 0.3)
lm.no_depth_test = true
_ramp_line.material_override = lm
add_child(_ramp_line)
# ------------------------------------------------------------ Aktivierung --- # ------------------------------------------------------------ Aktivierung ---
@ -102,6 +121,8 @@ func deactivate() -> void:
_end_stroke() _end_stroke()
if _ring != null and is_instance_valid(_ring): if _ring != null and is_instance_valid(_ring):
_ring.visible = false _ring.visible = false
if _ramp_line != null and is_instance_valid(_ramp_line):
_ramp_line.visible = false
## Nach Undo/Redo/Laden/Neue-Map: Feld neu dekodieren (Cache ist sonst alt). ## Nach Undo/Redo/Laden/Neue-Map: Feld neu dekodieren (Cache ist sonst alt).
@ -177,6 +198,16 @@ func set_weichheit(w: float) -> void:
brush_weichheit = clampf(w, 0.0, 1.0) brush_weichheit = clampf(w, 0.0, 1.0)
func set_falloff(f: float) -> void:
brush_falloff = clampf(f, 0.0, 1.0)
## Druck setzen (XR-Controller-Trigger 0–1 ruft das direkt; Pen/Maus laufen
## über _unhandled_input). Wert wird über TerrainField.pressure_of begrenzt.
func set_pressure(p: float) -> void:
pressure = TerrainField.pressure_of(p, 0.0)
func set_strength(mps: float) -> void: func set_strength(mps: float) -> void:
brush_strength = clampf(mps, 1.0, 20.0) brush_strength = clampf(mps, 1.0, 20.0)
@ -192,7 +223,10 @@ func _unhandled_input(event: InputEvent) -> void:
if not active: if not active:
return return
if event is InputEventMouseMotion: if event is InputEventMouseMotion:
_update_cursor((event as InputEventMouseMotion).position) var mm := event as InputEventMouseMotion
_update_cursor(mm.position)
# Stift: pressure > 0; Maus meldet 0 (oder 1) → bleibt voll.
pressure = TerrainField.pressure_of(mm.pressure, _trigger_axis())
return return
if event is InputEventMouseButton: if event is InputEventMouseButton:
var mb := event as InputEventMouseButton var mb := event as InputEventMouseButton
@ -220,6 +254,15 @@ func _unhandled_input(event: InputEvent) -> void:
# t_2470de89), in jedem Werkzeugzustand. # t_2470de89), in jedem Werkzeugzustand.
## Gamepad-Trigger (rechts) des ersten Pads; 0 ohne Pad. Nur Eingabe-Lesen,
## keine OS-Annahme.
func _trigger_axis() -> float:
var pads := Input.get_connected_joypads()
if pads.is_empty():
return 0.0
return Input.get_joy_axis(pads[0], JOY_AXIS_TRIGGER_RIGHT)
func _update_cursor(viewport_pos: Vector2) -> void: func _update_cursor(viewport_pos: Vector2) -> void:
var hit: Variant = TerrainField.raycast(_heights, _res, _sx, _sz, var hit: Variant = TerrainField.raycast(_heights, _res, _sx, _sz,
camera.project_ray_origin(viewport_pos), camera.project_ray_normal(viewport_pos)) camera.project_ray_origin(viewport_pos), camera.project_ray_normal(viewport_pos))
@ -256,6 +299,12 @@ func _begin_stroke(mode: int, material_id: int = -1) -> void:
_stroke_mode = mode _stroke_mode = mode
_stroke_material = paint_material if material_id < 0 else material_id _stroke_material = paint_material if material_id < 0 else material_id
_stroking = true _stroking = true
if mode == TerrainField.Mode.FLATTEN:
_flatten_target = _height_at_stroke(_cursor.x, _cursor.z)
elif mode == TerrainField.Mode.RAMP:
_ramp_a = Vector2(_cursor.x, _cursor.z)
_ramp_ha = _height_at_stroke(_cursor.x, _cursor.z)
_ramp_b = _ramp_a
# Im Malen-Modus lebt die Material-Schicht während des Strichs # Im Malen-Modus lebt die Material-Schicht während des Strichs
# (dekodiert — Legacy-Maps ohne Schicht starten mit alles-Gras). # (dekodiert — Legacy-Maps ohne Schicht starten mit alles-Gras).
if mode == TerrainField.Mode.PAINT: if mode == TerrainField.Mode.PAINT:
@ -288,11 +337,12 @@ func _process(delta: float) -> void:
view_builder.call("update_terrain", _heights, _mats, _splat_rect) view_builder.call("update_terrain", _heights, _mats, _splat_rect)
_ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3
return return
var amount: float if _stroke_mode == TerrainField.Mode.RAMP:
if _stroke_mode == TerrainField.Mode.SMOOTH: # Rampe wirkt erst beim Loslassen — hier nur der Endpunkt + Ring-Hinweis.
amount = clampf(brush_strength / SMOOTH_RATE_DIVISOR * delta, 0.0, 1.0) _ramp_b = Vector2(_cursor.x, _cursor.z)
else: _update_ramp_line()
amount = brush_strength * delta return
var amount := _step_amount(_stroke_mode, delta)
# Während des Strichs NICHT auf die Format-Range clampen — die zieht der # 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). # Range-Expander danach nach (sonst friert Heben/Senken an der Kante ein).
var changed := _height_step(_cursor.x, _cursor.z, amount) var changed := _height_step(_cursor.x, _cursor.z, amount)
@ -306,6 +356,17 @@ func _process(delta: float) -> void:
_ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3
## Stärke dieses Frames: Meter (Heben/Senken) bzw. Mischfaktor 0–1 (Glätten/
## Einebnen) — skaliert mit dem Druck.
func _step_amount(mode: int, dt: float) -> float:
match mode:
TerrainField.Mode.SMOOTH:
return clampf(brush_strength / SMOOTH_RATE_DIVISOR * dt * pressure, 0.0, 1.0)
TerrainField.Mode.FLATTEN:
return clampf(brush_strength / FLATTEN_RATE_DIVISOR * dt * pressure, 0.0, 1.0)
return brush_strength * dt * pressure
## Höhe spannt das Höhenfeld automatisch auf, wenn der Pinsel über die ## 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 ## 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. ## frisch angelegten (flachen) Map unmöglich und langes Heben würde clampen.
@ -329,6 +390,11 @@ func _end_stroke() -> void:
if not _stroking: if not _stroking:
return return
_stroking = false _stroking = false
if _stroke_mode == TerrainField.Mode.RAMP:
_ramp_line.visible = false
if not _ramp_apply():
main.call("_set_status", "Rampe: Start und Ende liegen zu nah beieinander — Klick, ziehen, loslassen.")
return # nichts geändert → kein leerer Undo-Eintrag
# Doc erst jetzt ändern: der GANZE Strich ist ein History-Eintrag. # Doc erst jetzt ändern: der GANZE Strich ist ein History-Eintrag.
var before: Dictionary = main.history.snapshot(["terrain"]) var before: Dictionary = main.history.snapshot(["terrain"])
var terrain: Dictionary = main.doc.get("terrain", {}) var terrain: Dictionary = main.doc.get("terrain", {})
@ -346,6 +412,40 @@ func _end_stroke() -> void:
main.call("_on_terrain_stroke_committed") main.call("_on_terrain_stroke_committed")
## Rampe anwenden (Strichende): Start-Höhe = Höhe beim Klick, End-Höhe = Höhe
## unter dem Endpunkt. Breite = 2 × Radius. Symmetrie wie bei den Pinseln.
func _ramp_apply() -> bool:
if _ramp_a.distance_to(_ramp_b) < 1.0:
return false
var hb := _height_at_stroke(_ramp_b.x, _ramp_b.y)
var width := brush_radius * 2.0
var snap := {}
var g := _world_to_grid(_ramp_a.x, _ramp_a.y)
if _sym_active():
var g2 := _world_to_grid(_ramp_b.x, _ramp_b.y)
var r := _brush_region(g).merge(_brush_region(g2))
snap = _snap_heights(r)
var changed := TerrainField.apply_ramp(_heights, _res, _sx, _sz,
_ramp_a, _ramp_b, _ramp_ha, hb, width, brush_falloff)
if changed and not snap.is_empty():
_mirror_heights(snap, g)
if changed:
_expand_range_if_needed()
view_builder.call("update_terrain", _heights, _mats)
return changed
## Rampen-Vorschau: gelbe Linie vom Startpunkt zum Cursor (auf Geländehöhe).
func _update_ramp_line() -> void:
var im := _ramp_line.mesh as ImmediateMesh
im.clear_surfaces()
im.surface_begin(Mesh.PRIMITIVE_LINES)
im.surface_add_vertex(Vector3(_ramp_a.x, _ramp_ha + 0.5, _ramp_a.y))
im.surface_add_vertex(Vector3(_ramp_b.x, _height_at_stroke(_ramp_b.x, _ramp_b.y) + 0.5, _ramp_b.y))
im.surface_end()
_ramp_line.visible = true
## Programmgesteuerter Stroke (Demo/Screenshot-Hook): macht exakt, was eine ## Programmgesteuerter Stroke (Demo/Screenshot-Hook): macht exakt, was eine
## Maus auch tun würde — inkl. History-Eintrag. ## Maus auch tun würde — inkl. History-Eintrag.
func demo_stroke(x: float, z: float, mode: int, seconds: float) -> void: func demo_stroke(x: float, z: float, mode: int, seconds: float) -> void:
@ -358,11 +458,7 @@ func demo_stroke(x: float, z: float, mode: int, seconds: float) -> void:
var save_x := _cursor.x var save_x := _cursor.x
var save_z := _cursor.z var save_z := _cursor.z
for i in steps: for i in steps:
var amount: float var amount := _step_amount(mode, dt)
if mode == TerrainField.Mode.SMOOTH:
amount = clampf(brush_strength / SMOOTH_RATE_DIVISOR * dt, 0.0, 1.0)
else:
amount = brush_strength * dt
if mode == TerrainField.Mode.PAINT: if mode == TerrainField.Mode.PAINT:
_paint_step(save_x, save_z) _paint_step(save_x, save_z)
continue continue
@ -472,7 +568,8 @@ func _height_step(x: float, z: float, amount: float) -> bool:
if _sym_active(): if _sym_active():
snap = _snap_heights(_brush_region(g)) snap = _snap_heights(_brush_region(g))
var changed := TerrainField.apply_brush(_heights, _res, _sx, _sz, var changed := TerrainField.apply_brush(_heights, _res, _sx, _sz,
-100000.0, 100000.0, x, z, brush_radius, _stroke_mode, amount) -100000.0, 100000.0, x, z, brush_radius, _stroke_mode, amount,
brush_falloff, _flatten_target)
if changed and not snap.is_empty(): if changed and not snap.is_empty():
_mirror_heights(snap, g) _mirror_heights(snap, g)
return changed return changed
@ -489,7 +586,7 @@ func _paint_step(x: float, z: float) -> bool:
if _sym_active() and _mats.size() >= _mres * _mres: if _sym_active() and _mats.size() >= _mres * _mres:
snap = _snap_mats(_brush_region_r(g, _mres)) snap = _snap_mats(_brush_region_r(g, _mres))
var changed := TerrainField.apply_material_brush_mix(_mats, _mix_b, _mix_w, _mres, var changed := TerrainField.apply_material_brush_mix(_mats, _mix_b, _mix_w, _mres,
_sx, _sz, x, z, brush_radius, _stroke_material, brush_weichheit) _sx, _sz, x, z, brush_radius, _stroke_material, brush_weichheit, pressure)
if changed and not snap.is_empty(): if changed and not snap.is_empty():
_mirror_mats(snap, g) _mirror_mats(snap, g)
return changed return changed
@ -614,6 +711,10 @@ func _refresh_ring_mesh() -> void:
var mc: int = clampi(paint_material, 0, SvmapIO.MATERIAL_IDS.size() - 1) var mc: int = clampi(paint_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)
mat.albedo_color = (terrain_mesh_color(mc) as Color) mat.albedo_color = (terrain_mesh_color(mc) as Color)
mat.albedo_color.a = 0.9 mat.albedo_color.a = 0.9
TerrainField.Mode.FLATTEN:
mat.albedo_color = Color(1.0, 0.85, 0.3, 0.9)
TerrainField.Mode.RAMP:
mat.albedo_color = Color(0.85, 0.5, 1.0, 0.9)
_: _:
mat.albedo_color = Color(0.4, 0.7, 1.0, 0.9) mat.albedo_color = Color(0.4, 0.7, 1.0, 0.9)
mat.emission_enabled = true mat.emission_enabled = true

View file

@ -10,7 +10,7 @@ class_name TerrainField
## ##
## Der eigentliche Doc-Mutator bleibt main.gd/terrain_tool.gd — hier nur Mathematik. ## Der eigentliche Doc-Mutator bleibt main.gd/terrain_tool.gd — hier nur Mathematik.
enum Mode { RAISE, LOWER, SMOOTH, PAINT } enum Mode { RAISE, LOWER, SMOOTH, PAINT, FLATTEN, RAMP }
## Editor-MVP-Perf-Limit (Karte t_9aba04f1): das Format erlaubt 17–513, aber ## 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 ## der Editor formt im MVP nur Grids bis 129 (≈ 32k Dreiecke — flüssig beim
@ -39,6 +39,99 @@ static func height_at(heights: PackedFloat32Array, res: int, size_x: float, size
return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz) return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz)
## Pinsel-Gewicht über einen einzigen Regler (Karte 25.10.): t = 1 − d/radius,
## w = lerp(t, t·t, falloff). falloff 0 = linear (hart, breite Mitte),
## 1 = quadratisch (weich, spitze Mitte). Ausserhalb des Radius 0.
static func falloff_weight(d: float, radius: float, falloff: float) -> float:
if radius <= 0.0 or d >= radius:
return 0.0
var t := clampf(1.0 - d / radius, 0.0, 1.0)
return lerpf(t, t * t, clampf(falloff, 0.0, 1.0))
## Pinsel-Stärke aus Eingabe: Pen-Druck (>0) gewinnt, sonst Controller-Trigger
## (> 0.02), sonst Maus = 1.0. Immer 0.05…1.
static func pressure_of(pen: float, trigger: float) -> float:
if pen > 0.0:
return clampf(pen, 0.05, 1.0)
if trigger > 0.02:
return clampf(trigger, 0.05, 1.0)
return 1.0
## Flatten: zieht Höhen im Kreis gewichtet zur Zielhöhe. amount = Mischfaktor 0–1.
## (Eigene Funktion, damit apply_brush-Signatur stabil bleibt.)
static func apply_flatten(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
cx: float, cz: float, radius: float, target: float, amount: float,
falloff: 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 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 - radius / cell_x)) - 1, 0)
var x1 := mini(int(ceil(gx + radius / cell_x)) + 1, res - 1)
var z0 := maxi(int(floor(gz - radius / cell_z)) - 1, 0)
var z1 := mini(int(ceil(gz + radius / cell_z)) + 1, res - 1)
var changed := false
for z in range(z0, z1 + 1):
for x in range(x0, x1 + 1):
var dx := (float(x) - gx) * cell_x
var dz := (float(z) - gz) * cell_z
var w := falloff_weight(sqrt(dx * dx + dz * dz), radius, falloff)
if w <= 0.0:
continue
var idx := z * res + x
var h := heights[idx]
var nh := lerpf(h, target, clampf(amount * w, 0.0, 1.0))
if nh != h:
heights[idx] = nh
changed = true
return changed
## Rampe: Höhe linear von ha (Punkt a) nach hb (Punkt b), Breite `width` m
## quer zur Achse; Kern (halbe Breite) voll, Falloff am Rand. a/b = Welt-X/Z (Vector2). Rückgabe: geändert.
static func apply_ramp(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
a: Vector2, b: Vector2, ha: float, hb: float, width: float, falloff: float) -> bool:
if res < 2 or width <= 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 half := width * 0.5
var ab := b - a
var len2 := ab.length_squared()
var lo := Vector2(minf(a.x, b.x) - half, minf(a.y, b.y) - half)
var hi := Vector2(maxf(a.x, b.x) + half, maxf(a.y, b.y) + half)
var x0 := maxi(int(floor((lo.x / size_x + 0.5) * float(res - 1))) - 1, 0)
var x1 := mini(int(ceil((hi.x / size_x + 0.5) * float(res - 1))) + 1, res - 1)
var z0 := maxi(int(floor((lo.y / size_z + 0.5) * float(res - 1))) - 1, 0)
var z1 := mini(int(ceil((hi.y / size_z + 0.5) * float(res - 1))) + 1, res - 1)
var changed := false
for z in range(z0, z1 + 1):
for x in range(x0, x1 + 1):
var p := Vector2((float(x) / float(res - 1) - 0.5) * size_x,
(float(z) / float(res - 1) - 0.5) * size_z)
var s := 0.0 if len2 < 0.000001 else clampf((p - a).dot(ab) / len2, 0.0, 1.0)
var d := p.distance_to(a + ab * s)
# Innere Hälfte der Breite voll (ebene Fahrfläche), äußere Hälfte
# läuft mit dem Falloff-Regler ins Gelände aus.
var core := half * 0.5
var w := 1.0 if d <= core else falloff_weight(d - core, half - core, falloff)
if d >= half:
w = 0.0
if w <= 0.0:
continue
var idx := z * res + x
var h := heights[idx]
var nh := lerpf(h, lerpf(ha, hb, s), w)
if nh != h:
heights[idx] = nh
changed = true
return changed
## Pinsel anwenden. amount: ## Pinsel anwenden. amount:
## RAISE/LOWER → Meter (bereits strength × dt vom Aufrufer), ## RAISE/LOWER → Meter (bereits strength × dt vom Aufrufer),
## SMOOTH → Mischfaktor 0–1 (Anteil Richtung 3×3-Umgebungsmittel). ## SMOOTH → Mischfaktor 0–1 (Anteil Richtung 3×3-Umgebungsmittel).
@ -46,7 +139,14 @@ static func height_at(heights: PackedFloat32Array, res: int, size_x: float, size
## Kegel. Rückgabe true, wenn mindestens ein Punkt verändert wurde. ## 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, static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
hmin: float, hmax: float, cx: float, cz: float, radius: float, hmin: float, hmax: float, cx: float, cz: float, radius: float,
mode: int, amount: float) -> bool: mode: int, amount: float, falloff: float = -1.0, target: float = 0.0) -> bool:
## falloff < 0 = alter Kosinus-Falloff (Kompatibilität), sonst Regler-Falloff.
## target nur für FLATTEN (Zielhöhe).
if mode == Mode.RAMP:
return false # Rampe hat ein eigenes Zwei-Punkt-API (apply_ramp)
if mode == Mode.FLATTEN:
return apply_flatten(heights, res, size_x, size_z, cx, cz, radius, target,
clampf(amount, 0.0, 1.0), maxf(falloff, 0.0))
if mode == Mode.PAINT: if mode == Mode.PAINT:
# PAINT läuft über apply_material_brush (Material-Schicht, Karte # PAINT läuft über apply_material_brush (Material-Schicht, Karte
# t_603ec1a8) — Höhe bleibt unangetastet. Hier ohne Material-Argument # t_603ec1a8) — Höhe bleibt unangetastet. Hier ohne Material-Argument
@ -66,7 +166,7 @@ static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, si
var z1 := mini(int(ceil(gz + r_cells_z)) + 1, res - 1) var z1 := mini(int(ceil(gz + r_cells_z)) + 1, res - 1)
if mode == Mode.SMOOTH: if mode == Mode.SMOOTH:
return _apply_smooth(heights, res, hmin, hmax, gx, gz, r_cells_x, r_cells_z, 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)) x0, x1, z0, z1, clampf(amount, 0.0, 1.0), falloff)
var changed := false var changed := false
var z := z0 var z := z0
while z <= z1: while z <= z1:
@ -78,7 +178,8 @@ static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, si
if d >= radius: if d >= radius:
x += 1 x += 1
continue continue
var w := 0.5 * (1.0 + cos(PI * d / radius)) var w := 0.5 * (1.0 + cos(PI * d / radius)) if falloff < 0.0 \
else falloff_weight(d, radius, falloff)
var idx := z * res + x var idx := z * res + x
var h := heights[idx] var h := heights[idx]
var nh := h + amount * w if mode == Mode.RAISE else h - amount * w var nh := h + amount * w if mode == Mode.RAISE else h - amount * w
@ -131,10 +232,13 @@ static func apply_material_brush(mats: PackedByteArray, res: int, size_x: float,
## radius·(1−weichheit) wird voll gemalt, außen fällt der Anteil des neuen ## radius·(1−weichheit) wird voll gemalt, außen fällt der Anteil des neuen
## Materials weich auf 0 (Smoothstep). Ein Punkt wird nur verstärkt, nie ## Materials weich auf 0 (Smoothstep). Ein Punkt wird nur verstärkt, nie
## abgeschwächt (Nachmalen am Rand macht ihn kräftiger, nicht schwächer). ## abgeschwächt (Nachmalen am Rand macht ihn kräftiger, nicht schwächer).
## Mischung bleibt zweiteilig: das neue Material + das bisher stärkste andere. ## Mischung bleibt zweiteilig: das neue Material + das bisher stärkste andere
## (kommt ein drittes Material dazu, wird das schwächere der beiden ersetzt).
## staerke (0–1, Controller-Trigger/Pen-Druck) deckelt den Zielanteil.
static func apply_material_brush_mix(mats: PackedByteArray, mix_b: PackedByteArray, static func apply_material_brush_mix(mats: PackedByteArray, mix_b: PackedByteArray,
mix_w: PackedByteArray, res: int, size_x: float, size_z: float, mix_w: PackedByteArray, res: int, size_x: float, size_z: float,
cx: float, cz: float, radius: float, material_id: int, weichheit: float) -> bool: cx: float, cz: float, radius: float, material_id: int, weichheit: float,
staerke: float = 1.0) -> bool:
if res < 2 or radius <= 0.0 or mats.size() < res * res \ if res < 2 or radius <= 0.0 or mats.size() < res * res \
or mix_b.size() < res * res or mix_w.size() < res * res: or mix_b.size() < res * res or mix_w.size() < res * res:
return false return false
@ -160,7 +264,7 @@ static func apply_material_brush_mix(mats: PackedByteArray, mix_b: PackedByteArr
if d > inner: if d > inner:
var t := clampf((d - inner) / maxf(radius - inner, 0.0001), 0.0, 1.0) var t := clampf((d - inner) / maxf(radius - inner, 0.0001), 0.0, 1.0)
f = 1.0 - t * t * (3.0 - 2.0 * t) f = 1.0 - t * t * (3.0 - 2.0 * t)
var fb := int(roundf(f * 255.0)) var fb := int(roundf(f * clampf(staerke, 0.0, 1.0) * 255.0))
if fb <= 0: if fb <= 0:
continue continue
var idx := z * res + x var idx := z * res + x
@ -175,7 +279,15 @@ static func apply_material_brush_mix(mats: PackedByteArray, mix_b: PackedByteArr
mix_b[idx] = material_id mix_b[idx] = material_id
mix_w[idx] = 0 mix_w[idx] = 0
else: else:
var andere := a if a != material_id else b # Ziel in keinem Slot → das SCHWÄCHERE Slot-Material wird ersetzt
# (Anteil von b = w/255, a hat 255−w): w ≥ 128 → b bleibt, sonst a.
var andere: int
if a == material_id:
andere = b
elif b == material_id:
andere = a
else:
andere = b if w >= 128 else a
mats[idx] = andere mats[idx] = andere
mix_b[idx] = material_id mix_b[idx] = material_id
mix_w[idx] = fb mix_w[idx] = fb
@ -187,52 +299,35 @@ static func apply_material_brush_mix(mats: PackedByteArray, mix_b: PackedByteArr
## aus dem STAND der Feldes berechnet, sonst wandert die Welle in eine Richtung). ## 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, static func _apply_smooth(heights: PackedFloat32Array, res: int, hmin: float, hmax: float,
gx: float, gz: float, r_cells_x: float, r_cells_z: float, gx: float, gz: float, r_cells_x: float, r_cells_z: float,
x0: int, x1: int, z0: int, z1: int, amount: float) -> bool: x0: int, x1: int, z0: int, z1: int, amount: float, falloff: float = -1.0) -> bool:
# Mittelwerte nur für das Pinsel-Rechteck (statt Puffer in Feldgröße pro Schritt).
var bw := x1 - x0 + 1
var avg := PackedFloat32Array() var avg := PackedFloat32Array()
avg.resize(heights.size()) avg.resize(bw * (z1 - z0 + 1))
var z := z0 for z in range(z0, z1 + 1):
while z <= z1: for x in range(x0, x1 + 1):
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 sum := 0.0
var count := 0 var count := 0
var zi := maxi(z - 1, 0) for zi in range(maxi(z - 1, 0), mini(z + 1, res - 1) + 1):
while zi <= mini(z + 1, res - 1): for xi in range(maxi(x - 1, 0), mini(x + 1, res - 1) + 1):
var xi := maxi(x - 1, 0)
while xi <= mini(x + 1, res - 1):
sum += heights[zi * res + xi] sum += heights[zi * res + xi]
count += 1 count += 1
xi += 1 avg[(z - z0) * bw + (x - x0)] = sum / float(maxi(count, 1))
zi += 1
avg[z * res + x] = sum / float(maxi(count, 1))
x += 1
z += 1
var changed := false var changed := false
z = z0 for z in range(z0, z1 + 1):
while z <= z1: for x in range(x0, x1 + 1):
var x := x0
while x <= x1:
var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001) var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001)
var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001) var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001)
var d := sqrt(dx * dx + dz * dz) var d := sqrt(dx * dx + dz * dz)
if d >= 1.0: if d >= 1.0:
x += 1
continue continue
var w := 0.5 * (1.0 + cos(PI * d)) var w := falloff_weight(d, 1.0, falloff)
var idx := z * res + x var idx := z * res + x
var nh := lerpf(heights[idx], avg[idx], clampf(amount * w, 0.0, 1.0)) var nh := lerpf(heights[idx], avg[(z - z0) * bw + (x - x0)], clampf(amount * w, 0.0, 1.0))
nh = clampf(nh, hmin, hmax) nh = clampf(nh, hmin, hmax)
if nh != heights[idx]: if nh != heights[idx]:
heights[idx] = nh heights[idx] = nh
changed = true changed = true
x += 1
z += 1
return changed return changed

View file

@ -1673,7 +1673,7 @@ func _unhandled_key_input(event: InputEvent) -> void:
if tool_mode == "terrain": if tool_mode == "terrain":
_terrain_brush_size_step(key == KEY_BRACKETRIGHT) _terrain_brush_size_step(key == KEY_BRACKETRIGHT)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
KEY_1, KEY_2, KEY_3, KEY_4: KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6:
# Terrain-Pinsel-Modi 1–4 (Karte t_55257792): Heben/Senken/Glätten/ # Terrain-Pinsel-Modi 1–4 (Karte t_55257792): Heben/Senken/Glätten/
# Malen — NUR im Terrain-Werkzeug (Konfliktfreiheit, Punkt 6; im # Malen — NUR im Terrain-Werkzeug (Konfliktfreiheit, Punkt 6; im
# Objekt-/Bauen-Modus bleiben die Ziffern unbenutzt). # Objekt-/Bauen-Modus bleiben die Ziffern unbenutzt).
@ -1924,15 +1924,19 @@ func _terrain_brush_mode_key(mode_idx: int) -> void:
btn = t.terrain_mode_smooth btn = t.terrain_mode_smooth
3: 3:
btn = t.terrain_mode_paint btn = t.terrain_mode_paint
4:
btn = t.terrain_mode_flatten
5:
btn = t.terrain_mode_ramp
if btn == null or terrain_tool == null: if btn == null or terrain_tool == null:
return return
btn.set_pressed(true) btn.set_pressed(true)
var labels := ["Heben", "Senken", "Glätten", "Malen"] var labels := ["Heben", "Senken", "Glätten", "Malen", "Einebnen", "Rampe"]
var extra := "" var extra := ""
if mode_idx == 3: if mode_idx == 3:
var mats: Array = SvmapIO.MATERIAL_IDS var mats: Array = SvmapIO.MATERIAL_IDS
extra = " (Material: %s — mit der Palette rechts wechseln)" % str(mats[clampi(int(terrain_tool.get("paint_material")), 0, mats.size() - 1)]) extra = " (Material: %s — mit der Palette rechts wechseln)" % str(mats[clampi(int(terrain_tool.get("paint_material")), 0, mats.size() - 1)])
_set_status("Pinsel-Modus: %s%s — Linksklick-Ziehen formt/malt jetzt so (Ziffern 1–4 wechseln)." % [str(labels[mode_idx]), extra]) _set_status("Pinsel-Modus: %s%s — Linksklick-Ziehen formt/malt jetzt so (Ziffern 1–6 wechseln)." % [str(labels[mode_idx]), extra])
## Ein Pinsel-Strich ist fertig (Doc + History schon aktualisiert vom Tool). ## Ein Pinsel-Strich ist fertig (Doc + History schon aktualisiert vom Tool).

View file

@ -11,6 +11,10 @@ var terrain_mode_raise: Button
var terrain_mode_lower: Button var terrain_mode_lower: Button
var terrain_mode_smooth: Button var terrain_mode_smooth: Button
var terrain_mode_paint: Button var terrain_mode_paint: Button
var terrain_mode_flatten: Button
var terrain_mode_ramp: Button
var terrain_falloff_label: Label
var terrain_falloff_slider: HSlider
var terrain_palette: VBoxContainer var terrain_palette: VBoxContainer
var terrain_mat_buttons: Array = [] # Gras/Sand/Fels (ButtonGroup) var terrain_mat_buttons: Array = [] # Gras/Sand/Fels (ButtonGroup)
var terrain_size_btn_small: Button var terrain_size_btn_small: Button
@ -49,8 +53,10 @@ func build() -> void:
add_child(terrain_panel) add_child(terrain_panel)
terrain_panel.add_child(_field_label("Pinsel-Werkzeug:")) terrain_panel.add_child(_field_label("Pinsel-Werkzeug:"))
var mode_row := HBoxContainer.new() var mode_row := GridContainer.new()
mode_row.add_theme_constant_override("separation", UIScale.px(8)) # U7-Raster mode_row.columns = 3
mode_row.add_theme_constant_override("h_separation", UIScale.px(8)) # U7-Raster
mode_row.add_theme_constant_override("v_separation", UIScale.px(8))
terrain_panel.add_child(mode_row) terrain_panel.add_child(mode_row)
var mode_group := ButtonGroup.new() var mode_group := ButtonGroup.new()
terrain_mode_raise = _terrain_mode_button("Heben ⬆", mode_group, terrain_mode_raise = _terrain_mode_button("Heben ⬆", mode_group,
@ -61,7 +67,13 @@ func build() -> void:
"Pinsel-Modus Glätten: Höhenunterschiede verrutschen (Taste 3)") "Pinsel-Modus Glätten: Höhenunterschiede verrutschen (Taste 3)")
terrain_mode_paint = _terrain_mode_button("Malen 🖌", mode_group, terrain_mode_paint = _terrain_mode_button("Malen 🖌", mode_group,
"Pinsel-Modus Malen: Material auf den Boden malen (Taste 4)") "Pinsel-Modus Malen: Material auf den Boden malen (Taste 4)")
terrain_mode_flatten = _terrain_mode_button("Einebnen ▬", mode_group,
"Pinsel-Modus Einebnen: zieht den Boden auf die Höhe, die beim Strichstart unter dem Cursor war")
terrain_mode_ramp = _terrain_mode_button("Rampe ◢", mode_group,
"Pinsel-Modus Rampe: Klick = Start, ziehen und loslassen = Ende — Höhe läuft gleichmäßig dazwischen (Breite = Pinselgröße)")
terrain_mode_raise.button_pressed = true terrain_mode_raise.button_pressed = true
terrain_mode_flatten.toggled.connect(_on_terrain_mode_changed)
terrain_mode_ramp.toggled.connect(_on_terrain_mode_changed)
terrain_mode_raise.toggled.connect(_on_terrain_mode_changed) terrain_mode_raise.toggled.connect(_on_terrain_mode_changed)
terrain_mode_lower.toggled.connect(_on_terrain_mode_changed) terrain_mode_lower.toggled.connect(_on_terrain_mode_changed)
terrain_mode_smooth.toggled.connect(_on_terrain_mode_changed) terrain_mode_smooth.toggled.connect(_on_terrain_mode_changed)
@ -70,6 +82,8 @@ func build() -> void:
mode_row.add_child(terrain_mode_lower) mode_row.add_child(terrain_mode_lower)
mode_row.add_child(terrain_mode_smooth) mode_row.add_child(terrain_mode_smooth)
mode_row.add_child(terrain_mode_paint) mode_row.add_child(terrain_mode_paint)
mode_row.add_child(terrain_mode_flatten)
mode_row.add_child(terrain_mode_ramp)
# Material-Palette (Karte t_603ec1a8): nur sichtbar im Malen-Modus. # Material-Palette (Karte t_603ec1a8): nur sichtbar im Malen-Modus.
terrain_palette = VBoxContainer.new() terrain_palette = VBoxContainer.new()
@ -153,6 +167,20 @@ func build() -> void:
terrain_strength_slider.value_changed.connect(_on_terrain_strength_changed) terrain_strength_slider.value_changed.connect(_on_terrain_strength_changed)
terrain_panel.add_child(terrain_strength_slider) terrain_panel.add_child(terrain_strength_slider)
# Pinsel-Kante (Falloff, Karte 25.10.): ein Regler hart ↔ weich für alle
# Höhen-Pinsel (Malen hat seinen eigenen Kanten-Regler weiter unten).
terrain_falloff_label = _field_label("Kante: 50 % weich")
terrain_panel.add_child(terrain_falloff_label)
terrain_falloff_slider = HSlider.new()
terrain_falloff_slider.min_value = 0.0
terrain_falloff_slider.max_value = 100.0
terrain_falloff_slider.step = 5.0
terrain_falloff_slider.value = 50.0
terrain_falloff_slider.custom_minimum_size = UIScale.v2(0, 32)
terrain_falloff_slider.tooltip_text = "Links = harte Kante (breiter Kern), rechts = weich auslaufend"
terrain_falloff_slider.value_changed.connect(_on_terrain_falloff_changed)
terrain_panel.add_child(terrain_falloff_slider)
# Malen kennt keine Stärke: das Material wird zellenweise gesetzt # Malen kennt keine Stärke: das Material wird zellenweise gesetzt
# (Material-Schicht = ein Wert pro Rasterpunkt, weiche Übergänge macht # (Material-Schicht = ein Wert pro Rasterpunkt, weiche Übergänge macht
# der Splat-Renderer). Statt eines wirkungslosen Reglers steht im # der Splat-Renderer). Statt eines wirkungslosen Reglers steht im
@ -177,6 +205,9 @@ func build() -> void:
terrain_weich_slider.value_changed.connect(_on_terrain_weich_changed) terrain_weich_slider.value_changed.connect(_on_terrain_weich_changed)
terrain_panel.add_child(terrain_weich_slider) terrain_panel.add_child(terrain_weich_slider)
terrain_panel.add_child(_muted_label(
"Stärke folgt dem Stift-Druck bzw. dem Controller-Trigger (Maus = voll). "
+ "Rampe: Start klicken, ziehen, loslassen. Einebnen: Höhe am Strichstart wird zum Ziel."))
terrain_panel.add_child(_muted_label( terrain_panel.add_child(_muted_label(
"Linksklick-Ziehen formt bzw. malt (je nach Werkzeug), Strg+Linksklick macht das Gegenteil — im Malen-Modus malt Strg+Klick Steppe (Grundboden) zurück. " "Linksklick-Ziehen formt bzw. malt (je nach Werkzeug), Strg+Linksklick macht das Gegenteil — im Malen-Modus malt Strg+Klick Steppe (Grundboden) zurück. "
+ "Jeder Strich ist EIN Rückgängig-Schritt (Strg+Z). Rechte Maustaste = Flug, ESC beendet den Terrain-Modus.")) + "Jeder Strich ist EIN Rückgängig-Schritt (Strg+Z). Rechte Maustaste = Flug, ESC beendet den Terrain-Modus."))
@ -257,6 +288,10 @@ func _selected_terrain_mode() -> int:
return TerrainField.Mode.SMOOTH return TerrainField.Mode.SMOOTH
if terrain_mode_paint.button_pressed: if terrain_mode_paint.button_pressed:
return TerrainField.Mode.PAINT return TerrainField.Mode.PAINT
if terrain_mode_flatten != null and terrain_mode_flatten.button_pressed:
return TerrainField.Mode.FLATTEN
if terrain_mode_ramp != null and terrain_mode_ramp.button_pressed:
return TerrainField.Mode.RAMP
return TerrainField.Mode.RAISE return TerrainField.Mode.RAISE
@ -268,6 +303,9 @@ func _on_terrain_mode_changed(_pressed: bool) -> void:
if terrain_strength_slider != null: if terrain_strength_slider != null:
terrain_strength_slider.visible = not paint terrain_strength_slider.visible = not paint
terrain_strength_label.visible = not paint terrain_strength_label.visible = not paint
if terrain_falloff_slider != null:
terrain_falloff_slider.visible = not paint
terrain_falloff_label.visible = not paint
if terrain_weich_slider != null: if terrain_weich_slider != null:
terrain_weich_slider.visible = paint terrain_weich_slider.visible = paint
terrain_weich_label.visible = paint terrain_weich_label.visible = paint
@ -329,6 +367,12 @@ func _on_terrain_weich_changed(value: float) -> void:
main.terrain_tool.call("set_weichheit", value / 100.0) main.terrain_tool.call("set_weichheit", value / 100.0)
func _on_terrain_falloff_changed(value: float) -> void:
terrain_falloff_label.text = "Kante: %d %% weich" % int(value) if value > 0.0 else "Kante: hart"
if main.terrain_tool != null:
main.terrain_tool.call("set_falloff", value / 100.0)
func _on_terrain_strength_changed(value: float) -> void: func _on_terrain_strength_changed(value: float) -> void:
terrain_strength_label.text = "Stärke: %.1f m pro Sekunde" % value terrain_strength_label.text = "Stärke: %.1f m pro Sekunde" % value
if main.terrain_tool != null: if main.terrain_tool != null:

View file

@ -0,0 +1,160 @@
extends SceneTree
## Pinsel-Falloff, Mal-Slot-Logik, Einebnen, Rampe, Druck (Karte 25.10.).
## Ausführen: godot --headless --path . --script res://tests/test_pinsel_werkzeuge.gd
var failures := 0
var checks := 0
const R := 129
const S := 1024.0
func _init() -> void:
_falloff()
_druck()
_flatten()
_ramp()
_glaetten()
_mal_slots()
_undo()
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 _flat(h := 0.0) -> PackedFloat32Array:
var a := PackedFloat32Array()
a.resize(R * R)
a.fill(h)
return a
func _falloff() -> void:
print("Testgruppe: Falloff lerp(t, t², f)")
_check(is_equal_approx(TerrainField.falloff_weight(0.0, 10.0, 0.0), 1.0), "Mitte = 1 (hart)")
_check(is_equal_approx(TerrainField.falloff_weight(0.0, 10.0, 1.0), 1.0), "Mitte = 1 (weich)")
_check(is_equal_approx(TerrainField.falloff_weight(5.0, 10.0, 0.0), 0.5), "hart: halber Radius = 0.5")
_check(is_equal_approx(TerrainField.falloff_weight(5.0, 10.0, 1.0), 0.25), "weich: halber Radius = 0.25")
_check(is_equal_approx(TerrainField.falloff_weight(5.0, 10.0, 0.5), 0.375), "mittel: lerp = 0.375")
_check(TerrainField.falloff_weight(10.0, 10.0, 0.5) == 0.0, "Rand = 0")
var h := _flat()
TerrainField.apply_brush(h, R, S, S, -1e5, 1e5, 0.0, 0.0, 40.0, TerrainField.Mode.RAISE, 1.0, 0.0)
var h2 := _flat()
TerrainField.apply_brush(h2, R, S, S, -1e5, 1e5, 0.0, 0.0, 40.0, TerrainField.Mode.RAISE, 1.0, 1.0)
var i := 64 * R + 64 + 3 # ~24 m neben der Mitte
_check(h[i] > h2[i] and h2[i] > 0.0, "Raise: hart hebt am Rand mehr als weich")
func _druck() -> void:
print("Testgruppe: Druck")
_check(TerrainField.pressure_of(0.0, 0.0) == 1.0, "Maus = 1.0")
_check(is_equal_approx(TerrainField.pressure_of(0.4, 0.9), 0.4), "Pen vor Trigger")
_check(is_equal_approx(TerrainField.pressure_of(0.0, 0.6), 0.6), "Trigger")
_check(TerrainField.pressure_of(0.001, 0.0) == 0.05 or TerrainField.pressure_of(0.001, 0.0) == 1.0, "Minimum begrenzt")
var t := Node3D.new()
t.set_script(load("res://src/editor/terrain_tool.gd"))
t.set_pressure(0.5)
var a: float = t.call("_step_amount", TerrainField.Mode.RAISE, 1.0)
t.set_pressure(1.0)
var b: float = t.call("_step_amount", TerrainField.Mode.RAISE, 1.0)
_check(is_equal_approx(a * 2.0, b), "Stärke skaliert mit Druck")
t.free()
func _flatten() -> void:
print("Testgruppe: Einebnen")
var h := _flat(0.0)
for i in h.size():
h[i] = float((i % R)) * 0.1 # Rampe in x
var target := 6.4
for k in 40:
TerrainField.apply_brush(h, R, S, S, -1e5, 1e5, 0.0, 0.0, 60.0, TerrainField.Mode.FLATTEN, 0.3, 0.3, target)
_check(absf(h[64 * R + 64] - target) < 0.05, "Mitte erreicht Zielhöhe")
_check(absf(h[64 * R + 4] - 0.4) < 0.0001, "weit außerhalb unverändert")
_check(absf(h[64 * R + 60] - target) < absf(0.1 * 60 - target) , "Rand näher am Ziel als vorher")
func _ramp() -> void:
print("Testgruppe: Rampe")
var h := _flat(0.0)
var a := Vector2(-80.0, 0.0)
var b := Vector2(80.0, 0.0)
TerrainField.apply_ramp(h, R, S, S, a, b, 2.0, 12.0, 30.0, 0.5)
var cx := func(wx: float) -> int: return int(round((wx / S + 0.5) * float(R - 1)))
var iz := 64
var mid: float = h[iz * R + cx.call(0.0)]
_check(absf(mid - 7.0) < 0.3, "Mitte ≈ lineare Höhe 7 (%.2f)" % mid)
var q: float = h[iz * R + cx.call(40.0)]
_check(absf(q - 9.5) < 0.4, "Viertelpunkt ≈ 9.5 (%.2f)" % q)
_check(h[iz * R + cx.call(-200.0)] == 0.0 and h[iz * R + cx.call(200.0)] == 0.0, "außerhalb der Enden unverändert")
var seitlich: float = h[(iz + 4) * R + cx.call(0.0)] # ≈ 32 m quer: außerhalb der halben Breite 15
_check(seitlich == 0.0, "außerhalb der Breite unverändert")
var rand: float = h[(iz + 1) * R + cx.call(0.0)] # 8 m quer: Falloff
_check(rand > 0.0 and rand < mid, "Rand mit Falloff schwächer als Achse")
func _glaetten() -> void:
print("Testgruppe: Glätten mit Falloff")
var h := _flat(0.0)
h[64 * R + 64] = 10.0
var changed := TerrainField.apply_brush(h, R, S, S, -1e5, 1e5, 0.0, 0.0, 30.0, TerrainField.Mode.SMOOTH, 1.0, 0.5)
_check(changed and h[64 * R + 64] < 10.0 and h[64 * R + 65] > 0.0, "Spitze verteilt sich")
var h2 := _flat(0.0)
h2[64 * R + 64] = 10.0
TerrainField.apply_brush(h2, R, S, S, -1e5, 1e5, 0.0, 0.0, 30.0, TerrainField.Mode.SMOOTH, 1.0)
_check(h2[64 * R + 64] < 10.0, "ohne Falloff (alt: Kosinus) funktioniert weiter")
func _mal_slots() -> void:
print("Testgruppe: Mal-Slots")
var n := 33
var a := PackedByteArray(); a.resize(n * n); a.fill(0)
var b := PackedByteArray(); b.resize(n * n); b.fill(0)
var w := PackedByteArray(); w.resize(n * n); w.fill(0)
var mid := 16 * n + 16
# Mitte: Slot a=1 (Anteil 255−200=55), Slot b=2 (Anteil 200 = stärker).
a[mid] = 1; b[mid] = 2; w[mid] = 200
# Ziel 3 mit voller Stärke: ersetzt das SCHWÄCHERE (1) → übrig: 2 + 3.
TerrainField.apply_material_brush_mix(a, b, w, n, 32.0, 32.0, 0.0, 0.0, 6.0, 3, 0.0, 0.5)
var slots := [int(a[mid]), int(b[mid])]
_check(slots.has(2) and slots.has(3) and not slots.has(1), "schwächeres Slot-Material ersetzt (%s)" % str(slots))
_check(int(w[mid]) > 100 and int(w[mid]) < 140 and int(b[mid]) == 3, "Anteil schiebt weich Richtung Ziel (w=%d)" % int(w[mid]))
TerrainField.apply_material_brush_mix(a, b, w, n, 32.0, 32.0, 0.0, 0.0, 6.0, 3, 0.0, 1.0)
_check(int(a[mid]) == 3 and int(w[mid]) == 0, "voll gemalt → reines Zielmaterial")
# Ziel ist schon in Slot b: Anteil wächst, kein Wechsel.
a[mid] = 1; b[mid] = 2; w[mid] = 100
TerrainField.apply_material_brush_mix(a, b, w, n, 32.0, 32.0, 0.0, 0.0, 6.0, 2, 0.0, 0.8)
_check(int(a[mid]) == 1 and int(b[mid]) == 2 and int(w[mid]) > 100, "Ziel in Slot b: Anteil wächst")
# Schwacher Strich schwächt nie ab.
var before := int(w[mid])
TerrainField.apply_material_brush_mix(a, b, w, n, 32.0, 32.0, 0.0, 0.0, 6.0, 2, 0.0, 0.1)
_check(int(w[mid]) == before, "schwächerer Strich ändert nichts")
func _undo() -> void:
print("Testgruppe: Einebnen/Rampe + Undo")
var doc := SvmapDocument.create_new("Pinsel", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var terrain: Dictionary = doc["terrain"]
var h := SvmapIO.decode_heights(terrain)
var vor := str(terrain["heightmap_r16_b64"])
var before: Dictionary = hist.snapshot(["terrain"])
TerrainField.apply_ramp(h, 129, 1024.0, 1024.0, Vector2(-50, 0), Vector2(50, 0), 0.0, 20.0, 20.0, 0.3)
terrain["max_height_m"] = 30.0
terrain["heightmap_r16_b64"] = TerrainField.encode(h, float(terrain["min_height_m"]), 30.0)
hist.commit("Boden geformt (Rampe)", before, hist.snapshot(["terrain"]))
_check(str(terrain["heightmap_r16_b64"]) != vor, "Rampe verändert Doc")
hist.undo()
_check(str(terrain["heightmap_r16_b64"]) == vor, "Undo stellt Höhenfeld wieder her")

View file

@ -180,6 +180,8 @@ func _test_inputmap_actions() -> void:
"editor_brush_lower": KEY_2, "editor_brush_lower": KEY_2,
"editor_brush_smooth": KEY_3, "editor_brush_smooth": KEY_3,
"editor_brush_paint": KEY_4, "editor_brush_paint": KEY_4,
"editor_brush_flatten": KEY_5,
"editor_brush_ramp": KEY_6,
} }
for action in expected_keys_2: for action in expected_keys_2:
if not InputMap.has_action(action): if not InputMap.has_action(action):
@ -918,4 +920,4 @@ func _test_brush_paint_mode() -> void:
tool.call("set_brush_mode", TerrainField.Mode.RAISE) tool.call("set_brush_mode", TerrainField.Mode.RAISE)
_check(int(tool.get("brush_mode")) == TerrainField.Mode.RAISE, "set_brush_mode(Heben) bleibt Heben") _check(int(tool.get("brush_mode")) == TerrainField.Mode.RAISE, "set_brush_mode(Heben) bleibt Heben")
tool.call("set_brush_mode", 99) tool.call("set_brush_mode", 99)
_check(int(tool.get("brush_mode")) == TerrainField.Mode.PAINT, "Out-of-Range clampet auf den höchsten Modus") _check(int(tool.get("brush_mode")) == TerrainField.Mode.RAMP, "Out-of-Range clampet auf den höchsten Modus")

View file

@ -177,7 +177,7 @@ func _reference_heights(steps: int, x: float, z: float, mode: int) -> PackedFloa
var amount := STRENGTH * dt var amount := STRENGTH * dt
for i in steps: for i in steps:
TerrainField.apply_brush(ref, RES, SIZE, SIZE, -100000.0, 100000.0, TerrainField.apply_brush(ref, RES, SIZE, SIZE, -100000.0, 100000.0,
x, z, RADIUS, mode, amount) x, z, RADIUS, mode, amount, 0.5) # Falloff = Tool-Standard
return ref return ref

View file

@ -1,9 +1,9 @@
#!/bin/bash #!/bin/bash
# Regression: alle Editor-Testsuiten (Karte t_3a998ae4) # Regression: alle Editor-Testsuiten (Karte t_3a998ae4)
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
GODOT=/opt/data/home/bin/godot4 GODOT=${GODOT:-/opt/data/home/bin/godot4}
rc=0 rc=0
for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang test_inspector; do for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang test_inspector test_pinsel_werkzeuge test_terrain_mirror test_mal_fein; do
"$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1 "$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1
code=$? code=$?
line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1) line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)