scopedvr-map-editor/src/svmap/terrain_field.gd
Hermes (godot-vr) e505329a42 Terrain B (t_234490cf): Pinsel-Position == Cursor-Position — Ray→Terrain-Mapping gefixt
Root-Cause 1 (Spiegelung): terrain_mesh.gd positionierte die Mesh-Vertices
gespiegelt (+0.5 − index·cell in X UND Z), während ALLE anderen Verbraucher
der Heightmap der Dokument-Konvention folgen (Index 0 = −X/−Z-Ecke):
SvmapDocument.terrain_height_at, TerrainField.height_at/apply_brush und der
Match-Client (svmap_terrain.gd). Der Renderer war der einzige Ausreißer —
der Pinsel-Strahl traf das gerenderte Mesh punktgespiegelt um die Map-Mitte
(Sly-Befund: Erhöhung erschien an ganz anderer Stelle). Fix: Vertices auf
−size*0.5 + index*cell (identisch zum Client), Normalen-Vorzeichen angepasst
(n ∝ (−∂h/∂x, 1, −∂h/∂z)); Winding bleibt gültig (180°-Rotation erhält die
Orientierung, Kreuzprodukt-Normale weiter nach unten = Frontseite).

Root-Cause 2 (Tangenten-Graze): Ein Strahl, der exakt auf eine Hügelkuppe
zielt, grast die gewölbte Oberfläche — f(t) bleibt minimal knapp über 0 und
steigt danach wieder, obwohl weiter hinten (VERDECKT hinter dem Hügel) noch
ein Vorzeichenwechsel an der Basis-Ebene lag. Der March lief durch und der
Pinsel sprang auf den verdeckten Basis-Punkt meterweit weg. Fix in
TerrainField.raycast: Regel 'erster sichtbarer Oberflächen-Kontakt gewinnt' —
kommt f innerhalb des Bandes (½ March-Schritt) an die Oberfläche und entfernt
sich der Strahl wieder, wird das Graze-Minimum lokal per Ternärsuche
verfeinert und als Treffer zurückgegeben. Echter Treffer (f ≤ 0) gewinnt
weiterhin immer; betrifft auch Bauen-Werkzeug und Spawn-Zonen (gleiche
Raycast-Routine).

Tests (test_terrain.gd, 64 → 86 Checks): Neue Gruppe 'Ray→Terrain-Mapping'
mit simulierter Kamera (SubViewport, schief + steil): exakter Screen-Punkt
(unproject) → project_ray → raycast → asserted Treffer-Zelle == Cursor-Zelle
(< 1 Zelle) auf Mitte/Innenraum/Corner/Kanten; Brush-Stroke am Treffer mit
Höhen-Änderungs-Zentrum == erwarteter Zelle; erhabenes Terrain (Strahl trifft
NEUE Oberfläche, y = Hügelhöhe); Mesh-Vertices komplett gegen die
Dokument-Konvention asserted (Root-Cause-Regression). Hügel-Normalen-Checks
auf die korrigierte Konvention umgestellt (lehnt jetzt −X/−Z).

Suite: 7 Editor-Suiten 463 Checks grün + Export 21/0 + GUI-Smoke 20 grün.
2026-10-16 17:27:19 +00:00

303 lines
11 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 }
## 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)