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.
This commit is contained in:
parent
b7b160d3bc
commit
e505329a42
3 changed files with 222 additions and 14 deletions
|
|
@ -205,11 +205,27 @@ static func raycast(heights: PackedFloat32Array, res: int, size_x: float, size_z
|
|||
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
|
||||
|
|
@ -221,7 +237,20 @@ static func raycast(heights: PackedFloat32Array, res: int, size_x: float, size_z
|
|||
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)
|
||||
|
|
@ -231,6 +260,25 @@ static func raycast(heights: PackedFloat32Array, res: int, size_x: float, size_z
|
|||
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
|
||||
|
|
|
|||
|
|
@ -103,22 +103,28 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0
|
|||
var x := 0
|
||||
while x < res:
|
||||
var idx := z * res + x
|
||||
# Index→Welt-Konvention (Karte t_234490cf): Index 0 = −X/−Z-Ecke,
|
||||
# x-Index läuft MIT +WeltX, z-Index MIT +WeltZ — IDENTISCH zu
|
||||
# SvmapDocument.terrain_height_at, TerrainField.height_at und dem
|
||||
# Match-Client (svmap_terrain.gd: −size*0.5 + index*cell).
|
||||
# Vor b7b160d…+Fix stand hier eine SPIGELUNG (+0.5 − index·cell in
|
||||
# beiden Achsen) — der Renderer war damit der einzige Verbraucher
|
||||
# dieser Konvention: der Pinsel-Strahl traf das Mesh an der
|
||||
# punktgespiegelten Stelle (Sly-Befund „Pinsel wirkt woanders“).
|
||||
positions[idx] = Vector3(
|
||||
-x / last * size_x + size_x * 0.5,
|
||||
-size_x * 0.5 + float(x) * cell_x,
|
||||
heights[idx],
|
||||
-z / last * size_z + size_z * 0.5)
|
||||
# Normale aus zentralen Differenzen (Rand: einseitig geklemmt).
|
||||
# x-Index läuft GEGEN die Welt-X-Achse (Spiegelung in positions) —
|
||||
# die Gradienten-Terme hängen deshalb an hx_r/hx_l bzw. hz_u/hz_d:
|
||||
# n ∝ (-dh/dWeltX, 1, -dh/dWeltZ), skaliert mit 2·cell_x·cell_z.
|
||||
-size_z * 0.5 + float(z) * cell_z)
|
||||
# Normale aus zentralen Differenzen (Rand: einseitig geklemmt):
|
||||
# n ∝ (−∂h/∂x, 1, −∂h/∂z), skaliert mit 2·cell_x·cell_z.
|
||||
var hx_l := heights[z * res + maxi(x - 1, 0)]
|
||||
var hx_r := heights[z * res + mini(x + 1, res - 1)]
|
||||
var hz_d := heights[maxi(z - 1, 0) * res + x]
|
||||
var hz_u := heights[mini(z + 1, res - 1) * res + x]
|
||||
normals[idx] = Vector3(
|
||||
(hx_r - hx_l) * cell_z,
|
||||
(hx_l - hx_r) * cell_z,
|
||||
2.0 * cell_x * cell_z,
|
||||
(hz_u - hz_d) * cell_x).normalized()
|
||||
(hz_d - hz_u) * cell_x).normalized()
|
||||
var t := (heights[idx] - hmin) / span
|
||||
# Material-Farbe × Höhen-Faktor (Steigungen bleiben ablesbar).
|
||||
var mat_id := 0
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ var checks := 0
|
|||
|
||||
func _init() -> void:
|
||||
_run_tests()
|
||||
_finish()
|
||||
|
||||
|
||||
## Ray→Terrain-Gruppe braucht einen Viewport IM Baum: erst zwei Frames warten
|
||||
## (wie test_gui_smoke), dann Kamera-Mathe fahren und Danach beenden.
|
||||
func _finish() -> void:
|
||||
await process_frame
|
||||
await process_frame
|
||||
_test_ray_terrain_mapping()
|
||||
if failures == 0:
|
||||
print("ALL OK: %d checks" % checks)
|
||||
quit(0)
|
||||
|
|
@ -41,6 +50,7 @@ func _run_tests() -> void:
|
|||
_test_material_format()
|
||||
_test_mesh_winding_live_grid()
|
||||
_test_grid_toggle()
|
||||
# _test_ray_terrain_mapping läuft in _finish() — braucht Viewport im Baum.
|
||||
|
||||
|
||||
## Decode → Encode bleibt innerhalb eines Quantisierungsschritts (R16).
|
||||
|
|
@ -404,8 +414,9 @@ func _test_mesh_winding_live_grid() -> void:
|
|||
"Erster Pinsel-Druck: Vertex-Zentrum sofort auf %.2f m (Live-Pfad)" % pos2[center].y)
|
||||
_check((pos2[center] - pos[center]).length() > 5.0,
|
||||
"Erster Pinsel-Druck: Vertex-Position MESSBAR verschoben")
|
||||
# Hügel-Normale lehnt korrekt: x-Index läuft GEGEN die Welt-X-Achse —
|
||||
# Höhe steigt mit x → Oberfläche fällt Richtung +WeltX → Normale lehnt +X.
|
||||
# Hügel-Normale lehnt korrekt: Index→Welt-Konvention (t_234490cf) —
|
||||
# x-Index läuft MIT +WeltX → Höhe steigt Richtung +WeltX → Oberfläche
|
||||
# fällt Richtung −WeltX → Normale lehnt −X.
|
||||
var slope := PackedFloat32Array()
|
||||
slope.resize(res * res)
|
||||
for i in slope.size():
|
||||
|
|
@ -413,15 +424,15 @@ func _test_mesh_winding_live_grid() -> void:
|
|||
view.call("update_heights", slope, res, size, size)
|
||||
var nrm2: PackedVector3Array = (mi.mesh.surface_get_arrays(0))[Mesh.ARRAY_NORMAL]
|
||||
var n_mid: Vector3 = nrm2[16 * res + 16]
|
||||
_check(n_mid.x > 0.01 and n_mid.y > 0.99,
|
||||
"X-Hügel: Normale lehnt korrekt Richtung +WeltX (nicht invertiert, n=%s)" % n_mid)
|
||||
_check(n_mid.x < -0.01 and n_mid.y > 0.99,
|
||||
"X-Hügel: Normale lehnt korrekt Richtung −WeltX (nicht invertiert, n=%s)" % n_mid)
|
||||
for i in slope.size():
|
||||
slope[i] = 0.5 * float(i / res)
|
||||
view.call("update_heights", slope, res, size, size)
|
||||
var nrm3: PackedVector3Array = (mi.mesh.surface_get_arrays(0))[Mesh.ARRAY_NORMAL]
|
||||
var n_mid3: Vector3 = nrm3[16 * res + 16]
|
||||
_check(n_mid3.z > 0.01 and n_mid3.y > 0.99,
|
||||
"Z-Hügel: Normale lehnt korrekt Richtung +WeltZ (nicht invertiert, n=%s)" % n_mid3)
|
||||
_check(n_mid3.z < -0.01 and n_mid3.y > 0.99,
|
||||
"Z-Hügel: Normale lehnt korrekt Richtung −WeltZ (nicht invertiert, n=%s)" % n_mid3)
|
||||
view.free()
|
||||
|
||||
|
||||
|
|
@ -450,6 +461,149 @@ func _test_grid_toggle() -> void:
|
|||
builder.free()
|
||||
|
||||
|
||||
## Karte t_234490cf: Pinsel-Position == Cursor-Position. Simulierte Kamera +
|
||||
## exakter Screen-Punkt (unproject des Zielpunkts) → project_ray_origin/normal
|
||||
## → TerrainField.raycast → Brush-Stroke. asserted: Treffer/Stroke-Zentrum ==
|
||||
## exakt erwartete Terrain-Zelle (Toleranz < 1 Zelle). Regressionen: Rand-/
|
||||
## Corner-Zellen, schiefe Perspektive, erhabenes Terrain unter dem Cursor
|
||||
## (Strahl muss die NEUE Oberfläche treffen).
|
||||
## Gleicher Komplex wie der Renderer: zusätzlich asserted, dass ALLE
|
||||
## Mesh-Vertices der Dokument-Konvention folgen (Index 0 = −X/−Z-Ecke) —
|
||||
## das war die Root-Cause (Renderer spiegelte beide Achsen, Raycast-Mathe
|
||||
## nicht → Pinsel wirkte punktgespiegelt um die Map-Mitte).
|
||||
func _test_ray_terrain_mapping() -> void:
|
||||
print("Testgruppe: Ray→Terrain-Mapping (Pinsel-Position == Cursor-Position)")
|
||||
var res := 33
|
||||
var size := 160.0
|
||||
var cell := size / float(res - 1)
|
||||
var heights := PackedFloat32Array()
|
||||
heights.resize(res * res)
|
||||
|
||||
# --- Mesh-Vertices == Dokument-Konvention (die eigentliche Root-Cause) ---
|
||||
var view := Node3D.new()
|
||||
view.set_script(load("res://src/viewport/terrain_mesh.gd"))
|
||||
view.call("update_heights", heights, res, size, size)
|
||||
var mi = view.get("_mi")
|
||||
var pos: PackedVector3Array = (mi.mesh.surface_get_arrays(0))[Mesh.ARRAY_VERTEX]
|
||||
var all_match := true
|
||||
var half := size * 0.5
|
||||
for z in res:
|
||||
for x in res:
|
||||
var want := Vector3(-half + float(x) * cell, 0.0, -half + float(z) * cell)
|
||||
if pos[z * res + x].distance_to(want) > 0.001:
|
||||
all_match = false
|
||||
break
|
||||
_check(all_match, "Mesh-Vertices == Doc-Konvention (Index 0 = −X/−Z-Ecke, kein Spiegel)")
|
||||
# Eck-Vertices explizit: (0,0)→−X/−Z-Ecke, (res−1,0)→+X, (0,res−1)→+Z.
|
||||
_check(pos[0].distance_to(Vector3(-half, 0.0, -half)) < 0.001,
|
||||
"Vertex (0,0) liegt an der −X/−Z-Ecke")
|
||||
_check(pos[res - 1].distance_to(Vector3(half, 0.0, -half)) < 0.001,
|
||||
"Vertex (res−1,0) liegt an der +X/−Z-Ecke")
|
||||
_check(pos[(res - 1) * res].distance_to(Vector3(-half, 0.0, half)) < 0.001,
|
||||
"Vertex (0,res−1) liegt an der −X/+Z-Ecke")
|
||||
view.free()
|
||||
|
||||
# --- Simulierte Kamera: schief von oben (Süd-West, ~40° Elevation) ---
|
||||
var vp := SubViewport.new()
|
||||
vp.size = Vector2i(1280, 720)
|
||||
root.add_child(vp)
|
||||
var cam := Camera3D.new()
|
||||
vp.add_child(cam)
|
||||
cam.current = true
|
||||
cam.look_at_from_position(Vector3(-95.0, 115.0, 135.0), Vector3.ZERO, Vector3.UP)
|
||||
|
||||
# Cursor-Punkt → Ray → Raycast-Treffer muss auf der Zelle landen.
|
||||
var targets := [
|
||||
Vector2(0.0, 0.0), # Map-Mitte
|
||||
Vector2(40.0, 40.0), # Innenraum
|
||||
Vector2(size * 0.45, -size * 0.45), # nahe +X/−Z-CORNER
|
||||
Vector2(-size * 0.45, size * 0.45), # nahe −X/+Z-CORNER
|
||||
Vector2(-size * 0.48, 10.0), # nahe −X-KANTE
|
||||
Vector2(12.0, size * 0.48), # nahe +Z-KANTE
|
||||
]
|
||||
for tp in targets:
|
||||
var surface := Vector3(tp.x, 0.0, tp.y)
|
||||
var screen := cam.unproject_position(surface)
|
||||
var origin := cam.project_ray_origin(screen)
|
||||
var dir := cam.project_ray_normal(screen)
|
||||
var hit: Variant = TerrainField.raycast(heights, res, size, size, origin, dir)
|
||||
_check(hit != null, "Raycast trifft bei Ziel %s" % tp)
|
||||
if hit == null:
|
||||
continue
|
||||
var p := hit as Vector3
|
||||
var dx := absf(p.x - tp.x)
|
||||
var dz := absf(p.z - tp.y)
|
||||
_check(dx < cell and dz < cell,
|
||||
"Treffer-Zelle == Cursor-Zelle bei %s (Abweichung %.2f/%.2f m, Zelle %.1f m)" % [
|
||||
tp, dx, dz, cell])
|
||||
|
||||
# Brush-Stroke am Trefferpunkt: Zentrum der Höhen-Änderung == Cursor-Zelle.
|
||||
var stroke_point: Vector2 = targets[2] # Corner-Nähe, die empfindlichste Stelle
|
||||
var screen2 := cam.unproject_position(Vector3(stroke_point.x, 0.0, stroke_point.y))
|
||||
var hit2: Variant = TerrainField.raycast(heights, res, size, size,
|
||||
cam.project_ray_origin(screen2), cam.project_ray_normal(screen2))
|
||||
if hit2 != null:
|
||||
var before := heights.duplicate()
|
||||
TerrainField.apply_brush(heights, res, size, size, -100000.0, 100000.0,
|
||||
(hit2 as Vector3).x, (hit2 as Vector3).z, 12.0,
|
||||
TerrainField.Mode.RAISE, 8.0)
|
||||
var best := 0
|
||||
var best_d := -1.0
|
||||
for i in heights.size():
|
||||
var d := heights[i] - before[i]
|
||||
if d > best_d:
|
||||
best_d = d
|
||||
best = i
|
||||
var bi := best % res
|
||||
var bz := best / res
|
||||
var want_gx := int(round(((hit2 as Vector3).x / size + 0.5) * float(res - 1)))
|
||||
var want_gz := int(round(((hit2 as Vector3).z / size + 0.5) * float(res - 1)))
|
||||
_check(absi(bi - want_gx) <= 1 and absi(bz - want_gz) <= 1,
|
||||
"Stroke-Zentrum == erwartete Terrain-Zelle (%d,%d vs. %d,%d)" % [
|
||||
bi, bz, want_gx, want_gz])
|
||||
_check(best_d > 1.0, "Stroke hat messbar erhöht (%.2f m)" % best_d)
|
||||
else:
|
||||
_check(false, "Corner-Stroke: Raycast trifft")
|
||||
|
||||
# --- Erhabenes Terrain: Strahl muss die NEUE Oberfläche treffen ---
|
||||
var hill := Vector2(20.0, -25.0)
|
||||
TerrainField.apply_brush(heights, res, size, size, -100000.0, 100000.0,
|
||||
hill.x, hill.y, 25.0, TerrainField.Mode.RAISE, 30.0)
|
||||
var h_hill := TerrainField.height_at(heights, res, size, size, hill.x, hill.y)
|
||||
_check(h_hill > 5.0, "Hügel steht (%.1f m)" % h_hill)
|
||||
var screen3 := cam.unproject_position(Vector3(hill.x, h_hill, hill.y))
|
||||
var hit3: Variant = TerrainField.raycast(heights, res, size, size,
|
||||
cam.project_ray_origin(screen3), cam.project_ray_normal(screen3))
|
||||
if hit3 != null:
|
||||
var p3 := hit3 as Vector3
|
||||
_check(absf(p3.x - hill.x) < cell and absf(p3.z - hill.y) < cell,
|
||||
"Erhaben: Treffer bleibt auf Cursor-Zelle (%.2f/%.2f m)" % [
|
||||
absf(p3.x - hill.x), absf(p3.z - hill.y)])
|
||||
_check(absf(p3.y - h_hill) < 1.5,
|
||||
"Erhaben: Strahl trifft die NEUE Oberfläche (y=%.2f vs. %.2f)" % [p3.y, h_hill])
|
||||
else:
|
||||
_check(false, "Erhaben: Raycast trifft")
|
||||
|
||||
# --- Steile Draufsicht (zweite Perspektive, Corner-Nähe) ---
|
||||
var cam2 := Camera3D.new()
|
||||
vp.add_child(cam2)
|
||||
cam2.current = true
|
||||
cam2.look_at_from_position(Vector3(0.0, 170.0, 25.0), Vector3.ZERO, Vector3.UP)
|
||||
var steep_target := Vector2(-size * 0.44, -size * 0.2)
|
||||
var screen4 := cam2.unproject_position(Vector3(steep_target.x, 0.0, steep_target.y))
|
||||
var hit4: Variant = TerrainField.raycast(heights, res, size, size,
|
||||
cam2.project_ray_origin(screen4), cam2.project_ray_normal(screen4))
|
||||
if hit4 != null:
|
||||
var p4 := hit4 as Vector3
|
||||
_check(absf(p4.x - steep_target.x) < cell and absf(p4.z - steep_target.y) < cell,
|
||||
"Steile Perspektive: Treffer-Zelle == Cursor-Zelle (%.2f/%.2f m)" % [
|
||||
absf(p4.x - steep_target.x), absf(p4.z - steep_target.y)])
|
||||
else:
|
||||
_check(false, "Steile Perspektive: Raycast trifft")
|
||||
|
||||
vp.free()
|
||||
|
||||
|
||||
func _variance_block(a: PackedFloat32Array, res: int, x0: int, x1: int, z0: int, z1: int) -> float:
|
||||
var s := 0.0
|
||||
var count := 0
|
||||
|
|
|
|||
Loading…
Reference in a new issue