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 } ## Editor-MVP-Perf-Limit (Karte t_9aba04f1): das Format erlaubt 17–513, aber ## der Editor formt im MVP nur Grids bis 129 (≈ 32k Dreiecke — flüssig beim ## Zeichnen, Ziel 60+ FPS). Neu-Maps werden direkt mit 129 angelegt. const EDITOR_MAX_RESOLUTION := 129 ## Höhe an Welt-X/Z auf einem bereits dekodierten Feld (bilinear). ## Gleiche Geometrie-Konvention wie SvmapDocument.terrain_height_at, aber ohne ## Base64-Neudekodierung pro Aufruf (macht den Pinsel flott). static func height_at(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, x: float, z: float) -> float: var last := float(res - 1) var fx := clampf((x / size_x + 0.5) * last, 0.0, last) var fz := clampf((z / size_z + 0.5) * last, 0.0, last) var xi := int(floor(fx)) var zi := int(floor(fz)) var tx := fx - float(xi) var tz := fz - float(zi) var xi1 := mini(xi + 1, res - 1) var zi1 := mini(zi + 1, res - 1) var h00 := heights[zi * res + xi] var h10 := heights[zi * res + xi1] var h01 := heights[zi1 * res + xi] var h11 := heights[zi1 * res + xi1] return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz) ## Pinsel anwenden. amount: ## RAISE/LOWER → Meter (bereits strength × dt vom Aufrufer), ## SMOOTH → Mischfaktor 0–1 (Anteil Richtung 3×3-Umgebungsmittel). ## Gewicht: Kosinus-Falloff (1 in der Mitte, 0 am Rand) — weiche Hügel statt ## Kegel. Rückgabe true, wenn mindestens ein Punkt verändert wurde. static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, hmin: float, hmax: float, cx: float, cz: float, radius: float, mode: int, amount: float) -> bool: if 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)) var changed := false var z := z0 while z <= z1: var x := x0 while x <= x1: var dx := (float(x) - gx) * cell_x var dz := (float(z) - gz) * cell_z var d := sqrt(dx * dx + dz * dz) if d >= radius: x += 1 continue var w := 0.5 * (1.0 + cos(PI * d / radius)) var idx := z * res + x var h := heights[idx] var nh := h + amount * w if mode == Mode.RAISE else h - amount * w nh = clampf(nh, hmin, hmax) if nh != h: heights[idx] = nh changed = true x += 1 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 ## Glätten: Mischung Richtung 3×3-Umgebungsmittel (zweiphasig — das Mittel wird ## aus dem STAND der Feldes berechnet, sonst wandert die Welle in eine Richtung). static func _apply_smooth(heights: PackedFloat32Array, res: int, hmin: float, hmax: float, gx: float, gz: float, r_cells_x: float, r_cells_z: float, x0: int, x1: int, z0: int, z1: int, amount: float) -> bool: var avg := PackedFloat32Array() avg.resize(heights.size()) var z := z0 while z <= z1: var x := x0 while x <= x1: var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001) var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001) var d := sqrt(dx * dx + dz * dz) if d >= 1.0: x += 1 continue var sum := 0.0 var count := 0 var zi := maxi(z - 1, 0) while zi <= mini(z + 1, res - 1): var xi := maxi(x - 1, 0) while xi <= mini(x + 1, res - 1): sum += heights[zi * res + xi] count += 1 xi += 1 zi += 1 avg[z * res + x] = sum / float(maxi(count, 1)) x += 1 z += 1 var changed := false z = z0 while z <= z1: var x := x0 while x <= x1: var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001) var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001) var d := sqrt(dx * dx + dz * dz) if d >= 1.0: x += 1 continue var w := 0.5 * (1.0 + cos(PI * d)) var idx := z * res + x var nh := lerpf(heights[idx], avg[idx], clampf(amount * w, 0.0, 1.0)) nh = clampf(nh, hmin, hmax) if nh != heights[idx]: heights[idx] = nh changed = true x += 1 z += 1 return changed ## Höhenfeld → Base64-Rohdaten (svmap-Format: uint16 LE, row-major). ## Rundung halbwegs offen (round) — Fehler ≤ ½ Quantisierungsschritt. static func encode(heights: PackedFloat32Array, hmin: float, hmax: float) -> String: var span := maxf(hmax - hmin, 0.000001) var bytes := PackedByteArray() bytes.resize(heights.size() * 2) for i in heights.size(): var v := int(round(clampf((heights[i] - hmin) / span, 0.0, 1.0) * 65535.0)) bytes.encode_u16(i * 2, v) return Marshalls.raw_to_base64(bytes) ## Cursor-Strahl → erster Treffer auf dem Höhenfeld (ray-march + Bisektion). ## Für den Pinsel: der Kreis muss UNTER dem Mauszeiger auf dem GELÄNDE liegen, ## nicht auf der flachen Basis-Ebene. Rückgabe null = kein Treffer. static func raycast(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, origin: Vector3, dir: Vector3, max_dist: float = 0.0) -> Variant: if res < 2 or heights.size() < res * res: return null if max_dist <= 0.0: max_dist = maxf(size_x, size_z) * 3.0 var d := dir if d.length_squared() < 0.0000001: return null d = d.normalized() var step := maxf(size_x, size_z) / float(res - 1) * 0.5 step = clampf(step, 0.5, 4.0) # 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)