scopedvr-map-core/svmap/terrain_field.gd
slyphax 17a8927fec Map-Kern aus dem Editor übernommen (Stand e8ab954)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:30:18 +01:00

456 lines
18 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)