Karte t_7a0a6b79 Terrain-Qualitaet: Splat-Top-2-Blend (weiche Uebergaenge), Live-Sub-Rect beim Malen, Ausbrennen-Fix (Shading-Normalisierung)
This commit is contained in:
parent
f5a4ab54d0
commit
04c6c67ba8
10 changed files with 461 additions and 9 deletions
0
assets/textures/terrain/.gdignore
Normal file
0
assets/textures/terrain/.gdignore
Normal file
Binary file not shown.
|
|
@ -16,3 +16,141 @@ class_name TerrainArrays
|
||||||
@export var albedo_images: Array[Image] = []
|
@export var albedo_images: Array[Image] = []
|
||||||
@export var normal_images: Array[Image] = []
|
@export var normal_images: Array[Image] = []
|
||||||
@export var tile_size := 1.0 # Meter pro Kachel (Worldspace-Tiling)
|
@export var tile_size := 1.0 # Meter pro Kachel (Worldspace-Tiling)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ SPLAT-GEWICHTS-ABLEITUNG ======================
|
||||||
|
# Karte t_7a0a6b79 (weiche Übergänge): der alte Shader pickte pro Pixel GENAU
|
||||||
|
# EINE Textur-Schicht über die Vertex-Alpha (floor+0.5-Snap) — an Dreieck-
|
||||||
|
# kanten sprang die GPU-interpolierte Alpha hart zwischen Material-IDs
|
||||||
|
# (blockartige, dreieckstreppige Grenzen). NEU: aus der Zell-Schicht
|
||||||
|
# (materials_r8, res²) wird eine SPLAT-GEWICHTS-TEXTUR abgeleitet — je Texel
|
||||||
|
# die Top-2-Zell-IDs mit bilinearen Kernel-Gewichten:
|
||||||
|
# R = id0 (0..N-1), G = w0, B = id1, A = w1 (w0 + w1 = 1 normalisiert)
|
||||||
|
# Der Shader sampelt die Splat-Textur (FILTER_NEAREST — IDs dürfen NICHT
|
||||||
|
# bilinear vermischt werden; die Weichheit steckt IN den Gewichten) und mischt
|
||||||
|
# die zwei Layer: mix(tex(id1), tex(id0), w0). Datenformat UNVERÄNDERT — die
|
||||||
|
# Zell-Schicht bleibt die Quelle der Wahrheit.
|
||||||
|
#
|
||||||
|
# Texel-Dichte: SPLAT_TEXELS_PER_CELL Texel je Terrain-Zelle (4 × res−1),
|
||||||
|
# als Zweierpotenz geklemmt auf [SPLAT_SIDE_MIN, SPLAT_SIDE_MAX] — beim Malen
|
||||||
|
# kann dann ein kleiner Sub-Rect neu abgeleitet werden (Live-Pfad).
|
||||||
|
const SPLAT_TEXELS_PER_CELL := 4
|
||||||
|
const SPLAT_SIDE_MIN := 256
|
||||||
|
const SPLAT_SIDE_MAX := 1024
|
||||||
|
|
||||||
|
|
||||||
|
## Splat-Textur-Kantengröße für ein res×res-Zellraster (Zweierpotenz).
|
||||||
|
static func splat_side_for(res: int) -> int:
|
||||||
|
var target := maxi(1, res - 1) * SPLAT_TEXELS_PER_CELL
|
||||||
|
var side := SPLAT_SIDE_MIN
|
||||||
|
while side < target:
|
||||||
|
side *= 2
|
||||||
|
return mini(side, SPLAT_SIDE_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
## Splat-Gewichts-Bytes (RGBA8, row-major) für die Zell-Schicht ableiten.
|
||||||
|
## rect = Texel-Rechteck (position/size im Bild) — leer = ganzes Bild.
|
||||||
|
## Bilinearer Kernel über die 4 umliegenden Zellen: an einer Zellgrenze rampen
|
||||||
|
## die Gewichte weich 0→1 (Weichheit = eine Zellbreite, wie in der Spec).
|
||||||
|
## Test-Vertrag: identische Formel in Client UND Editor (Spiegel-Datei).
|
||||||
|
static func build_splat_bytes(mats: PackedByteArray, res: int, side: int,
|
||||||
|
rect: Rect2i = Rect2i()) -> PackedByteArray:
|
||||||
|
var out := PackedByteArray()
|
||||||
|
if res < 2 or side < 1 or mats.size() < res * res:
|
||||||
|
return out
|
||||||
|
var rx := 0
|
||||||
|
var ry := 0
|
||||||
|
var rw := side
|
||||||
|
var rh := side
|
||||||
|
if rect.size.x > 0 and rect.size.y > 0:
|
||||||
|
rx = clampi(rect.position.x, 0, side - 1)
|
||||||
|
ry = clampi(rect.position.y, 0, side - 1)
|
||||||
|
rw = clampi(rect.size.x, 1, side - rx)
|
||||||
|
rh = clampi(rect.size.y, 1, side - ry)
|
||||||
|
out.resize(rw * rh * 4)
|
||||||
|
var last := float(res - 1)
|
||||||
|
# Achsen-Gewichte einmal vorrechnen (hängen nur von der Texel-Spalte/Zeile ab):
|
||||||
|
var ix0 := PackedInt32Array()
|
||||||
|
var ix1 := PackedInt32Array()
|
||||||
|
var fx := PackedFloat32Array()
|
||||||
|
ix0.resize(rw)
|
||||||
|
ix1.resize(rw)
|
||||||
|
fx.resize(rw)
|
||||||
|
for i in rw:
|
||||||
|
var t := (float(rx + i) + 0.5) / float(side) * last
|
||||||
|
var i0 := clampi(int(floorf(t)), 0, res - 1)
|
||||||
|
ix0[i] = i0
|
||||||
|
ix1[i] = mini(i0 + 1, res - 1)
|
||||||
|
fx[i] = t - float(i0)
|
||||||
|
var iz0 := PackedInt32Array()
|
||||||
|
var iz1 := PackedInt32Array()
|
||||||
|
var fz := PackedFloat32Array()
|
||||||
|
iz0.resize(rh)
|
||||||
|
iz1.resize(rh)
|
||||||
|
fz.resize(rh)
|
||||||
|
for j in rh:
|
||||||
|
var t := (float(ry + j) + 0.5) / float(side) * last
|
||||||
|
var j0 := clampi(int(floorf(t)), 0, res - 1)
|
||||||
|
iz0[j] = j0
|
||||||
|
iz1[j] = mini(j0 + 1, res - 1)
|
||||||
|
fz[j] = t - float(j0)
|
||||||
|
var ofs := 0
|
||||||
|
for j in rh:
|
||||||
|
var wz0 := 1.0 - fz[j]
|
||||||
|
var wz1 := fz[j]
|
||||||
|
var row0 := iz0[j] * res
|
||||||
|
var row1 := iz1[j] * res
|
||||||
|
for i in rw:
|
||||||
|
var wx0 := 1.0 - fx[i]
|
||||||
|
var wx1 := fx[i]
|
||||||
|
# ≤ 4 Zellen mit bilinearen Gewichten; Duplikat-IDs zusammenziehen:
|
||||||
|
var ids := PackedInt32Array()
|
||||||
|
var ws := PackedFloat32Array()
|
||||||
|
var cells := PackedInt32Array([
|
||||||
|
row0 + ix0[i], row0 + ix1[i], row1 + ix0[i], row1 + ix1[i]])
|
||||||
|
var weights := [wx0 * wz0, wx1 * wz0, wx0 * wz1, wx1 * wz1]
|
||||||
|
for c in 4:
|
||||||
|
var id := int(mats[cells[c]])
|
||||||
|
var w: float = weights[c]
|
||||||
|
var found := false
|
||||||
|
for k in ids.size():
|
||||||
|
if ids[k] == id:
|
||||||
|
ws[k] += w
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
if not found and w > 0.0:
|
||||||
|
ids.append(id)
|
||||||
|
ws.append(w)
|
||||||
|
# Top-2 IDs nach Gewicht (Fenster von max. 4 Kandidaten):
|
||||||
|
var id0 := ids[0]
|
||||||
|
var w0 := ws[0]
|
||||||
|
var id1 := id0
|
||||||
|
var w1 := 0.0
|
||||||
|
for k in range(1, ids.size()):
|
||||||
|
if ws[k] > w0:
|
||||||
|
id1 = id0
|
||||||
|
w1 = w0
|
||||||
|
id0 = ids[k]
|
||||||
|
w0 = ws[k]
|
||||||
|
elif ws[k] > w1 or id1 == id0:
|
||||||
|
id1 = ids[k]
|
||||||
|
w1 = ws[k]
|
||||||
|
# Normalisieren (w0 + w1 = 1) und 8-bit-packen:
|
||||||
|
var wsum := maxf(w0 + w1, 0.0001)
|
||||||
|
var b0 := clampi(roundi(w0 / wsum * 255.0), 0, 255)
|
||||||
|
out.encode_u8(ofs, clampi(id0, 0, 255))
|
||||||
|
out.encode_u8(ofs + 1, b0)
|
||||||
|
out.encode_u8(ofs + 2, clampi(id1, 0, 255))
|
||||||
|
out.encode_u8(ofs + 3, 255 - b0)
|
||||||
|
ofs += 4
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## Ein Splat-Texel dekodieren (für Tests): → [id0, w0, id1, w1] (w0 ∈ 0..1).
|
||||||
|
static func decode_splat_texel(bytes: PackedByteArray, texel_ofs: int) -> Array:
|
||||||
|
return [
|
||||||
|
int(bytes.decode_u8(texel_ofs)),
|
||||||
|
float(bytes.decode_u8(texel_ofs + 1)) / 255.0,
|
||||||
|
int(bytes.decode_u8(texel_ofs + 2)),
|
||||||
|
float(bytes.decode_u8(texel_ofs + 3)) / 255.0,
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,11 @@ func _process(delta: float) -> void:
|
||||||
if _stroke_mode == TerrainField.Mode.PAINT:
|
if _stroke_mode == TerrainField.Mode.PAINT:
|
||||||
var painted := _paint_step(_cursor.x, _cursor.z)
|
var painted := _paint_step(_cursor.x, _cursor.z)
|
||||||
if painted:
|
if painted:
|
||||||
view_builder.call("update_terrain", _heights, _mats)
|
# Karte t_7a0a6b79: Malen lebt im Sub-Rect — update_terrain bekommt
|
||||||
|
# das betroffene Zellrechteck (inkl. Spiegelzellen) und leitet nur
|
||||||
|
# die Splat-Region neu ab; das Mesh selbst bleibt unangetastet
|
||||||
|
# (Vertex-Farben hängen NICHT vom Material ab).
|
||||||
|
view_builder.call("update_terrain", _heights, _mats, _splat_rect)
|
||||||
_ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3
|
_ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3
|
||||||
return
|
return
|
||||||
var amount: float
|
var amount: float
|
||||||
|
|
@ -424,6 +428,9 @@ func _height_step(x: float, z: float, amount: float) -> bool:
|
||||||
## Ein Pinsel-Schritt MATERIAL (Malen) inkl. Spiegel-Kopie.
|
## Ein Pinsel-Schritt MATERIAL (Malen) inkl. Spiegel-Kopie.
|
||||||
func _paint_step(x: float, z: float) -> bool:
|
func _paint_step(x: float, z: float) -> bool:
|
||||||
var g := _world_to_grid(x, z)
|
var g := _world_to_grid(x, z)
|
||||||
|
# VOR dem Pinsel: Zellrect des Fußabdrucks merken (deckt Spiegelzellen mit)
|
||||||
|
# — der Live-Splat-Refresh (t_7a0a6b79) leitet genau diese Region neu ab.
|
||||||
|
_splat_rect = _splat_region(g)
|
||||||
var snap := {}
|
var snap := {}
|
||||||
if _sym_active() and _mats.size() >= _res * _res:
|
if _sym_active() and _mats.size() >= _res * _res:
|
||||||
snap = _snap_mats(_brush_region(g))
|
snap = _snap_mats(_brush_region(g))
|
||||||
|
|
@ -558,3 +565,27 @@ func terrain_mesh_color(id: int) -> Color:
|
||||||
return Color(0.45, 0.44, 0.42) # Fels
|
return Color(0.45, 0.44, 0.42) # Fels
|
||||||
_:
|
_:
|
||||||
return Color(0.30, 0.45, 0.22) # Gras
|
return Color(0.30, 0.45, 0.22) # Gras
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------ Live-Splat-Sub-Rect (Karte
|
||||||
|
# t_7a0a6b79): Zellrechteck des letzten Mal-Schritts für das Live-Update der
|
||||||
|
# Splat-Gewichtstextur. Am Dateiende angefügt (Karten-Regel).
|
||||||
|
|
||||||
|
## Zellrect des letzten Mal-Schritts (Pinsel-Fußabdruck ∪ gespiegelte
|
||||||
|
## Schwesterzellen) — vom Stroke-Code als 3. Parameter an update_terrain
|
||||||
|
## (view_builder) weitergereicht.
|
||||||
|
var _splat_rect := Rect2i()
|
||||||
|
|
||||||
|
|
||||||
|
## Zellrect des Pinsel-Fußabdrucks (∪ Spiegelzellen): dieselben Region-
|
||||||
|
## Grenzen wie apply_material_brush, mit dem SYM_REGION_MARGIN-Rand — der
|
||||||
|
## deckt auch die Kernel-Nachbarschaft der Splat-Ableitung mit ab.
|
||||||
|
func _splat_region(g: Vector2) -> Rect2i:
|
||||||
|
var rect := _brush_region(g)
|
||||||
|
var sym := _sym_mode()
|
||||||
|
if sym == SymmetryCore.Mode.OFF:
|
||||||
|
return rect
|
||||||
|
var a := SymmetryCore.mirror_cell(sym, rect.position.x, rect.position.y, _res)
|
||||||
|
var b := SymmetryCore.mirror_cell(sym, rect.end.x - 1, rect.end.y - 1, _res)
|
||||||
|
return rect.merge(Rect2i(mini(a.x, b.x), mini(a.y, b.y),
|
||||||
|
absi(a.x - b.x) + 1, absi(a.y - b.y) + 1))
|
||||||
|
|
|
||||||
|
|
@ -192,9 +192,13 @@ func _build_wasser(data: Dictionary) -> void:
|
||||||
## mats (optional) = Material-Schicht (Karte t_603ec1a8, Malen-Modus) —
|
## mats (optional) = Material-Schicht (Karte t_603ec1a8, Malen-Modus) —
|
||||||
## leer = terrain_mesh verwendet die zuletzt gesehene Schicht weiter (Cache,
|
## leer = terrain_mesh verwendet die zuletzt gesehene Schicht weiter (Cache,
|
||||||
## Karte t_55ed041a); nur bei NIE gesehener Schicht (Legacy-v1) gilt Gras.
|
## Karte t_55ed041a); nur bei NIE gesehener Schicht (Legacy-v1) gilt Gras.
|
||||||
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray()) -> void:
|
## splat_rect_cells (optional, Karte t_7a0a6b79) = Zellrechteck des Mal-
|
||||||
|
## Schritts (inkl. Spiegelzellen) — terrain_mesh leitet nur die Splat-Region
|
||||||
|
## neu ab (Sub-Rect-Update, kein Voll-Rebuild pro Mal-Frame).
|
||||||
|
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray(),
|
||||||
|
splat_rect_cells: Rect2i = Rect2i()) -> void:
|
||||||
if terrain_view != null and is_instance_valid(terrain_view):
|
if terrain_view != null and is_instance_valid(terrain_view):
|
||||||
terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats)
|
terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats, splat_rect_cells)
|
||||||
|
|
||||||
|
|
||||||
## Nur das Terrain neu aufbauen (nach Strich-Commit/Undo) — Objekt-Marker und
|
## Nur das Terrain neu aufbauen (nach Strich-Commit/Undo) — Objekt-Marker und
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,24 @@ uniform float layer_count = 20.0;
|
||||||
uniform float tile_size = 1.0;
|
uniform float tile_size = 1.0;
|
||||||
uniform float texturen_an = 0.0; // 1.0 = Texturen aktiv (vom Mesh-Code gesetzt)
|
uniform float texturen_an = 0.0; // 1.0 = Texturen aktiv (vom Mesh-Code gesetzt)
|
||||||
|
|
||||||
|
// Splat-Gewichtstextur (Karte t_7a0a6b79 — weiche Übergänge): je Texel die
|
||||||
|
// Top-2-Zell-IDs + bilineare Gewichte, von TerrainArrays.build_splat_bytes
|
||||||
|
// abgeleitet (Spiegel-Datei, identische Formel im Client). R=id0, G=w0,
|
||||||
|
// B=id1, A=w1 (w0+w1=1). FILTER_NEAREST: IDs dürfen NICHT bilinear vermisch
|
||||||
|
// werden — die Weichheit steckt IN den Gewichten, nicht in der Filterung.
|
||||||
|
// Ohne source_color-Hint: raw-Bytes, id = round(kanal × 255).
|
||||||
|
uniform sampler2D splat_map : filter_nearest, repeat_disable;
|
||||||
|
uniform float map_size_x = 1.0; // Terrain-Kantenlänge in Weltmetern
|
||||||
|
uniform float map_size_z = 1.0;
|
||||||
|
|
||||||
varying vec3 world_pos;
|
varying vec3 world_pos;
|
||||||
|
varying vec2 map_uv;
|
||||||
|
|
||||||
void vertex() {
|
void vertex() {
|
||||||
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||||
|
// UV 0..1 über die Map (Index 0 = −X/−Z-Ecke, konventionstreu zum Mesh-Bau).
|
||||||
|
map_uv = vec2((world_pos.x + 0.5 * map_size_x) / max(map_size_x, 0.001),
|
||||||
|
(world_pos.z + 0.5 * map_size_z) / max(map_size_z, 0.001));
|
||||||
}
|
}
|
||||||
|
|
||||||
void fragment() {
|
void fragment() {
|
||||||
|
|
@ -41,10 +55,16 @@ void fragment() {
|
||||||
// Normal-Map würde ohne Licht ohnehin nichts zeigen — die Relief-Optik
|
// Normal-Map würde ohne Licht ohnehin nichts zeigen — die Relief-Optik
|
||||||
// kommt aus dem gebackenen Hillshade in COLOR.rgb).
|
// kommt aus dem gebackenen Hillshade in COLOR.rgb).
|
||||||
if (texturen_an > 0.5) {
|
if (texturen_an > 0.5) {
|
||||||
float mat_idx = clamp(floor(COLOR.a * layer_count + 0.5), 0.0, layer_count - 1.0);
|
// Top-2-Splat-Blend (t_7a0a6b79): die Weichheit steckt in den Gewich-
|
||||||
|
// ten der Splat-Textur — an Zellgrenzen rampen die IDs weich (bilinear
|
||||||
|
// über die 4 umliegenden Zellen), im Flachbereich w0=1 (reines id0).
|
||||||
|
vec4 sp = texture(splat_map, map_uv);
|
||||||
|
float id0 = clamp(floor(sp.r * 255.0 + 0.5), 0.0, layer_count - 1.0);
|
||||||
|
float id1 = clamp(floor(sp.b * 255.0 + 0.5), 0.0, layer_count - 1.0);
|
||||||
vec2 uv = world_pos.xz / max(tile_size, 0.05);
|
vec2 uv = world_pos.xz / max(tile_size, 0.05);
|
||||||
vec4 alb = texture(albedo_array, vec3(uv, mat_idx));
|
vec4 alb0 = texture(albedo_array, vec3(uv, id0));
|
||||||
col = alb.rgb * col; // Tönung/Shading multipliziert (wie Client)
|
vec4 alb1 = texture(albedo_array, vec3(uv, id1));
|
||||||
|
col = mix(alb1.rgb, alb0.rgb, sp.g) * col; // Tönung/Shading multipliziert (wie Client)
|
||||||
}
|
}
|
||||||
// --- Konturlinien (unverändert, Karte t_3ebe4086) ---
|
// --- Konturlinien (unverändert, Karte t_3ebe4086) ---
|
||||||
float h = UV2.x;
|
float h = UV2.x;
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,16 @@ var _triangles := 0
|
||||||
var _contours_pref := true # UI-Toggle „Höhenlinien“ (Default AN)
|
var _contours_pref := true # UI-Toggle „Höhenlinien“ (Default AN)
|
||||||
var _shader: Shader
|
var _shader: Shader
|
||||||
|
|
||||||
|
# --- Splat-Gewichtstextur (Karte t_7a0a6b79 — weiche Übergänge) -------------
|
||||||
|
# CPU-Image + GPU-Textur der Top-2-Splat-Gewichte (RGBA8: R=id0 G=w0 B=id1
|
||||||
|
# A=w1, Formel TerrainArrays.build_splat_bytes — Spiegel-Datei). Beim Malen
|
||||||
|
# wird NUR die betroffene Region neu abgeleitet (Sub-Rect-Update, siehe
|
||||||
|
# _refresh_splat_region) — kein Voll-Rebuild pro Mal-Frame.
|
||||||
|
var _splat_image: Image
|
||||||
|
var _splat_tex: ImageTexture
|
||||||
|
var _splat_side := 0 # Texel-Kantenlänge (Zweierpotenz)
|
||||||
|
var _splat_res := 0 # Zellraster-Auflösung, für die die Splat gilt
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_ensure_mesh_instance()
|
_ensure_mesh_instance()
|
||||||
|
|
@ -165,6 +175,9 @@ func rebuild_from_doc(data: Dictionary) -> void:
|
||||||
if _mi != null:
|
if _mi != null:
|
||||||
_mi.mesh = null
|
_mi.mesh = null
|
||||||
return
|
return
|
||||||
|
# Splat-Textur verwerfen — sie wird im update_heights frisch aus der
|
||||||
|
# (nach Undo/Load evtl. geänderten) Zell-Schicht abgeleitet.
|
||||||
|
_splat_tex = null
|
||||||
# Wasser (Karte t_28fdef03): Höhe merken — der Matsch-Ring (Ufer-Automatik)
|
# Wasser (Karte t_28fdef03): Höhe merken — der Matsch-Ring (Ufer-Automatik)
|
||||||
# mischt sich in relief_color ein. INF = kein Wasser (Legacy unverändert).
|
# mischt sich in relief_color ein. INF = kein Wasser (Legacy unverändert).
|
||||||
_wasser_level = SvmapWasser.level(data)
|
_wasser_level = SvmapWasser.level(data)
|
||||||
|
|
@ -182,7 +195,8 @@ func rebuild_from_doc(data: Dictionary) -> void:
|
||||||
## BEHALTEN statt live auf alles-Gras zurückzufallen (Karte t_55ed041a).
|
## BEHALTEN statt live auf alles-Gras zurückzufallen (Karte t_55ed041a).
|
||||||
## Nur wenn NIE eine Schicht gesehen wurde (Legacy-v1-Map) gilt alles Gras.
|
## Nur wenn NIE eine Schicht gesehen wurde (Legacy-v1-Map) gilt alles Gras.
|
||||||
func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0.0,
|
func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0.0,
|
||||||
size_z: float = 0.0, mats: PackedByteArray = PackedByteArray()) -> void:
|
size_z: float = 0.0, mats: PackedByteArray = PackedByteArray(),
|
||||||
|
splat_rect_cells: Rect2i = Rect2i()) -> void:
|
||||||
if res < 2:
|
if res < 2:
|
||||||
res = _res
|
res = _res
|
||||||
if size_x <= 0.0:
|
if size_x <= 0.0:
|
||||||
|
|
@ -296,6 +310,20 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0
|
||||||
_mi.mesh = mesh
|
_mi.mesh = mesh
|
||||||
_triangles = indices.size() / 3
|
_triangles = indices.size() / 3
|
||||||
|
|
||||||
|
# --- Splat-Gewichtstextur (t_7a0a6b79): map_size + Sub-Rect-Update --------
|
||||||
|
# map_size_x/z in Weltmetern (Shader rechnet map_uv = Weltkoord. über Map).
|
||||||
|
# Mal-Stroke: splat_rect_cells = betroffenes Zellrechteck → NUR die Region
|
||||||
|
# neu ableiten. Voll-Rebuild (Load/Undo/Stroke-Ende): Textur frisch bauen.
|
||||||
|
var mat := _mi.material_override
|
||||||
|
if mat is ShaderMaterial:
|
||||||
|
var shm := mat as ShaderMaterial
|
||||||
|
shm.set_shader_parameter("map_size_x", size_x)
|
||||||
|
shm.set_shader_parameter("map_size_z", size_z)
|
||||||
|
if splat_rect_cells.size.x > 0 and _splat_tex != null and _splat_res == res:
|
||||||
|
_refresh_splat_region(splat_rect_cells)
|
||||||
|
elif _splat_tex == null or _splat_res != res:
|
||||||
|
_build_splat(shm, mats, res)
|
||||||
|
|
||||||
|
|
||||||
## Höhenlinien-Uniform an die aktuelle Situation anpassen (Pref ∧ Relief ≥ 2 m).
|
## Höhenlinien-Uniform an die aktuelle Situation anpassen (Pref ∧ Relief ≥ 2 m).
|
||||||
func _apply_contour_state(span_ok: bool) -> void:
|
func _apply_contour_state(span_ok: bool) -> void:
|
||||||
|
|
@ -327,7 +355,12 @@ static func relief_color(mat_id: int, h: float, hmin: float, hmax: float,
|
||||||
var nd := clampf(normal.normalized().dot(RELIEF_SUN.normalized()), 0.0, 1.0)
|
var nd := clampf(normal.normalized().dot(RELIEF_SUN.normalized()), 0.0, 1.0)
|
||||||
var x := clampf((nd - LIGHT_T0) / (LIGHT_T1 - LIGHT_T0), 0.0, 1.0)
|
var x := clampf((nd - LIGHT_T0) / (LIGHT_T1 - LIGHT_T0), 0.0, 1.0)
|
||||||
var lf := lerpf(LIGHT_LOW, LIGHT_HIGH, x * x * (3.0 - 2.0 * x))
|
var lf := lerpf(LIGHT_LOW, LIGHT_HIGH, x * x * (3.0 - 2.0 * x))
|
||||||
var f := clampf(hf * lf, SHADE_MIN, SHADE_MAX)
|
# Ausbrennen-Fix (t_7a0a6b79): der Faktor wird auf 1.0 normalisiert. Vorher
|
||||||
|
# lief hf·lf bis 1.35 und RGBA8 klemmte auf 1.0 — ALLE Relief-Unterschiede
|
||||||
|
# oberhalb 1.0 kollabierten zu flachem Weiß (Slys „ausgebrannte Weißflächen“
|
||||||
|
# auf Sand hell/Schnee). Relative Spanne bleibt identisch (0.40…1.35 →
|
||||||
|
# 0.296…1.0), Kontrast-Verhältnis unverändert, Shading bleibt sichtbar.
|
||||||
|
var f := clampf(hf * lf, SHADE_MIN, SHADE_MAX) / SHADE_MAX
|
||||||
var base := MATERIAL_COLORS[m] as Color
|
var base := MATERIAL_COLORS[m] as Color
|
||||||
# Kanäle auf 0..1 klemmen: der Mesh-Speicher (ARRAY_COLOR, RGBA8) clamp't
|
# Kanäle auf 0..1 klemmen: der Mesh-Speicher (ARRAY_COLOR, RGBA8) clamp't
|
||||||
# genauso — der Helfer muss die RENDER-Wahrheit liefern (Probe-Vergleich),
|
# genauso — der Helfer muss die RENDER-Wahrheit liefern (Probe-Vergleich),
|
||||||
|
|
@ -342,3 +375,45 @@ func triangle_count() -> int:
|
||||||
|
|
||||||
func resolution() -> int:
|
func resolution() -> int:
|
||||||
return _res
|
return _res
|
||||||
|
|
||||||
|
|
||||||
|
## Splat-Gewichtstextur komplett ableiten (t_7a0a6b79). Die Zell-Schicht
|
||||||
|
## (mats, res²) bleibt die Quelle der Wahrheit — die Textur ist reine
|
||||||
|
## Ableitung (Datenformat unverändert). Unbrauchbare Schicht → alte Textur
|
||||||
|
## bleibt (Robustheit, kein Crash).
|
||||||
|
func _build_splat(mat: ShaderMaterial, mats: PackedByteArray, res: int) -> void:
|
||||||
|
if res < 2:
|
||||||
|
_splat_tex = null
|
||||||
|
return
|
||||||
|
var side := TerrainArrays.splat_side_for(res)
|
||||||
|
var bytes := TerrainArrays.build_splat_bytes(mats, res, side)
|
||||||
|
if bytes.size() != side * side * 4:
|
||||||
|
return
|
||||||
|
_splat_image = Image.create_from_data(side, side, false, Image.FORMAT_RGBA8, bytes)
|
||||||
|
_splat_side = side
|
||||||
|
_splat_res = res
|
||||||
|
_splat_tex = ImageTexture.create_from_image(_splat_image)
|
||||||
|
if mat != null:
|
||||||
|
mat.set_shader_parameter("splat_map", _splat_tex)
|
||||||
|
|
||||||
|
|
||||||
|
## Live-Pfad beim Malen (t_7a0a6b79): NUR die betroffene Region der Splat-
|
||||||
|
## Textur neu ableiten (dieselben Region-Grenzen wie apply_material_brush,
|
||||||
|
## Zellrechteck inkl. Spiegelzellen; hier in Texel umgerechnet). Die Gewichte
|
||||||
|
## brauchen den Kernel-Umfeld-Zugriff auf die GESAMTE Schicht (_mats_cache).
|
||||||
|
func _refresh_splat_region(cell_rect: Rect2i) -> void:
|
||||||
|
if _splat_image == null or _splat_res < 2:
|
||||||
|
return
|
||||||
|
var last := float(_splat_res - 1)
|
||||||
|
var tpc := float(_splat_side) / last # Texel je Zelle
|
||||||
|
var tx0 := clampi(int(floorf(float(cell_rect.position.x) * tpc)) - 1, 0, _splat_side - 1)
|
||||||
|
var ty0 := clampi(int(floorf(float(cell_rect.position.y) * tpc)) - 1, 0, _splat_side - 1)
|
||||||
|
var tx1 := clampi(int(ceilf(float(cell_rect.end.x) * tpc)) + 1, tx0 + 1, _splat_side)
|
||||||
|
var ty1 := clampi(int(ceilf(float(cell_rect.end.y) * tpc)) + 1, ty0 + 1, _splat_side)
|
||||||
|
var rect := Rect2i(tx0, ty0, tx1 - tx0, ty1 - ty0)
|
||||||
|
var bytes := TerrainArrays.build_splat_bytes(_mats_cache, _splat_res, _splat_side, rect)
|
||||||
|
if bytes.size() != rect.size.x * rect.size.y * 4:
|
||||||
|
return
|
||||||
|
var sub := Image.create_from_data(rect.size.x, rect.size.y, false, Image.FORMAT_RGBA8, bytes)
|
||||||
|
_splat_image.blit_rect(sub, Rect2i(0, 0, rect.size.x, rect.size.y), rect.position)
|
||||||
|
_splat_tex.update(_splat_image)
|
||||||
|
|
|
||||||
1
tests/probe_arrays_load.gd.uid
Normal file
1
tests/probe_arrays_load.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bi0m3dxhvagy
|
||||||
|
|
@ -84,10 +84,13 @@ class ToolBuilder:
|
||||||
var last_heights := PackedFloat32Array()
|
var last_heights := PackedFloat32Array()
|
||||||
var last_mats := PackedByteArray()
|
var last_mats := PackedByteArray()
|
||||||
var calls := 0
|
var calls := 0
|
||||||
|
var last_splat_rect := Rect2i() # t_7a0a6b79: Live-Splat-Sub-Rect
|
||||||
|
|
||||||
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray()) -> void:
|
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray(),
|
||||||
|
splat_rect: Rect2i = Rect2i()) -> void:
|
||||||
last_heights = heights
|
last_heights = heights
|
||||||
last_mats = mats
|
last_mats = mats
|
||||||
|
last_splat_rect = splat_rect
|
||||||
calls += 1
|
calls += 1
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -456,6 +459,17 @@ func _test_live_rebuild_inputs() -> void:
|
||||||
var paint_mats: PackedByteArray = builder.get("last_mats")
|
var paint_mats: PackedByteArray = builder.get("last_mats")
|
||||||
_check(_count_asym_mats(paint_mats, SymmetryCore.Mode.POINT) == 0,
|
_check(_count_asym_mats(paint_mats, SymmetryCore.Mode.POINT) == 0,
|
||||||
"Live-Mal-Frame: Mesh-Input-Material bereits gespiegelt")
|
"Live-Mal-Frame: Mesh-Input-Material bereits gespiegelt")
|
||||||
|
# Live-Splat-Sub-Rect (t_7a0a6b79): das Rechteck muss Mal-Zelle UND die
|
||||||
|
# gespiegelte Schwesterzelle abdecken (der Sub-Rect-Refresh leitet genau
|
||||||
|
# diese Region neu ab — sonst bliebe die Spiegelseite optisch alt).
|
||||||
|
var splat_rect: Rect2i = builder.get("last_splat_rect")
|
||||||
|
var painted_cell := _world_to_cell(Vector2(-100.0, -60.0))
|
||||||
|
var mirrored_cell := SymmetryCore.mirror_cell(
|
||||||
|
SymmetryCore.Mode.POINT, painted_cell.x, painted_cell.y, RES)
|
||||||
|
_check(splat_rect.size.x > 0 and splat_rect.has_point(Vector2i(painted_cell.x, painted_cell.y))
|
||||||
|
and splat_rect.has_point(Vector2i(mirrored_cell.x, mirrored_cell.y)),
|
||||||
|
"Live-Mal-Frame: Splat-Sub-Rect deckt Mal-Zelle (%d,%d) + Spiegelzelle (%d,%d)" % [
|
||||||
|
painted_cell.x, painted_cell.y, mirrored_cell.x, mirrored_cell.y])
|
||||||
ttool.call("_end_stroke")
|
ttool.call("_end_stroke")
|
||||||
_check(int(tmain.get("commits")) == 2,
|
_check(int(tmain.get("commits")) == 2,
|
||||||
"Zwei Striche → zwei Commits (Undo-Vertrag bleibt)")
|
"Zwei Striche → zwei Commits (Undo-Vertrag bleibt)")
|
||||||
|
|
|
||||||
169
tests/test_terrain_splat.gd
Normal file
169
tests/test_terrain_splat.gd
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
extends SceneTree
|
||||||
|
## Splat-Gewichts-Ableitung (Karte t_7a0a6b79 — weiche Übergänge).
|
||||||
|
## Diese Datei ist SPIEGEL-IDENTISCH in Client UND Editor
|
||||||
|
## (client/tests/test_terrain_splat.gd ↔ editor/tests/test_terrain_splat.gd):
|
||||||
|
## beide Suiten rechnen dieselben Referenzwerte gegen dieselbe
|
||||||
|
## TerrainArrays.build_splat_bytes-Formel — grün in beiden Repos beweist die
|
||||||
|
## identische Formel beider Seiten (Spiegel-Datei-Vertrag, wie terrain_arrays.gd).
|
||||||
|
## Ausführen: godot --headless --path . -s res://tests/test_terrain_splat.gd
|
||||||
|
## Exit 0 = alles grün.
|
||||||
|
##
|
||||||
|
## Referenz-Fall (exakt rechenbar, Zweierpotenz-Koordinaten):
|
||||||
|
## 4×4 Zellen, alles ID 2 außer Zelle (2,2) = 7; side = 8.
|
||||||
|
## Texel (6,6): t = 6.5/8·3 = 2.4375 (exakt) → fx = fz = 0.4375 (exakt)
|
||||||
|
## → w(id2) = 0.80859375, w(id7) = 0.19140625 → Bytes [2, 206, 7, 49].
|
||||||
|
|
||||||
|
var failures := 0
|
||||||
|
var checks := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run()
|
||||||
|
if failures == 0:
|
||||||
|
print("ALL OK: %d checks" % checks)
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("FAILED: %d/%d checks" % [failures, checks])
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(cond: bool, label: String) -> void:
|
||||||
|
checks += 1
|
||||||
|
if cond:
|
||||||
|
print(" ok: %s" % label)
|
||||||
|
else:
|
||||||
|
failures += 1
|
||||||
|
print(" FAIL: %s" % label)
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
_test_side_for()
|
||||||
|
_test_uniform_region()
|
||||||
|
_test_reference_texel()
|
||||||
|
_test_boundary_ramp()
|
||||||
|
_test_sub_rect_equality()
|
||||||
|
_test_three_meeting_cells()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_side_for() -> void:
|
||||||
|
print("Testgruppe: splat_side_for (Texel-Dichte, Zweierpotenz, geklemmt)")
|
||||||
|
_check(TerrainArrays.splat_side_for(2) == 256, "res 2 → 256 (Minimum)")
|
||||||
|
_check(TerrainArrays.splat_side_for(33) == 256, "res 33 → 256 (Ziel 128 < 256)")
|
||||||
|
_check(TerrainArrays.splat_side_for(65) == 256, "res 65 → 256 (Ziel 256)")
|
||||||
|
_check(TerrainArrays.splat_side_for(129) == 512, "res 129 → 512 (Ziel 512)")
|
||||||
|
_check(TerrainArrays.splat_side_for(257) == 1024, "res 257 → 1024 (Ziel 1024)")
|
||||||
|
_check(TerrainArrays.splat_side_for(513) == 1024, "res 513 → 1024 (Maximum-Klemme)")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_uniform_region() -> void:
|
||||||
|
print("Testgruppe: Einmaterial-Fläche → eine ID, w0 = 1")
|
||||||
|
var mats := PackedByteArray()
|
||||||
|
mats.resize(4 * 4)
|
||||||
|
mats.fill(2)
|
||||||
|
var bytes := TerrainArrays.build_splat_bytes(mats, 4, 8)
|
||||||
|
_check(bytes.size() == 8 * 8 * 4, "Bytes: 8² Texel × 4 Kanäle")
|
||||||
|
var t := TerrainArrays.decode_splat_texel(bytes, 0)
|
||||||
|
_check(t[0] == 2 and t[1] == 1.0 and t[2] == 2 and t[3] == 0.0,
|
||||||
|
"Eck-Texel: id0=2, w0=1, id1=2 (Platzhalter), w1=0")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_reference_texel() -> void:
|
||||||
|
print("Testgruppe: Referenz-Texel (exakt rechenbar, Spiegel-Vertrag)")
|
||||||
|
# VERTEX-Konvention: die Material-Schicht indiziert MESH-VERTEXE (res²,
|
||||||
|
# row-major wie die Heightmap). Texel (6,6), res 4, side 8:
|
||||||
|
# t = 6.5/8·3 = 2.4375 (exakt) → zwischen Vertex 2 und 3, fx = fz = 0.4375.
|
||||||
|
# Vertex (2,2) trägt ID 7 → w(7) = 0.5625² = 0.31640625 (exakt),
|
||||||
|
# w(2) = 0.68359375 → 8-bit: 174 + 81 = 255.
|
||||||
|
var mats := PackedByteArray()
|
||||||
|
mats.resize(4 * 4)
|
||||||
|
mats.fill(2)
|
||||||
|
mats[2 * 4 + 2] = 7
|
||||||
|
var bytes := TerrainArrays.build_splat_bytes(mats, 4, 8)
|
||||||
|
var t := TerrainArrays.decode_splat_texel(bytes, (6 * 8 + 6) * 4)
|
||||||
|
_check(t[0] == 2, "Referenz-Texel (6,6): id0 = 2 (got %d)" % t[0])
|
||||||
|
_check(t[1] > 0.67 and t[1] < 0.70, "Referenz-Texel (6,6): w0 ≈ 0.6836 (got %.4f)" % t[1])
|
||||||
|
_check(t[2] == 7, "Referenz-Texel (6,6): id1 = 7 (got %d)" % t[2])
|
||||||
|
_check(t[3] > 0.30 and t[3] < 0.33, "Referenz-Texel (6,6): w1 ≈ 0.3164 (got %.4f)" % t[3])
|
||||||
|
# 8-bit-Vertrag: w0 + w1 ≈ 1 (Bytes 174 + 81 = 255).
|
||||||
|
var raw := (bytes.decode_u8((6 * 8 + 6) * 4 + 1)
|
||||||
|
+ bytes.decode_u8((6 * 8 + 6) * 4 + 3))
|
||||||
|
_check(raw == 255, "Referenz-Texel (6,6): Gewichts-Bytes summieren auf 255 (got %d)" % raw)
|
||||||
|
|
||||||
|
|
||||||
|
func _test_boundary_ramp() -> void:
|
||||||
|
print("Testgruppe: Zellgrenze → weiche Rampe (keine Snap-Kante)")
|
||||||
|
# 5×5 VERTEXE: x < 3 → id 1, sonst id 3. Grenze zwischen Vertex 2 und 3 —
|
||||||
|
# die bilineare Rampe kreuzt 0.5 genau bei t = 2.5 (Mitte zwischen den
|
||||||
|
# Vertex-Positionen); Rampenbreite = ein Zell-Abstand (wie in der Spec).
|
||||||
|
var res := 5
|
||||||
|
var mats := PackedByteArray()
|
||||||
|
mats.resize(res * res)
|
||||||
|
for z in res:
|
||||||
|
for x in res:
|
||||||
|
mats[z * res + x] = 1 if x < 3 else 3
|
||||||
|
var side := 64
|
||||||
|
var bytes := TerrainArrays.build_splat_bytes(mats, res, side)
|
||||||
|
# Texel 39: t = 2.46875 → fx = 0.469 → w(id1) = 0.531; Texel 40: t = 2.53125
|
||||||
|
# → w(id1) = 0.469 — die dominante ID wechselt EXAKT an der Mitte.
|
||||||
|
var t39 := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + 39) * 4)
|
||||||
|
var t40 := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + 40) * 4)
|
||||||
|
_check(t39[0] == 1 and t39[2] == 3, "Grenz-Texel 39: IDs {1, 3} (got %d/%d)" % [t39[0], t39[2]])
|
||||||
|
_check(t40[0] == 3 and t40[2] == 1, "Grenz-Texel 40: IDs {3, 1} (gewechselt, got %d/%d)" % [t40[0], t40[2]])
|
||||||
|
_check(absf(t39[1] - 0.53125) < 0.004, "Grenz-Texel 39: w0 ≈ 0.531 (got %.4f)" % t39[1])
|
||||||
|
_check(absf(t40[1] - 0.53125) < 0.004, "Grenz-Texel 40: w0 ≈ 0.531 (got %.4f)" % t40[1])
|
||||||
|
_check(absf((t39[1] + t40[3]) - 1.0) < 0.01, "Grenz-Texel 39/40 spiegeln sich an t = 2.5")
|
||||||
|
# Weit weg von der Grenze: reine Seite (w0 = 1):
|
||||||
|
var t8 := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + 8) * 4)
|
||||||
|
_check(t8[0] == 1 and t8[1] > 0.999, "Flachbereich links: w0 = 1 (got %.4f)" % t8[1])
|
||||||
|
# Kein Snap: das gewichtete ID-Feld (w0·id0 + w1·id1) steigt über die
|
||||||
|
# Grenze monoton und ohne Ein-Texel-Sprung (> 0.3 wäre ein Snap-Muster).
|
||||||
|
var prev := -1.0
|
||||||
|
var ok_ramp := true
|
||||||
|
for tx in range(34, 46):
|
||||||
|
var t := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + tx) * 4)
|
||||||
|
var blended: float = float(t[0]) * t[1] + float(t[2]) * t[3]
|
||||||
|
if prev >= 0.0 and (blended < prev - 0.0001 or blended - prev > 0.3):
|
||||||
|
ok_ramp = false
|
||||||
|
prev = blended
|
||||||
|
_check(ok_ramp, "Rampe über die Grenze monoton, ohne Ein-Texel-Sprung")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_sub_rect_equality() -> void:
|
||||||
|
print("Testgruppe: Sub-Rect-Ableitung == Voll-Ableitung (Live-Pfad-Vertrag)")
|
||||||
|
var res := 8
|
||||||
|
var mats := PackedByteArray()
|
||||||
|
mats.resize(res * res)
|
||||||
|
for i in mats.size():
|
||||||
|
mats[i] = (i * 7 + 3) % 5 # deterministisches Muster mit 5 IDs
|
||||||
|
var side := 32
|
||||||
|
var full := TerrainArrays.build_splat_bytes(mats, res, side)
|
||||||
|
var same := true
|
||||||
|
for rect: Rect2i in [Rect2i(0, 0, 16, 8), Rect2i(8, 8, 12, 20), Rect2i(24, 0, 8, 32), Rect2i(5, 3, 7, 9)]:
|
||||||
|
var part := TerrainArrays.build_splat_bytes(mats, res, side, rect)
|
||||||
|
if part.size() != rect.size.x * rect.size.y * 4:
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
for j in rect.size.y:
|
||||||
|
for i in rect.size.x:
|
||||||
|
var fofs := ((rect.position.y + j) * side + rect.position.x + i) * 4
|
||||||
|
for c in 4:
|
||||||
|
if part[j * rect.size.x * 4 + i * 4 + c] != full[fofs + c]:
|
||||||
|
same = false
|
||||||
|
_check(same, "Sub-Rect-Bytes byte-identisch zum Voll-Bild (4 Rechtecke)")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_three_meeting_cells() -> void:
|
||||||
|
print("Testgruppe: >2 IDs am Treffpunkt → Top-2-Gewichte (Spec-Vertrag)")
|
||||||
|
# 2×2-Zellen-Beispiel: Zelle (0,0)=1, (1,0)=2, (0,1)=3, (1,1)=4; res 2, side 8.
|
||||||
|
# Texel (3,3): t = 3.5/8·1 = 0.4375 → fx = fz = 0.4375.
|
||||||
|
# w: id1 0.3164, id2 0.2461, id3 0.2461, id4 0.1914 → Top-2 = {1, 2}
|
||||||
|
# (Gleichstand 2/3: kleinerer Index, deterministisch); id4 (0.1914) wird
|
||||||
|
# bewusst verworfen (Zweikant-Fälle). Normalisiert: 0.3164/0.5625 = 0.5625.
|
||||||
|
var mats := PackedByteArray([1, 2, 3, 4])
|
||||||
|
var bytes := TerrainArrays.build_splat_bytes(mats, 2, 8)
|
||||||
|
var t := TerrainArrays.decode_splat_texel(bytes, (3 * 8 + 3) * 4)
|
||||||
|
_check(t[0] == 1 and t[1] > 0.55 and t[1] < 0.58,
|
||||||
|
"Treffpunkt: id0=1, w0 ≈ 0.5625 (normalisiert, got %.4f)" % t[1])
|
||||||
|
_check(t[2] == 2, "Treffpunkt: id1 = 2 (zweitgrößter, Gleichstand → kleinerer Index, got %d)" % t[2])
|
||||||
|
_check(t[3] > 0.42 and t[3] < 0.45, "Treffpunkt: w1 ≈ 0.4375 (got %.4f)" % t[3])
|
||||||
|
_check(t[1] + t[3] > 0.99, "Treffpunkt: Top-2-Gewichte normalisiert (Summe %.4f)" % (t[1] + t[3]))
|
||||||
Loading…
Reference in a new issue