From 2c108637595117ec98e2f37b22a5910c21637aea Mon Sep 17 00:00:00 2001 From: "Hermes (scopedvr-dev)" Date: Tue, 20 Oct 2026 06:49:06 +0000 Subject: [PATCH] =?UTF-8?q?M2=20(t=5Ff9eff526):=20Terrain=20spiegeln=20?= =?UTF-8?q?=E2=80=94=20H=C3=B6hen=20UND=20Boden-Material=20in=20allen=20Li?= =?UTF-8?q?ve-Pfaden=20(Spec=20vc27).=20terrain=5Ftool:=20jeder=20Pinsel-S?= =?UTF-8?q?chritt=20(Heben/Senken/Gl=C3=A4tten/Malen)=20wendet=20den=20Pin?= =?UTF-8?q?sel=20EINMAL=20an=20und=20kopiert=20die=20ge=C3=A4nderten=20Zel?= =?UTF-8?q?len=20(Snapshot-Vergleich)=20exakt=20per=20SymmetryCore.mirror?= =?UTF-8?q?=5Fcell=20in=20die=20andere=20H=C3=A4lfte=20=E2=80=94=20Strich-?= =?UTF-8?q?Seite=20ist=20Master,=20=C3=9Cberlappung=20gewinnt=20strichn?= =?UTF-8?q?=C3=A4her,=20Mitte/Achse=20wird=20nicht=20gedoppelt.=20demo=5Fs?= =?UTF-8?q?troke=20l=C3=A4uft=20=C3=BCber=20dieselben=20Schritte.=20Ohne?= =?UTF-8?q?=20sym=5Fmode-Feld/Modus=20AUS:=20exakt=20altes=20Verhalten=20(?= =?UTF-8?q?byte-identisch=20zu=20origin/main,=20SHA256-Gegenprobe).=20terr?= =?UTF-8?q?ain=5Fmesh=20unver=C3=A4ndert=20(Live-Pfade=20tragen=20H=C3=B6h?= =?UTF-8?q?e+Schicht=20bereits,=20Audit=20im=20Handoff).=20Neu:=20tests/te?= =?UTF-8?q?st=5Fterrain=5Fmirror.gd=20(105=20checks,=20alle=203=20Modi=20?= =?UTF-8?q?=C3=97=204=20Pinsel=20+=20Mitte/Achse/Rand/=C3=9Cberlapp=20+=20?= =?UTF-8?q?Gegenprobe)=20und=20tests/probe=5Fterrain=5Fmirror=5Frender.gd?= =?UTF-8?q?=20(Beweis-Bild=20Draufsicht).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/terrain_tool.gd | 195 ++++++++- tests/probe_terrain_mirror_render.gd | 148 +++++++ tests/probe_terrain_mirror_render.gd.uid | 1 + tests/test_terrain_mirror.gd | 499 +++++++++++++++++++++++ tests/test_terrain_mirror.gd.uid | 1 + 5 files changed, 834 insertions(+), 10 deletions(-) create mode 100644 tests/probe_terrain_mirror_render.gd create mode 100644 tests/probe_terrain_mirror_render.gd.uid create mode 100644 tests/test_terrain_mirror.gd create mode 100644 tests/test_terrain_mirror.gd.uid diff --git a/src/editor/terrain_tool.gd b/src/editor/terrain_tool.gd index 142c110..3f1a8a0 100644 --- a/src/editor/terrain_tool.gd +++ b/src/editor/terrain_tool.gd @@ -237,8 +237,7 @@ func _process(delta: float) -> void: if not _stroking or not _cursor_valid: return if _stroke_mode == TerrainField.Mode.PAINT: - var painted := TerrainField.apply_material_brush(_mats, _res, _sx, _sz, - _cursor.x, _cursor.z, brush_radius, _stroke_material) + var painted := _paint_step(_cursor.x, _cursor.z) if painted: view_builder.call("update_terrain", _heights, _mats) _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 @@ -250,8 +249,7 @@ func _process(delta: float) -> void: amount = brush_strength * delta # Während des Strichs NICHT auf die Format-Range clampen — die zieht der # Range-Expander danach nach (sonst friert Heben/Senken an der Kante ein). - var changed := TerrainField.apply_brush(_heights, _res, _sx, _sz, -100000.0, 100000.0, - _cursor.x, _cursor.z, brush_radius, _stroke_mode, amount) + var changed := _height_step(_cursor.x, _cursor.z, amount) if changed: _expand_range_if_needed() # Karte t_55ed041a: Material-Schicht MITGEBEN — ohne sie hat der @@ -313,17 +311,15 @@ func demo_stroke(x: float, z: float, mode: int, seconds: float) -> void: var save_x := _cursor.x var save_z := _cursor.z for i in steps: - if mode == TerrainField.Mode.PAINT: - TerrainField.apply_material_brush(_mats, _res, _sx, _sz, - save_x, save_z, brush_radius, _stroke_material) - continue var amount: float if mode == TerrainField.Mode.SMOOTH: amount = clampf(brush_strength / SMOOTH_RATE_DIVISOR * dt, 0.0, 1.0) else: amount = brush_strength * dt - TerrainField.apply_brush(_heights, _res, _sx, _sz, -100000.0, 100000.0, - save_x, save_z, brush_radius, mode, amount) + if mode == TerrainField.Mode.PAINT: + _paint_step(save_x, save_z) + continue + _height_step(save_x, save_z, amount) _expand_range_if_needed() _end_stroke() _refresh_from_doc() @@ -339,6 +335,185 @@ func demo_park_brush(x: float, z: float) -> void: _ring.visible = true +# ------------------------------------------------------------- Symmetrie ---- +## M2 (Karte t_f9eff526): Im Symmetrie-Modus (main.sym_mode ≠ AUS) schreibt +## jeder Strich seine Zellen EXAKT gespiegelt in die andere Kartenseite — +## Höhe ist ein Skalar → 1:1 identisch übernommen, Material ebenso. Die +## Zell-Abbildung kommt aus M1 (SymmetryCore.mirror_cell), nichts neu erfunden. +## +## Ablauf pro Pinsel-Schritt: +## 1. Pinsel EINMAL an der Strich-Position anwenden — bewusst NICHT zweimal +## (Strichpunkt + Spiegelpunkt): sonst bekämen Zellen im Überlappband den +## Effekt doppelt und an der Rastermitte entstünde ein Grat. +## 2. Die seit dem Snapshot geänderten Zellen des Pinsel-Rechtecks ermitteln +## und jede Änderung in die gespiegelte Schwesterzelle kopieren. +## Liegt der Strich genau auf der Mitte/Achse, bildet mirror_cell die Zellen +## auf sich selbst bzw. auf Partner mit IDENTISCHEm Wert ab (spiegelsymmetrischer +## Fußabdruck, gleicher Falloff-Abstand) → nichts wird gedoppelt. +## Überlappen Fußabdruck und Spiegelbild (Strich nahe der Mitte), gewinnt pro +## Zellpaar die Seite, die NÄHER am Strichpunkt liegt — die andere Hälfte ist +## exakte Kopie der Strich-Seite; bei exaktem Gleichstand entscheidet der +## kleinere Grid-Index (deterministisch, Row-major-Verarbeitung). +## Bei Modus AUS: keine Snapshot-/Kopierarbeit — exakt das alte Verhalten. + +## Zusätzlicher Rand um die grobe Pinsel-Region (TerrainField arbeitet mit +## floor/ceil ± 1; hier großzügig ± 2 — der Snapshot-Vergleich entscheidet +## ohnehin zellgenau, was wirklich geändert wurde). +const SYM_REGION_MARGIN := 2 + +func _sym_mode() -> int: + if main == null: + return SymmetryCore.Mode.OFF + # .get statt direkter Property: Haupts ohne sym_mode-Feld (z. B. alte + # Test-Stubs, Tools außerhalb des Editors) fallen auf AUS zurück — + # exakt das Verhalten vor M2. + var mode: Variant = main.get("sym_mode") + if mode == null or not (mode is int): + return SymmetryCore.Mode.OFF + return int(mode) + + +func _sym_active() -> bool: + return _sym_mode() != SymmetryCore.Mode.OFF + + +## Welt-X/Z → Raster-Koordinaten (gleiche Konvention wie TerrainField.height_at: +## Index 0 = −X/−Z-Ecke, x-Index läuft MIT +WeltX). +func _world_to_grid(x: float, z: float) -> Vector2: + var last := float(_res - 1) + return Vector2((x / _sx + 0.5) * last, (z / _sz + 0.5) * last) + + +## Grobes Rechteck um die Raster-Position (g), das den Pinsel-Fußabdruck +## jedes Modus garantiert abdeckt (Radius in Zellen + Margin). +func _brush_region(g: Vector2) -> Rect2i: + var cell_x := _sx / float(_res - 1) + var cell_z := _sz / float(_res - 1) + var rx := brush_radius / cell_x + float(SYM_REGION_MARGIN) + var rz := brush_radius / cell_z + float(SYM_REGION_MARGIN) + var x0 := maxi(int(floorf(g.x - rx)), 0) + var x1 := mini(int(ceilf(g.x + rx)), _res - 1) + var z0 := maxi(int(floorf(g.y - rz)), 0) + var z1 := mini(int(ceilf(g.y + rz)), _res - 1) + return Rect2i(x0, z0, x1 - x0 + 1, z1 - z0 + 1) + + +## Abstand einer Rasterzelle zum Strichpunkt in Metern (gleiche Zell-Metrik +## wie TerrainField.apply_brush: Zellindex-Abstand × Zellgröße). +func _cell_dist(pos: Vector2i, g: Vector2, cell_x: float, cell_z: float) -> float: + var dx := (float(pos.x) - g.x) * cell_x + var dz := (float(pos.y) - g.y) * cell_z + return sqrt(dx * dx + dz * dz) + + +## Ein Pinsel-Schritt HÖHE (Heben/Senken/Glätten) inkl. Spiegel-Kopie. +## amount: Meter (Heben/Senken) bzw. Mischfaktor 0–1 (Glätten) — unverändert +## zur bisherigen apply_brush-Semantik; ohne Clamp (Range-Expander zieht nach). +func _height_step(x: float, z: float, amount: float) -> bool: + var g := _world_to_grid(x, z) + var snap := {} + if _sym_active(): + snap = _snap_heights(_brush_region(g)) + var changed := TerrainField.apply_brush(_heights, _res, _sx, _sz, + -100000.0, 100000.0, x, z, brush_radius, _stroke_mode, amount) + if changed and not snap.is_empty(): + _mirror_heights(snap, g) + return changed + + +## Ein Pinsel-Schritt MATERIAL (Malen) inkl. Spiegel-Kopie. +func _paint_step(x: float, z: float) -> bool: + var g := _world_to_grid(x, z) + var snap := {} + if _sym_active() and _mats.size() >= _res * _res: + snap = _snap_mats(_brush_region(g)) + var changed := TerrainField.apply_material_brush(_mats, _res, _sx, _sz, + x, z, brush_radius, _stroke_material) + if changed and not snap.is_empty(): + _mirror_mats(snap, g) + return changed + + +## Snapshot {Vector2i(x, z) → Höhe} über das Pinsel-Rechteck (NUR bei +## Symmetrie AN erzeugt — bei AUS bleibt es bei exakt dem alten Verhalten). +func _snap_heights(rect: Rect2i) -> Dictionary: + var snap := {} + for z: int in range(rect.position.y, rect.end.y): + for x: int in range(rect.position.x, rect.end.x): + snap[Vector2i(x, z)] = _heights[z * _res + x] + return snap + + +## Spiegel-Pass für die Höhe: jede seit dem Snapshot geänderte Zelle wird in +## ihre gespiegelte Schwester kopiert (Strich-Seite ist Master, siehe oben). +func _mirror_heights(snap: Dictionary, g: Vector2) -> void: + var sym := _sym_mode() + var cell_x := _sx / float(_res - 1) + var cell_z := _sz / float(_res - 1) + var done := {} + var positions: Array = snap.keys() + # Row-major (kleinster Index zuerst): der Gleichstand-Tie-Break „kleinerer + # Index gewinnt“ ist damit deterministisch, und ein Paar wird genau einmal + # an seinem kleineren Index entschieden. + positions.sort_custom(func(a: Vector2i, b: Vector2i) -> bool: + return (a.y * _res + a.x) < (b.y * _res + b.x)) + for pos: Vector2i in positions: + if done.has(pos): + continue + var idx: int = pos.y * _res + pos.x + var mirror: Vector2i = SymmetryCore.mirror_cell(sym, pos.x, pos.y, _res) + done[pos] = true + if mirror == pos: + continue # Selbstabbildung (Rastermitte/Achse) — nichts zu kopieren + var midx: int = mirror.y * _res + mirror.x + var value: float = _heights[idx] + if snap.has(mirror): + # Beide Seiten wurden vom Pinsel geändert (Fußabdruck überlappt + # sein Spiegelbild): näher am Strichpunkt gewinnt, Gleichstand + # → kleinere Index (== diese pos, da Row-major zuerst kam). + if _cell_dist(mirror, g, cell_x, cell_z) < _cell_dist(pos, g, cell_x, cell_z): + value = _heights[midx] + _heights[idx] = value + _heights[midx] = value + done[mirror] = true + + +## Snapshot {Vector2i(x, z) → Material-ID} über das Pinsel-Rechteck. +func _snap_mats(rect: Rect2i) -> Dictionary: + var snap := {} + for z: int in range(rect.position.y, rect.end.y): + for x: int in range(rect.position.x, rect.end.x): + snap[Vector2i(x, z)] = int(_mats[z * _res + x]) + return snap + + +## Spiegel-Pass für die Material-Schicht (identische Paar-Logik wie _mirror_heights). +func _mirror_mats(snap: Dictionary, g: Vector2) -> void: + var sym := _sym_mode() + var cell_x := _sx / float(_res - 1) + var cell_z := _sz / float(_res - 1) + var done := {} + var positions: Array = snap.keys() + positions.sort_custom(func(a: Vector2i, b: Vector2i) -> bool: + return (a.y * _res + a.x) < (b.y * _res + b.x)) + for pos: Vector2i in positions: + if done.has(pos): + continue + var idx: int = pos.y * _res + pos.x + var mirror: Vector2i = SymmetryCore.mirror_cell(sym, pos.x, pos.y, _res) + done[pos] = true + if mirror == pos: + continue + var midx: int = mirror.y * _res + mirror.x + var value: int = int(_mats[idx]) + if snap.has(mirror): + if _cell_dist(mirror, g, cell_x, cell_z) < _cell_dist(pos, g, cell_x, cell_z): + value = int(_mats[midx]) + _mats[idx] = value + _mats[midx] = value + done[mirror] = true + + # ----------------------------------------------------------- Ring-Optik ----- func _refresh_ring_mesh() -> void: diff --git a/tests/probe_terrain_mirror_render.gd b/tests/probe_terrain_mirror_render.gd new file mode 100644 index 0000000..9e55f1f --- /dev/null +++ b/tests/probe_terrain_mirror_render.gd @@ -0,0 +1,148 @@ +extends SceneTree +## Render-Probe M2 (Karte t_f9eff526): Terrain spiegeln im Symmetrie-Modus. +## Zwei Striche (Heben bei −260/−140, Malen Sand bei +150/+180) mit +## Symmetrie=Punkt über den ECHTEN terrain_tool-Pfad (demo_stroke) — +## draufsichtig gerendert müssen BOTHSEITIG Hügel + Sand erscheinen: +## - Hügel-Fenster und Zwilling-Hügel-Fenster nahezu gleich hell +## - Sand-Fenster und Zwilling-Sand-Fenster nahezu gleich farbig +## - beide deutlich verschieden zur unberührten Mitte +## PNGs landen im Profil-Scratch + /opt/data/scopedvr/work/m2_bilder/. +## Start (BRAUCHT Rendering — NICHT --headless): +## xvfb-run godot --path . --rendering-driver opengl3 -s res://tests/probe_terrain_mirror_render.gd + +const RES := 65 +const SIZE := 1024.0 +var failures := 0 +var checks := 0 +var out_dir := "/opt/data/profiles/scopedvr-dev/cache/scratch" + + +class ToolMain: + var doc: Dictionary = {} + var sym_mode: int = SymmetryCore.Mode.POINT + var history: ActionHistory = ActionHistory.new() + func _set_status(_text: String, _err := false) -> void: + pass + func _on_terrain_stroke_committed() -> void: + pass + + +func _initialize() -> void: + await process_frame + await process_frame + # --- Doc + Terrain-Tool (echter Strich-Pfad inkl. Spiegel) --- + var doc := SvmapDocument.create_new("MirrorProbe", SIZE, "mittag") + var terrain: Dictionary = doc["terrain"] + terrain["resolution"] = RES + terrain["size_x_m"] = SIZE + terrain["size_z_m"] = SIZE + terrain["min_height_m"] = 0.0 + terrain["max_height_m"] = 40.0 + var flat := PackedFloat32Array() + flat.resize(RES * RES) + terrain["heightmap_r16_b64"] = TerrainField.encode(flat, 0.0, 40.0) + var mats := PackedByteArray() + mats.resize(RES * RES) + terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats) + var tmain := ToolMain.new() + tmain.doc = doc + tmain.history.begin(doc) + var ttool := Node3D.new() + ttool.set_script(load("res://src/editor/terrain_tool.gd")) + ttool.setup(tmain, Camera3D.new(), null, null) + ttool.call("activate") + ttool.call("set_radius", 100.0) + ttool.call("set_paint_material", 1) + ttool.call("demo_stroke", -260.0, -140.0, TerrainField.Mode.RAISE, 2.0) + ttool.call("demo_stroke", 150.0, 180.0, TerrainField.Mode.PAINT, 1.0) + var heights := SvmapIO.decode_heights(terrain) + var got_mats := SvmapIO.decode_materials(terrain) + # Zellgleiche Hälften numerisch gegenprüfen (Punkt-Modus): + var bad_h := 0 + var bad_m := 0 + for z in RES: + for x in RES: + var mc: Vector2i = SymmetryCore.mirror_cell(SymmetryCore.Mode.POINT, x, z, RES) + if heights[z * RES + x] != heights[mc.y * RES + mc.x]: + bad_h += 1 + if int(got_mats[z * RES + x]) != int(got_mats[mc.y * RES + mc.x]): + bad_m += 1 + print("PROBE_MIRROR_ASYM hoehe=%d material=%d (0 = exakt zellgleich)" % [bad_h, bad_m]) + _check(bad_h == 0, "Höhen-Hälften exakt zellgleich (0 Abweichungen)") + _check(bad_m == 0, "Material-Hälften exakt zellgleich (0 Abweichungen)") + + # --- Draufsicht rendern (unshaded Vertex-Farben + Höhenlinien) --- + var view := Node3D.new() + view.set_script(load("res://src/viewport/terrain_mesh.gd")) + root.add_child(view) + view.call("update_heights", heights, RES, SIZE, SIZE, got_mats) + var cam := Camera3D.new() + root.add_child(cam) + cam.projection = Camera3D.PROJECTION_ORTHOGONAL + var vp := root.size + cam.size = SIZE * float(vp.y) / float(vp.x) + cam.position = Vector3(0.0, SIZE, 0.01) + cam.look_at(Vector3.ZERO, Vector3(0, 0, -1)) + cam.current = true + await process_frame + await process_frame + await RenderingServer.frame_post_draw + var img := root.get_texture().get_image() + img.save_png(out_dir + "/terrain_mirror_beweis.png") + print("PROBE_SAVED %s/terrain_mirror_beweis.png" % out_dir) + + # --- Fenster-Messungen --- + # Ortho-Kamera füllt exakt die BildBREITE: horizontale Ausdehnung = + # SIZE, vertikale = SIZE × (Höhe/Breite des Viewports) (KEEP_HEIGHT). + # Welt → Bild: sx = 0.5 + x/SIZE, sy = 0.5 + z/vert_extent. + var w := img.get_width() + var h := img.get_height() + var vert := SIZE * float(h) / float(w) + var hill_a := _window_mean(img, Vector2(w * (0.5 - 260.0 / SIZE), h * (0.5 - 140.0 / vert)), int(w * 0.04)) + var hill_b := _window_mean(img, Vector2(w * (0.5 + 260.0 / SIZE), h * (0.5 + 140.0 / vert)), int(w * 0.04)) + var sand_a := _window_mean(img, Vector2(w * (0.5 + 150.0 / SIZE), h * (0.5 + 180.0 / vert)), int(w * 0.04)) + var sand_b := _window_mean(img, Vector2(w * (0.5 - 150.0 / SIZE), h * (0.5 - 180.0 / vert)), int(w * 0.04)) + var mitte := _window_mean(img, Vector2(w * 0.5, h * 0.5), int(w * 0.03)) + print("PROBE_HUEGEL a=%s b=%s" % [hill_a, hill_b]) + print("PROBE_SAND a=%s b=%s" % [sand_a, sand_b]) + print("PROBE_MITTE %s (unberührtes Gras)" % mitte) + var dh := _v3dist(hill_a, hill_b) + var ds := _v3dist(sand_a, sand_b) + print("PROBE_SYMDIFF huegel=%.4f sand=%.4f" % [dh, ds]) + _check(dh < 0.02, "Hügel und Zwilling-Hügel im Bild gleich (Δ %.4f < 0.02)" % dh) + _check(ds < 0.02, "Sand und Zwilling-Sand im Bild gleich (Δ %.4f < 0.02)" % ds) + _check(_v3dist(hill_a, mitte) > 0.05, "Hügel deutlich sichtbar vs. Mitte (Δ %.4f)" % _v3dist(hill_a, mitte)) + _check(_v3dist(sand_a, mitte) > 0.05, "Sand deutlich sichtbar vs. Gras-Mitte (Δ %.4f)" % _v3dist(sand_a, mitte)) + + if failures == 0: + print("ALL OK: %d checks" % checks) + quit(0) + else: + print("FAILED: %d/%d checks" % [failures, checks]) + quit(1) + + +func _window_mean(img: Image, center: Vector2, radius_px: int) -> Vector3: + var s := Vector3.ZERO + var n := 0 + var cx := int(center.x) + var cy := int(center.y) + for y in range(maxi(0, cy - radius_px), mini(img.get_height(), cy + radius_px)): + for x in range(maxi(0, cx - radius_px), mini(img.get_width(), cx + radius_px)): + var c := img.get_pixel(x, y) + s += Vector3(c.r, c.g, c.b) + n += 1 + return s / float(maxi(n, 1)) + + +func _v3dist(a: Vector3, b: Vector3) -> float: + return (a - b).length() + + +func _check(cond: bool, label: String) -> void: + checks += 1 + if cond: + print(" ok: %s" % label) + else: + failures += 1 + print(" FAIL: %s" % label) \ No newline at end of file diff --git a/tests/probe_terrain_mirror_render.gd.uid b/tests/probe_terrain_mirror_render.gd.uid new file mode 100644 index 0000000..df2862c --- /dev/null +++ b/tests/probe_terrain_mirror_render.gd.uid @@ -0,0 +1 @@ +uid://fk6ohskqg8ip diff --git a/tests/test_terrain_mirror.gd b/tests/test_terrain_mirror.gd new file mode 100644 index 0000000..6107ba9 --- /dev/null +++ b/tests/test_terrain_mirror.gd @@ -0,0 +1,499 @@ +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() + + +# ------------------------------------------------------------- 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 + + func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray()) -> void: + last_heights = heights + last_mats = mats + 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) + 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 + _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 + _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") + 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) \ No newline at end of file diff --git a/tests/test_terrain_mirror.gd.uid b/tests/test_terrain_mirror.gd.uid new file mode 100644 index 0000000..eb8a268 --- /dev/null +++ b/tests/test_terrain_mirror.gd.uid @@ -0,0 +1 @@ +uid://c58vp6djhup5o