diff --git a/src/editor/shortcut_keys.gd b/src/editor/shortcut_keys.gd index f8f2eea..5327530 100644 --- a/src/editor/shortcut_keys.gd +++ b/src/editor/shortcut_keys.gd @@ -60,6 +60,8 @@ const ACTIONS := { "editor_brush_lower": [KEY_2], "editor_brush_smooth": [KEY_3], "editor_brush_paint": [KEY_4], + "editor_brush_flatten": [KEY_5], + "editor_brush_ramp": [KEY_6], # --- Datei & Undo (Karte t_11b5c25b, Karte 3/3) -------------------------- # Strg+Z/Strg+Y laufen seit dem M2-UX-Fundament; hier NACHGEBUGGEN als # dokumentierte Actions (alle 3 Shortcut-Karten: ACTIONS-Dict = Source of diff --git a/src/editor/terrain_tool.gd b/src/editor/terrain_tool.gd index e68db05..9468dd8 100644 --- a/src/editor/terrain_tool.gd +++ b/src/editor/terrain_tool.gd @@ -20,7 +20,8 @@ extends Node3D ## 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", "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 camera: Camera3D @@ -32,6 +33,10 @@ var brush_mode: int = TerrainField.Mode.RAISE var brush_radius := 12.0 # Meter ## Kante beim Malen: 0 = hart, 1 = ganz weich (Karte 25.10.). 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 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 _stroke_mode: int = TerrainField.Mode.RAISE 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 _mats := PackedByteArray() # Material-Schicht (res²) — seit _refresh_ # _from_doc immer dekodiert (t_55ed041a), @@ -56,6 +65,7 @@ var _cursor := Vector3.ZERO # Pinsel-Ring var _ring: MeshInstance3D +var _ramp_line: MeshInstance3D # Rampen-Vorschau: Linie Start → Cursor var _ring_radius := -1.0 var _ring_mode := -1 @@ -74,6 +84,15 @@ func _ensure_ring() -> void: _ring = MeshInstance3D.new() _ring.visible = false 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 --- @@ -102,6 +121,8 @@ func deactivate() -> void: _end_stroke() if _ring != null and is_instance_valid(_ring): _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). @@ -177,6 +198,16 @@ func set_weichheit(w: float) -> void: 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: brush_strength = clampf(mps, 1.0, 20.0) @@ -192,7 +223,10 @@ func _unhandled_input(event: InputEvent) -> void: if not active: return 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 if event is InputEventMouseButton: var mb := event as InputEventMouseButton @@ -220,6 +254,15 @@ func _unhandled_input(event: InputEvent) -> void: # 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: var hit: Variant = TerrainField.raycast(_heights, _res, _sx, _sz, 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_material = paint_material if material_id < 0 else material_id _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 # (dekodiert — Legacy-Maps ohne Schicht starten mit alles-Gras). if mode == TerrainField.Mode.PAINT: @@ -288,11 +337,12 @@ func _process(delta: float) -> void: view_builder.call("update_terrain", _heights, _mats, _splat_rect) _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 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 + if _stroke_mode == TerrainField.Mode.RAMP: + # Rampe wirkt erst beim Loslassen — hier nur der Endpunkt + Ring-Hinweis. + _ramp_b = Vector2(_cursor.x, _cursor.z) + _update_ramp_line() + return + var amount := _step_amount(_stroke_mode, 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 := _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 +## 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 ## 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. @@ -329,6 +390,11 @@ func _end_stroke() -> void: if not _stroking: return _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. var before: Dictionary = main.history.snapshot(["terrain"]) var terrain: Dictionary = main.doc.get("terrain", {}) @@ -346,6 +412,40 @@ func _end_stroke() -> void: 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 ## Maus auch tun würde — inkl. History-Eintrag. 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_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 + var amount := _step_amount(mode, dt) if mode == TerrainField.Mode.PAINT: _paint_step(save_x, save_z) continue @@ -472,7 +568,8 @@ func _height_step(x: float, z: float, amount: float) -> bool: if _sym_active(): snap = _snap_heights(_brush_region(g)) 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(): _mirror_heights(snap, g) return changed @@ -489,7 +586,7 @@ func _paint_step(x: float, z: float) -> bool: if _sym_active() and _mats.size() >= _mres * _mres: snap = _snap_mats(_brush_region_r(g, _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(): _mirror_mats(snap, g) return changed @@ -614,6 +711,10 @@ func _refresh_ring_mesh() -> void: var mc: int = clampi(paint_material, 0, SvmapIO.MATERIAL_IDS.size() - 1) mat.albedo_color = (terrain_mesh_color(mc) as Color) 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.emission_enabled = true diff --git a/src/svmap/terrain_field.gd b/src/svmap/terrain_field.gd index 41db5c9..dc7c698 100644 --- a/src/svmap/terrain_field.gd +++ b/src/svmap/terrain_field.gd @@ -10,7 +10,7 @@ class_name TerrainField ## ## 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 ## 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) +## 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: ## RAISE/LOWER → Meter (bereits strength × dt vom Aufrufer), ## 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. 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: + 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: # PAINT läuft über apply_material_brush (Material-Schicht, Karte # 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) 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)) + x0, x1, z0, z1, clampf(amount, 0.0, 1.0), falloff) var changed := false var z := z0 while z <= z1: @@ -78,7 +178,8 @@ static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, si if d >= radius: x += 1 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 h := heights[idx] 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 ## Materials weich auf 0 (Smoothstep). Ein Punkt wird nur verstärkt, nie ## 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, 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 \ or mix_b.size() < res * res or mix_w.size() < res * res: return false @@ -160,7 +264,7 @@ static func apply_material_brush_mix(mats: PackedByteArray, mix_b: PackedByteArr if d > inner: var t := clampf((d - inner) / maxf(radius - inner, 0.0001), 0.0, 1.0) 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: continue 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_w[idx] = 0 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 mix_b[idx] = material_id 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). 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: + 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() - 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 + avg.resize(bw * (z1 - z0 + 1)) + for z in range(z0, z1 + 1): + for x in range(x0, x1 + 1): 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): + for zi in range(maxi(z - 1, 0), mini(z + 1, res - 1) + 1): + for xi in range(maxi(x - 1, 0), mini(x + 1, res - 1) + 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 + avg[(z - z0) * bw + (x - x0)] = sum / float(maxi(count, 1)) var changed := false - z = z0 - while z <= z1: - var x := x0 - while x <= x1: + for z in range(z0, z1 + 1): + for x in range(x0, x1 + 1): 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 w := falloff_weight(d, 1.0, falloff) 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) if nh != heights[idx]: heights[idx] = nh changed = true - x += 1 - z += 1 return changed diff --git a/src/ui/main.gd b/src/ui/main.gd index 8d5dcc7..52f8097 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -1673,7 +1673,7 @@ func _unhandled_key_input(event: InputEvent) -> void: if tool_mode == "terrain": _terrain_brush_size_step(key == KEY_BRACKETRIGHT) 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/ # Malen — NUR im Terrain-Werkzeug (Konfliktfreiheit, Punkt 6; im # 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 3: btn = t.terrain_mode_paint + 4: + btn = t.terrain_mode_flatten + 5: + btn = t.terrain_mode_ramp if btn == null or terrain_tool == null: return btn.set_pressed(true) - var labels := ["Heben", "Senken", "Glätten", "Malen"] + var labels := ["Heben", "Senken", "Glätten", "Malen", "Einebnen", "Rampe"] var extra := "" if mode_idx == 3: 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)]) - _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). diff --git a/src/ui/panels/terrain_panel.gd b/src/ui/panels/terrain_panel.gd index ec37caa..fbb8f3d 100644 --- a/src/ui/panels/terrain_panel.gd +++ b/src/ui/panels/terrain_panel.gd @@ -11,6 +11,10 @@ var terrain_mode_raise: Button var terrain_mode_lower: Button var terrain_mode_smooth: 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_mat_buttons: Array = [] # Gras/Sand/Fels (ButtonGroup) var terrain_size_btn_small: Button @@ -49,8 +53,10 @@ func build() -> void: add_child(terrain_panel) terrain_panel.add_child(_field_label("Pinsel-Werkzeug:")) - var mode_row := HBoxContainer.new() - mode_row.add_theme_constant_override("separation", UIScale.px(8)) # U7-Raster + var mode_row := GridContainer.new() + 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) var mode_group := ButtonGroup.new() 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)") terrain_mode_paint = _terrain_mode_button("Malen 🖌", mode_group, "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_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_lower.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_smooth) 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. terrain_palette = VBoxContainer.new() @@ -153,6 +167,20 @@ func build() -> void: terrain_strength_slider.value_changed.connect(_on_terrain_strength_changed) 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 # (Material-Schicht = ein Wert pro Rasterpunkt, weiche Übergänge macht # 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_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( "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.")) @@ -257,6 +288,10 @@ func _selected_terrain_mode() -> int: return TerrainField.Mode.SMOOTH if terrain_mode_paint.button_pressed: 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 @@ -268,6 +303,9 @@ func _on_terrain_mode_changed(_pressed: bool) -> void: if terrain_strength_slider != null: terrain_strength_slider.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: terrain_weich_slider.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) +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: terrain_strength_label.text = "Stärke: %.1f m pro Sekunde" % value if main.terrain_tool != null: diff --git a/tests/test_pinsel_werkzeuge.gd b/tests/test_pinsel_werkzeuge.gd new file mode 100644 index 0000000..5d55cdd --- /dev/null +++ b/tests/test_pinsel_werkzeuge.gd @@ -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") diff --git a/tests/test_shortcuts.gd b/tests/test_shortcuts.gd index fa51edd..4d8cc55 100644 --- a/tests/test_shortcuts.gd +++ b/tests/test_shortcuts.gd @@ -180,6 +180,8 @@ func _test_inputmap_actions() -> void: "editor_brush_lower": KEY_2, "editor_brush_smooth": KEY_3, "editor_brush_paint": KEY_4, + "editor_brush_flatten": KEY_5, + "editor_brush_ramp": KEY_6, } for action in expected_keys_2: if not InputMap.has_action(action): @@ -918,4 +920,4 @@ func _test_brush_paint_mode() -> void: tool.call("set_brush_mode", TerrainField.Mode.RAISE) _check(int(tool.get("brush_mode")) == TerrainField.Mode.RAISE, "set_brush_mode(Heben) bleibt Heben") tool.call("set_brush_mode", 99) - _check(int(tool.get("brush_mode")) == TerrainField.Mode.PAINT, "Out-of-Range clampet auf den höchsten Modus") \ No newline at end of file + _check(int(tool.get("brush_mode")) == TerrainField.Mode.RAMP, "Out-of-Range clampet auf den höchsten Modus") \ No newline at end of file diff --git a/tests/test_terrain_mirror.gd b/tests/test_terrain_mirror.gd index 4d38c61..c7c54d9 100644 --- a/tests/test_terrain_mirror.gd +++ b/tests/test_terrain_mirror.gd @@ -177,7 +177,7 @@ func _reference_heights(steps: int, x: float, z: float, mode: int) -> PackedFloa var amount := STRENGTH * dt for i in steps: 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 diff --git a/tools/run_tests.sh b/tools/run_tests.sh index 41a5cf8..a595d81 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -1,9 +1,9 @@ #!/bin/bash # Regression: alle Editor-Testsuiten (Karte t_3a998ae4) cd "$(dirname "$0")/.." -GODOT=/opt/data/home/bin/godot4 +GODOT=${GODOT:-/opt/data/home/bin/godot4} 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 code=$? line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)