scopedvr-map-editor/tests/test_terrain_mirror.gd
slyphax 7f06e28472 Malen mit Symmetrie: nur wirklich bemalte Punkte spiegeln (harte gerade Kanten weg)
Sly 25.10.: „teilweise sehr harte Kanten in den Übergängen“. Ursache: bei
aktiver Symmetrie kopierte jeder Mal-Schritt das GANZE Snapshot-Quadrat um
den Pinsel auf die Gegenseite — dort vorhandene weiche Verläufe wurden an
den Quadratkanten gerade abgeschnitten. Jetzt werden nur Punkte gespiegelt,
die der Schritt verändert hat; überlappen beide Seiten, gewinnt wie bisher
der näher am Strichpunkt liegende Wert.
Test: test_terrain_mirror 109/109 (neuer Fall schlägt mit altem Code fehl).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 00:50:09 +02:00

551 lines
22 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 SceneTree
## Editor-Tests (headless): M2 Terrain spiegeln (Karte t_f9eff526, Spec
## /opt/data/scopedvr/KARTEN_MIRROR_vc27.md) — im Symmetrie-Modus schreibt
## jeder Terrain-Strich (Heben/Senken/Glätten/Malen) seine Zellen EXAKT
## gespiegelt in die andere Kartenseite.
## Ausführen: godot --headless --path . --script res://tests/test_terrain_mirror.gd
## Exit 0 = alles grün.
##
## Geprüft wird (Spec-Abnahme):
## 1. Strich in einer Hälfte → beide Zellhälften identisch (Höhe UND Material)
## für alle drei Modi (Punkt / Achse X / Achse Z) und alle vier Pinsel.
## 2. Strich genau auf der Rastermitte bzw. auf der Spiegelachse dupliziert
## NICHT — das Ergebnis ist byte-identisch zu EINER Pinsel-Anwendung
## (Referenz über TerrainField direkt, Gegenprobe gegen den Primitive).
## 3. Strich am Rand: Ränder bleiben exakt gespiegelt, kein Absturz.
## 4. Modus AUS = Verhalten wie vorher: byte-identisches Ergebnis zur
## direkten TerrainField-Anwendung (Gegenprobe; zusätzlich wird der
## gleiche Probe-Strich gegen einen origin/main-Checkout gefahren —
## siehe /opt/data/scopedvr/work/m2_beweise/).
## 5. Live-Rebuild-Pfad: die per _process an update_terrain gegebenen
## Mesh-Inputs (Höhen UND Material-Schicht) sind bereits gespiegelt.
## 6. Ein Strich = EIN Undo-Schritt, Undo stellt BEIDE Hälften zurück.
## 7. Zellgleichheit auch wenn der Pinsel-Fußabdruck die Mitte ÜBERLAPPT
## (Strich-nahe Seite gewinnt, deterministisch).
const RES := 33
const SIZE := 320.0
const HMIN := 0.0
const HMAX := 60.0
const STRENGTH := 8.0 # terrain_tool-Default (m/s Heben/Senken)
const STROKE_SEC := 0.5 # Strichlänge der Proben
const RADIUS := 30.0
var failures := 0
var checks := 0
func _init() -> void:
_run_tests()
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_tests() -> void:
_test_half_stroke_all_modes()
_test_center_stroke_no_duplicate()
_test_axis_stroke_no_duplicate()
_test_edge_stroke()
_test_overlap_stroke_near_center()
_test_off_mode_unchanged()
_test_live_rebuild_inputs()
_test_undo_one_step()
_test_mirror_nur_bemalte()
# ------------------------------------------------------------- Hilfsbau -----
class ToolMain:
var doc: Dictionary = {}
var sym_mode: int = SymmetryCore.Mode.OFF
var history: ActionHistory = ActionHistory.new()
var commits := 0
func _set_status(_text: String, _err := false) -> void:
pass
func _on_terrain_stroke_committed() -> void:
commits += 1
class ToolBuilder:
var last_heights := PackedFloat32Array()
var last_mats := PackedByteArray()
var calls := 0
var last_splat_rect := Rect2i() # t_7a0a6b79: Live-Splat-Sub-Rect
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray(),
splat_rect: Rect2i = Rect2i()) -> void:
last_heights = heights
last_mats = mats
last_splat_rect = splat_rect
calls += 1
## Kleine Test-Map (33² über 320 m, flach, alles Gras) — schnell genug für
## Voll-Raster-Vergleiche, mit Rastermitte (ungerade res → Selbstabbildung).
func _make_doc() -> Dictionary:
var doc := SvmapDocument.create_new("Mirror", SIZE, "mittag")
var terrain: Dictionary = doc["terrain"]
terrain["resolution"] = RES
terrain["size_x_m"] = SIZE
terrain["size_z_m"] = SIZE
terrain["min_height_m"] = HMIN
terrain["max_height_m"] = HMAX
var flat := PackedFloat32Array()
flat.resize(RES * RES)
terrain["heightmap_r16_b64"] = TerrainField.encode(flat, HMIN, HMAX)
var mats := PackedByteArray()
mats.resize(RES * RES)
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
terrain.erase("material_resolution") # Test-Raster: Material = Höhenraster (Alt-Karte)
return doc
func _make_tool(sym: int) -> Array:
var doc := _make_doc()
var tmain := ToolMain.new()
tmain.doc = doc
tmain.sym_mode = sym
tmain.history.begin(doc)
var builder := ToolBuilder.new()
var ttool := Node3D.new()
ttool.set_script(load("res://src/editor/terrain_tool.gd"))
ttool.setup(tmain, Camera3D.new(), builder, null)
var activated: bool = ttool.call("activate")
_check(activated, "terrain_tool aktiviert (Sym-Modus %d)" % sym)
ttool.call("set_radius", RADIUS)
return [ttool, tmain, builder, doc]
## Programmgesteuerter Strich über den ECHTEN Pfad (demo_stroke: begin →
## Schritte → end → Commit → Refresh — exakt wie ein Maus-Strich).
func _stroke(ttool, x: float, z: float, mode: int, seconds: float) -> void:
ttool.call("demo_stroke", x, z, mode, seconds)
func _decode_heights(doc: Dictionary) -> PackedFloat32Array:
return SvmapIO.decode_heights(doc["terrain"])
func _decode_mats(doc: Dictionary) -> PackedByteArray:
return SvmapIO.decode_materials(doc["terrain"])
## Anzahl Zellpaare, deren gespiegelte Schwester einen ANDEREN Wert trägt
## (0 = beide Hälften exakt zellgleich). Höhe.
func _count_asym_heights(heights: PackedFloat32Array, mode: int) -> int:
var bad := 0
for z: int in RES:
for x: int in RES:
var mc: Vector2i = SymmetryCore.mirror_cell(mode, x, z, RES)
if heights[z * RES + x] != heights[mc.y * RES + mc.x]:
bad += 1
return bad
## Anzahl Zellpaare, deren gespiegelte Schwester ein ANDERES Material trägt.
func _count_asym_mats(mats: PackedByteArray, mode: int) -> int:
var bad := 0
for z: int in RES:
for x: int in RES:
var mc: Vector2i = SymmetryCore.mirror_cell(mode, x, z, RES)
if int(mats[z * RES + x]) != int(mats[mc.y * RES + mc.x]):
bad += 1
return bad
## Referenz: N Einzelschritte DIREKT über TerrainField (der Primitive vor M2) —
## für die Gegenproben „Mitte dupliziert nicht" und „AUS wie vorher".
func _reference_heights(steps: int, x: float, z: float, mode: int) -> PackedFloat32Array:
var ref := PackedFloat32Array()
ref.resize(RES * RES)
var dt := STROKE_SEC / float(maxi(steps, 1))
var amount := STRENGTH * dt
for i in steps:
TerrainField.apply_brush(ref, RES, SIZE, SIZE, -100000.0, 100000.0,
x, z, RADIUS, mode, amount)
return ref
# --------------------------------------------------------------- Tests ------
## 1) Strich in einer Hälfte → beide Zellhälften exakt identisch (Höhe UND
## Material) für alle drei Symmetrie-Modi und alle vier Pinsel-Modi.
func _test_half_stroke_all_modes() -> void:
print("Testgruppe: Strich in einer Hälfte → Hälften zellgleich (3 Sym-Modi × 4 Pinsel)")
var sym_modes := [
["Punkt", SymmetryCore.Mode.POINT],
["Achse X", SymmetryCore.Mode.AXIS_X],
["Achse Z", SymmetryCore.Mode.AXIS_Z],
]
var brush_modes := [
["Heben", TerrainField.Mode.RAISE],
["Senken", TerrainField.Mode.LOWER],
["Malen", TerrainField.Mode.PAINT],
]
for entry0 in sym_modes:
var sym_name: String = entry0[0]
var sym: int = entry0[1]
for entry1 in brush_modes:
var brush_name: String = entry1[0]
var brush: int = entry1[1]
var parts := _make_tool(sym)
var ttool = parts[0]
var doc: Dictionary = parts[3]
if brush == TerrainField.Mode.PAINT:
ttool.call("set_paint_material", 1) # Sand statt Default-Gras
_stroke(ttool, -100.0, -60.0, brush, STROKE_SEC)
var heights := _decode_heights(doc)
var mats := _decode_mats(doc)
_check(_count_asym_heights(heights, sym) == 0,
"%s/%s: Höhen-Hälften exakt zellgleich" % [sym_name, brush_name])
_check(_count_asym_mats(mats, sym) == 0,
"%s/%s: Material-Hälften exakt zellgleich" % [sym_name, brush_name])
var moved := false
if brush == TerrainField.Mode.PAINT:
var pm := _decode_mats(doc)
for m in pm:
if int(m) != 0:
moved = true
break
else:
for v in heights:
if v != 0.0:
moved = true
break
_check(moved, "%s/%s: Strich hat tatsächlich gewirkt" % [sym_name, brush_name])
ttool.free()
# Malen (Sand) als eigener Material-Strich pro Sym-Modus:
var parts_p := _make_tool(sym)
var ttool_p = parts_p[0]
var doc_p: Dictionary = parts_p[3]
ttool_p.call("set_paint_material", 1) # Sand statt Default-Gras
ttool_p.set("brush_weichheit", 0.0) # harte Kante: Vergleich mit apply_material_brush
_stroke(ttool_p, -100.0, -60.0, TerrainField.Mode.PAINT, 0.3)
var mats_p := _decode_mats(doc_p)
var sand := 0
for m in mats_p:
if int(m) == 1:
sand += 1
_check(sand > 0, "%s/Malen: Sand gemalt (%d Zellen)" % [sym_name, sand])
_check(_count_asym_mats(mats_p, sym) == 0,
"%s/Malen: Material-Hälften exakt zellgleich (Sand)" % sym_name)
var painted_heights := _decode_heights(doc_p)
var flat := true
for v in painted_heights:
if v != 0.0:
flat = false
break
_check(flat, "%s/Malen: Höhe bleibt unangetastet" % sym_name)
ttool_p.free()
# Glätten braucht Form unter dem Pinsel — symmetrisch vorgeformter Hügel:
for entry in sym_modes:
var sym_name2: String = entry[0]
var sym2: int = entry[1]
var parts_s := _make_tool(sym2)
var ttool_s = parts_s[0]
var tmain_s = parts_s[1]
var doc_s: Dictionary = parts_s[3]
var terrain: Dictionary = doc_s["terrain"]
var hs := _decode_heights(doc_s)
var hill := Vector3(-70.0, 0.0, -40.0)
var center := SymmetryCore.map_center(doc_s)
var twin := SymmetryCore.mirror_point(sym2, hill, center)
TerrainField.apply_brush(hs, RES, SIZE, SIZE, -100000.0, 100000.0,
hill.x, hill.z, 25.0, TerrainField.Mode.RAISE, 10.0)
TerrainField.apply_brush(hs, RES, SIZE, SIZE, -100000.0, 100000.0,
twin.x, twin.z, 25.0, TerrainField.Mode.RAISE, 10.0)
terrain["heightmap_r16_b64"] = TerrainField.encode(hs, HMIN, HMAX)
ttool_s.call("refresh_from_doc")
var before := _decode_heights(doc_s)
_stroke(ttool_s, -52.0, -28.0, TerrainField.Mode.SMOOTH, STROKE_SEC)
var after := _decode_heights(doc_s)
var smoothed := false
for i in after.size():
if after[i] != before[i]:
smoothed = true
break
_check(smoothed, "%s/Glätten: Strich hat geglättet" % sym_name2)
_check(_count_asym_heights(after, sym2) == 0,
"%s/Glätten: Höhen-Hälften exakt zellgleich" % sym_name2)
ttool_s.free()
# Undo-/Commit-Verhalten testet _test_undo_one_step (Strich-Summe oben OK).
## 2) Strich GENAU auf der Rastermitte (Punkt) dupliziert NICHT: Ergebnis ist
## byte-identisch zu EINER Pinsel-Anwendung pro Schritt (Referenz).
func _test_center_stroke_no_duplicate() -> void:
print("Testgruppe: Strich genau auf der Mitte dupliziert nicht (Punkt)")
var parts := _make_tool(SymmetryCore.Mode.POINT)
var ttool = parts[0]
var doc: Dictionary = parts[3]
_stroke(ttool, 0.0, 0.0, TerrainField.Mode.RAISE, STROKE_SEC)
var steps := int(ceil(STROKE_SEC / 0.05))
var ref := _reference_heights(steps, 0.0, 0.0, TerrainField.Mode.RAISE)
var ref_b64 := TerrainField.encode(ref, HMIN, HMAX)
var got_b64 := str(doc["terrain"]["heightmap_r16_b64"])
_check(got_b64 == ref_b64,
"Mitte-Strich (Punkt): byte-identisch zu EINER Anwendung (kein Doppel-Heben)")
var heights := _decode_heights(doc)
_check(_count_asym_heights(heights, SymmetryCore.Mode.POINT) == 0,
"Mitte-Strich (Punkt): Hälften exakt zellgleich")
var center_h := heights[(RES / 2) * RES + (RES / 2)]
_check(absf(center_h - (STRENGTH * STROKE_SEC)) < 0.05,
"Mitte-Zelle exakt EINmal gehoben (%.3f m statt %.3f m)" % [
center_h, 2.0 * STRENGTH * STROKE_SEC])
ttool.free()
## 2b) Strich GENAU auf der Spiegelachse dupliziert nicht (Achse X und Z).
func _test_axis_stroke_no_duplicate() -> void:
print("Testgruppe: Strich auf der Spiegelachse dupliziert nicht (Achse X/Z)")
var cases := [
["Achse X", SymmetryCore.Mode.AXIS_X, Vector2(0.0, -80.0)],
["Achse Z", SymmetryCore.Mode.AXIS_Z, Vector2(80.0, 0.0)],
]
for case in cases:
var sym_name: String = case[0]
var sym: int = case[1]
var at: Vector2 = case[2]
var parts := _make_tool(sym)
var ttool = parts[0]
var doc: Dictionary = parts[3]
_stroke(ttool, at.x, at.y, TerrainField.Mode.RAISE, STROKE_SEC)
var steps := int(ceil(STROKE_SEC / 0.05))
var ref := _reference_heights(steps, at.x, at.y, TerrainField.Mode.RAISE)
var ref_b64 := TerrainField.encode(ref, HMIN, HMAX)
_check(str(doc["terrain"]["heightmap_r16_b64"]) == ref_b64,
"%s: Strich auf der Achse byte-identisch zu EINER Anwendung" % sym_name)
var heights := _decode_heights(doc)
_check(_count_asym_heights(heights, sym) == 0,
"%s: Hälften exakt zellgleich" % sym_name)
ttool.free()
## 3) Strich am Rand: gespiegelte Randzellen exakt, gegenüberliegende Ecke
## bekommt den Zwilling, kein Absturz, kein Duplikat-Effekt.
func _test_edge_stroke() -> void:
print("Testgruppe: Strich am Rand (Ecke, Punkt-Modus)")
var parts := _make_tool(SymmetryCore.Mode.POINT)
var ttool = parts[0]
var doc: Dictionary = parts[3]
_stroke(ttool, 150.0, 150.0, TerrainField.Mode.RAISE, STROKE_SEC)
var heights := _decode_heights(doc)
_check(_count_asym_heights(heights, SymmetryCore.Mode.POINT) == 0,
"Rand-Strich: beide Hälften exakt zellgleich (auch Randzellen)")
var corner := heights[(RES - 1) * RES + (RES - 1)] # +X/+Z-Ecke
var opposite := heights[0] # −X/−Z-Ecke (Zwilling)
_check(corner > 0.5 and absf(corner - opposite) < 0.0001,
"Rand: Ecke %.3f m, Zwilling-Ecke %.3f m (identisch)" % [corner, opposite])
_check(absf(heights[(RES / 2) * RES + (RES / 2)]) < 0.0001,
"Rand: Rastermitte unberührt (Fußabdruck reicht nicht hin)")
ttool.free()
## 7) Fußabdruck ÜBERLAPPT die Mitte (Strich nahe dran): die strichnahe Seite
## gewinnt, das Ergebnis bleibt exakt zellgleich — auch am Selbstabbild-
## Punkt der ungeraden Rastermitte.
func _test_overlap_stroke_near_center() -> void:
print("Testgruppe: Überlapp-Strich nahe der Mitte (alle 3 Modi)")
var cases := [
["Punkt", SymmetryCore.Mode.POINT, Vector2(12.0, 12.0)],
["Achse X", SymmetryCore.Mode.AXIS_X, Vector2(12.0, -90.0)],
["Achse Z", SymmetryCore.Mode.AXIS_Z, Vector2(-90.0, 12.0)],
]
for case in cases:
var sym_name: String = case[0]
var sym: int = case[1]
var at: Vector2 = case[2]
var parts := _make_tool(sym)
var ttool = parts[0]
var doc: Dictionary = parts[3]
_stroke(ttool, at.x, at.y, TerrainField.Mode.RAISE, STROKE_SEC)
var heights := _decode_heights(doc)
_check(_count_asym_heights(heights, sym) == 0,
"%s: Überlapp-Fußabdruck — Hälften exakt zellgleich" % sym_name)
# Strich-Seite ist Master: die Zelle unter dem Cursor ist sichtbar
# gehoben UND trägt denselben Wert wie ihr Zwilling.
var under := _world_to_cell(at)
var mc: Vector2i = SymmetryCore.mirror_cell(sym, under.x, under.y, RES)
_check(heights[under.y * RES + under.x] > 0.5
and heights[under.y * RES + under.x] == heights[mc.y * RES + mc.x],
"%s: Strich-Zelle und Zwilling identisch (%.3f m)" % [
sym_name, heights[under.y * RES + under.x]])
ttool.free()
## 4) Modus AUS = Verhalten wie vorher: byte-identisch zur direkten
## TerrainField-Anwendung (Gegenprobe), NICHTS wird gespiegelt.
func _test_off_mode_unchanged() -> void:
print("Testgruppe: Modus AUS = Verhalten wie vorher (Gegenprobe)")
# Höhen-Strich:
var parts := _make_tool(SymmetryCore.Mode.OFF)
var ttool = parts[0]
var doc: Dictionary = parts[3]
_stroke(ttool, -100.0, -60.0, TerrainField.Mode.RAISE, STROKE_SEC)
var steps := int(ceil(STROKE_SEC / 0.05))
var ref := _reference_heights(steps, -100.0, -60.0, TerrainField.Mode.RAISE)
var ref_b64 := TerrainField.encode(ref, HMIN, HMAX)
_check(str(doc["terrain"]["heightmap_r16_b64"]) == ref_b64,
"AUS/Heben: byte-identisch zur direkten TerrainField-Anwendung")
var heights := _decode_heights(doc)
var twin := SymmetryCore.mirror_point(SymmetryCore.Mode.POINT,
Vector3(-100.0, 0.0, -60.0), Vector2.ZERO)
var twin_h := TerrainField.height_at(heights, RES, SIZE, SIZE, twin.x, twin.z)
_check(absf(twin_h) < 0.0001,
"AUS/Heben: Zwilling-Position bleibt FLACH (nichts gespiegelt)")
ttool.free()
# Malen-Strich:
var parts_p := _make_tool(SymmetryCore.Mode.OFF)
var ttool_p = parts_p[0]
var doc_p: Dictionary = parts_p[3]
ttool_p.call("set_paint_material", 1) # Sand statt Default-Gras
ttool_p.set("brush_weichheit", 0.0) # harte Kante: Vergleich mit apply_material_brush
_stroke(ttool_p, -100.0, -60.0, TerrainField.Mode.PAINT, 0.3)
var ref_mats := PackedByteArray()
ref_mats.resize(RES * RES)
for i in int(ceil(0.3 / 0.05)):
TerrainField.apply_material_brush(ref_mats, RES, SIZE, SIZE,
-100.0, -60.0, RADIUS, 1)
var ref_mats_b64 := SvmapIO.encode_materials(ref_mats)
_check(str(doc_p["terrain"]["materials_r8_b64"]) == ref_mats_b64,
"AUS/Malen: Material-Schicht byte-identisch zur direkten Anwendung")
var got_mats := _decode_mats(doc_p)
var twin_cell := _world_to_cell(Vector2(100.0, 60.0))
_check(int(got_mats[twin_cell.y * RES + twin_cell.x]) == 0,
"AUS/Malen: Zwilling-Position bleibt Gras (nichts gespiegelt)")
ttool_p.free()
## 5) Live-Rebuild-Pfad: was _process pro Frame an update_terrain gibt
## (Höhen UND Material-Schicht), ist bereits gespiegelt — der Live-Mesh-
## Input ist identisch zum Vollrebuild (Falle t_55ed041a bleibt zu).
func _test_live_rebuild_inputs() -> void:
print("Testgruppe: Live-Pfad — Mesh-Inputs pro Frame gespiegelt + Schicht dabei")
var parts := _make_tool(SymmetryCore.Mode.POINT)
var ttool = parts[0]
var tmain = parts[1]
var builder = parts[2]
ttool.set("_cursor_valid", true)
ttool.set("_cursor", Vector3(-100.0, 0.0, -60.0))
ttool.call("_begin_stroke", TerrainField.Mode.RAISE)
for frame in 3:
ttool.call("_process", 0.016)
var live: PackedFloat32Array = builder.get("last_heights")
var live_mats: PackedByteArray = builder.get("last_mats")
_check(live.size() == RES * RES,
"Live-Frame %d: Höhen-Feld komplett (33²)" % (frame + 1))
_check(_count_asym_heights(live, SymmetryCore.Mode.POINT) == 0,
"Live-Frame %d: Mesh-Input-Höhen bereits gespiegelt" % (frame + 1))
_check(live_mats.size() == RES * RES,
"Live-Frame %d: Material-Schicht MITGEBEN (t_55ed041a-Vertrag)" % (frame + 1))
# Malen live: auch der Mal-Frame gibt Höhe + (gespiegelte) Schicht weiter.
ttool.call("_end_stroke")
ttool.call("set_paint_material", 1) # Sand — Gras über Gras wäre ein No-op
ttool.set("_cursor", Vector3(-100.0, 0.0, -60.0))
ttool.call("_begin_stroke", TerrainField.Mode.PAINT)
ttool.call("_process", 0.016)
var paint_mats: PackedByteArray = builder.get("last_mats")
_check(_count_asym_mats(paint_mats, SymmetryCore.Mode.POINT) == 0,
"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")
_check(int(tmain.get("commits")) == 2,
"Zwei Striche → zwei Commits (Undo-Vertrag bleibt)")
ttool.free()
# builder/tmain sind RefCounted (wie in test_terrain.gd) — kein free().
## 6) Ein gespiegelter Strich = EIN Undo-Schritt; Undo nimmt BEIDE Hälften
## zurück, Redo bringt sie byte-identisch zurück.
func _test_undo_one_step() -> void:
print("Testgruppe: Gespiegelter Strich = EIN Undo-Schritt (beide Hälften)")
var parts := _make_tool(SymmetryCore.Mode.POINT)
var ttool = parts[0]
var tmain = parts[1]
var doc: Dictionary = parts[3]
_stroke(ttool, -100.0, -60.0, TerrainField.Mode.RAISE, STROKE_SEC)
_check(int(tmain.get("commits")) == 1, "Ein Strich → EIN Commit")
var hist: ActionHistory = tmain.get("history")
_check(hist.entries.size() == 1, "Ein Strich → EIN History-Eintrag (beide Hälften)")
var raised_b64 := str(doc["terrain"]["heightmap_r16_b64"])
var desc := hist.undo()
_check(desc.contains("Boden geformt"), "Undo-Desc nennt den Strich (%s)" % desc)
var restored := _decode_heights(doc)
var flat := true
for v in restored:
if v != 0.0:
flat = false
break
_check(flat, "Undo: BEIDE Hälften wieder flach (ganzer gespiegelter Strich)")
var redo_desc := hist.redo()
_check(redo_desc.contains("Boden geformt"), "Redo-Desc stimmt")
_check(str(doc["terrain"]["heightmap_r16_b64"]) == raised_b64,
"Redo: byte-identisches gespiegeltes Höhenfeld zurück")
ttool.free()
## Welt-X/Z → Rasterzelle (gleiche Konvention wie terrain_tool._world_to_grid).
func _world_to_cell(at: Vector2) -> Vector2i:
var fx: int = clampi(int(roundf((at.x / SIZE + 0.5) * float(RES - 1))), 0, RES - 1)
var fz: int = clampi(int(roundf((at.y / SIZE + 0.5) * float(RES - 1))), 0, RES - 1)
return Vector2i(fx, fz)
## Karte 25.10.: Mit Symmetrie wird NUR gespiegelt, was der Pinsel wirklich
## bemalt hat — die Gegenseite außerhalb des Spiegelkreises bleibt unberührt
## (vorher: ganzes Snapshot-Quadrat kopiert → harte gerade Kanten).
func _test_mirror_nur_bemalte() -> void:
print("Testgruppe: Spiegeln nur bemalter Punkte (Quadrat-Kanten-Bug)")
var parts := _make_tool(SymmetryCore.Mode.POINT)
var ttool = parts[0]
var doc: Dictionary = parts[3]
# Gegenseite vorbereiten: Fels-Fleck dort, wo das Spiegel-QUADRAT, aber
# nicht der Spiegel-KREIS des Strichs liegt (Ecke des Quadrats).
var t: Dictionary = doc["terrain"]
var mats := SvmapIO.decode_materials(t)
var cell := SIZE / float(RES - 1)
var ecke := Vector2(100.0 + RADIUS * 0.9, 60.0 + RADIUS * 0.9) # Spiegelpunkt der Quadratecke
var gx := int(roundf((ecke.x / SIZE + 0.5) * float(RES - 1)))
var gz := int(roundf((ecke.y / SIZE + 0.5) * float(RES - 1)))
mats[gz * RES + gx] = 2
t["materials_r8_b64"] = SvmapIO.encode_materials(mats)
ttool.call("refresh_from_doc")
ttool.call("set_paint_material", 1)
ttool.set("brush_weichheit", 0.8)
_stroke(ttool, -100.0, -60.0, TerrainField.Mode.PAINT, 0.2)
var after := SvmapIO.decode_materials(doc["terrain"])
_check(int(after[gz * RES + gx]) == 2,
"Fels-Fleck auf der Gegenseite (Quadratecke, außerhalb des Kreises) bleibt (Zelle %d,%d)" % [gx, gz])
var mm := SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT,
int(roundf((-100.0 / SIZE + 0.5) * float(RES - 1))),
int(roundf((-60.0 / SIZE + 0.5) * float(RES - 1))), RES)
var mix := SvmapIO.decode_mix(doc["terrain"])
var share := func(i: int) -> float:
var w := float(mix[1][i]) / 255.0
return (1.0 - w if after[i] == 1 else 0.0) + (w if mix[0][i] == 1 else 0.0)
_check(share.call(mm.y * RES + mm.x) > 0.99, "Strichmitte auf der Gegenseite gespiegelt (Sand)")