M2 (t_f9eff526): Terrain spiegeln — Höhen UND Boden-Material in allen Live-Pfaden (Spec vc27). terrain_tool: jeder Pinsel-Schritt (Heben/Senken/Glätten/Malen) wendet den Pinsel EINMAL an und kopiert die geänderten Zellen (Snapshot-Vergleich) exakt per SymmetryCore.mirror_cell in die andere Hälfte — Strich-Seite ist Master, Überlappung gewinnt strichnäher, Mitte/Achse wird nicht gedoppelt. demo_stroke läuft über dieselben Schritte. Ohne sym_mode-Feld/Modus AUS: exakt altes Verhalten (byte-identisch zu origin/main, SHA256-Gegenprobe). terrain_mesh unverändert (Live-Pfade tragen Höhe+Schicht bereits, Audit im Handoff). Neu: tests/test_terrain_mirror.gd (105 checks, alle 3 Modi × 4 Pinsel + Mitte/Achse/Rand/Überlapp + Gegenprobe) und tests/probe_terrain_mirror_render.gd (Beweis-Bild Draufsicht).

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-20 06:49:06 +00:00
parent 238ca7bb2f
commit 2c10863759
5 changed files with 834 additions and 10 deletions

View file

@ -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:

View file

@ -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)

View file

@ -0,0 +1 @@
uid://fk6ohskqg8ip

View file

@ -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)

View file

@ -0,0 +1 @@
uid://c58vp6djhup5o