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, 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 ## 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-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). ## 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, 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 # aufrufbar, aber bewusst NEUTRAL: kein Pattern, keine Höhe. return false 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), falloff) 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)) 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 nh = clampf(nh, hmin, hmax) if nh != h: heights[idx] = nh changed = true x += 1 z += 1 return changed ## Material-Pinsel (Karte t_603ec1a8): setzt die Material-ID aller Zellen im ## Kreis (d < radius) auf material_id — Malen statt Formen, Höhe bleibt unangetastet. ## Kante bewusst CRISP (d < radius): Malen fühlt sich wie ein Malprogramm an, ## die Vertex-Interpolation des Meshes glättet die Grenze optisch. ## mats = dekodierte Material-Schicht (PackedByteArray, res² Einträge). static func apply_material_brush(mats: PackedByteArray, res: int, size_x: float, size_z: float, cx: float, cz: float, radius: float, material_id: int) -> bool: if res < 2 or radius <= 0.0 or mats.size() < res * res: return false var gx := (cx / size_x + 0.5) * float(res - 1) var gz := (cz / size_z + 0.5) * float(res - 1) var r_cells_x := radius / (size_x / float(res - 1)) var r_cells_z := radius / (size_z / 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) var changed := false var z := z0 while z <= z1: var x := x0 while x <= x1: var dx := (float(x) - gx) * (size_x / float(res - 1)) var dz := (float(z) - gz) * (size_z / float(res - 1)) if dx * dx + dz * dz < radius * radius: var idx := z * res + x if mats[idx] != material_id: mats[idx] = material_id changed = true x += 1 z += 1 return changed ## Malen MIT weichem Rand (Karte 25.10., Sly: „weicher Übergang der Texturen ## am Rand des Malwerkzeugs“). Jeder Punkt kann zwei Materialien mischen: ## a = mats (Hauptschicht), b = mix_b, Anteil von b = mix_w/255. Innerhalb ## 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 ## (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, 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 var soft := clampf(weichheit, 0.0, 1.0) var inner := radius * (1.0 - soft) 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 d := sqrt(dx * dx + dz * dz) if d >= radius: continue var f := 1.0 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 * clampf(staerke, 0.0, 1.0) * 255.0)) if fb <= 0: continue var idx := z * res + x var a := int(mats[idx]) var b := int(mix_b[idx]) var w := int(mix_w[idx]) var anteil := (255 - w if a == material_id else 0) + (w if b == material_id else 0) if fb <= anteil: continue if fb >= 255: mats[idx] = material_id mix_b[idx] = material_id mix_w[idx] = 0 else: # 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 changed = true 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, 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(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 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 avg[(z - z0) * bw + (x - x0)] = sum / float(maxi(count, 1)) var changed := false 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: continue var w := falloff_weight(d, 1.0, falloff) var idx := z * res + x 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 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) # Tangenten-Band (Karte t_234490cf, Teil 2 des Gerätebefunds): Ein Strahl, # der EXAKT auf eine Hügelkuppe zielt, grast die gewölbte Oberfläche — # f(t) bleibt minimal knapp ÜBER 0 am Kuppen-Scheitel, DANACH steigt f # wieder, obwohl weiter hinten (verdeckt hinter dem Hügel) noch ein # Vorzeichenwechsel an der Basis-Ebene käme. Regel „erster sichtbarer # Oberflächen-Kontakt gewinnt": kommt f innerhalb des Bandes (½ Schritt) # an die Oberfläche und entfernt sich der Strahl wieder, gilt die # Oberfläche als getroffen (lokal verfeinert). Ein echter Treffer # (f ≤ 0) gewinnt weiterhin immer. var graze := step * 0.5 var best_t := -1.0 var best_f := INF var t := 0.0 var prev_t := 0.0 var prev_f := INF 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 < best_f: best_f = f best_t = 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 # Tangenten-Erkennung (Karte t_234490cf): war der Strahl im Band an # der Oberfläche und steigt f JETZT wieder, hat er die Oberfläche # tangential berührt und entfernt sich — der erste sichtbare Kontakt # war das Graze-Minimum. Ohne das fiele der Strahl über die Kuppe # hinweg und träfe die (verdeckte) Basis-Ebene weit hinten. if f > prev_f and best_f <= graze: return _graze_hit(heights, res, size_x, size_z, origin, d, best_t, best_f, step, max_dist) prev_f = f prev_t = t # Band-Minimum direkt am March-Ende (kein steigender Sample mehr kam): if best_f <= graze and best_t > 0.0: return _graze_hit(heights, res, size_x, size_z, origin, d, best_t, best_f, step, max_dist) # Nichts getroffen → Basis-Ebene als Fallback (wie interaction._ground_point). if absf(d.y) > 0.0001: var plane_y: float = _min_h(heights) var t_plane: float = (plane_y - origin.y) / d.y if t_plane > 0.0: return origin + d * t_plane return null ## Tangenten-Treffer (Karte t_234490cf): dichteste Annäherung des Strahls an ## die Oberfläche lokal verfeinern (Ternärsuche um den besten March-Sample) ## und den Punkt auf dem Strahl zurückgeben — der x/z-Punkt liegt damit dort, ## wo der Cursor die Oberfläche sichtbar berührt, nicht meterweit dahinter. static func _graze_hit(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, origin: Vector3, d: Vector3, best_t: float, _best_f: float, step: float, max_dist: float) -> Vector3: var glo := maxf(best_t - step, 0.0) var ghi := minf(best_t + step, max_dist) for i in 12: var ga := glo + (ghi - glo) / 3.0 var gb := ghi - (ghi - glo) / 3.0 if _ray_error(heights, res, size_x, size_z, origin, d, ga) < _ray_error(heights, res, size_x, size_z, origin, d, gb): ghi = gb else: glo = ga return origin + d * ((glo + ghi) * 0.5) ## Minimum/Maximum des Feldes (PackedFloat32Array hat kein .min()/.max()). static func _min_h(heights: PackedFloat32Array) -> float: var out := INF for v in heights: if v < out: out = v return out static func _max_h(heights: PackedFloat32Array) -> float: var out := -INF for v in heights: if v > out: out = v return out ## 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)