Feines Material-Raster: Malen auf ~1 m statt Höhenraster (bei 1024 m vorher 8 m)

Sly: „feiner malen“. Die Material-Schicht bekommt ein eigenes Raster
(terrain.material_resolution, optional; Default = resolution → alte Karten
unverändert gültig): ~1 m pro Punkt, auf dem Höhenraster ausgerichtet,
max. 1025 (1024 m → 1025, 256 m → 257, 2048 m → 1025 = 2 m).

- SvmapIO: material_resolution / material_resolution_for / resample /
  upgrade_material_layer / material_at_height_point; V15 prüft Raster + Länge.
- Neue Karten starten fein; beim Öffnen werden alte Karten optisch identisch
  angehoben (Datei ändert sich erst beim Speichern); Resize rechnet die
  Schicht auf ihrem eigenen Raster an der Weltposition um.
- Pinsel, Spiegeln (Symmetrie live + „Symmetrie anwenden“) und Live-Splat
  arbeiten auf dem Material-Raster; Vertex-Farben lesen den passenden Punkt.
- Malen-Hinweis zeigt die echte feinste Einheit.
- Tests: test_mal_fein 22/22 (Raster, Anheben, 2-m-Pinsel ≈ 13 Punkte,
  V15, Roundtrip, Resize); test_terrain/test_terrain_mirror/
  test_symmetry_onclick auf das neue Raster nachgezogen; Identitäts-Runner OK.
  Kette Editor → Server (Prüfsumme) → Client per run_map_download_e2e 15/15.

Format-Spiegel: Match-Server svmap_loader (V15) + Client svmap_terrain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-10-25 00:07:25 +02:00
parent 66df3bdc1e
commit 69668eb250
17 changed files with 397 additions and 67 deletions

View file

@ -148,6 +148,7 @@ static func analyze(doc: Dictionary, mode: int) -> Dictionary:
"gebaeude": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0}, "gebaeude": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0},
"spawns": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0}, "spawns": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0},
"terrain_cells": [], "terrain_cells": [],
"material_cells": [], # Material-Raster (fein, Karte 24.10.) — eigenes Gitter
"deviations": [], "deviations": [],
"links": 0, # positionierende Paare ohne sym-Kopplung (werden verknüpft) "links": 0, # positionierende Paare ohne sym-Kopplung (werden verknüpft)
"conflicts": 0, # widersprüchliche Paare + Boden-Zellen "conflicts": 0, # widersprüchliche Paare + Boden-Zellen
@ -163,7 +164,8 @@ static func analyze(doc: Dictionary, mode: int) -> Dictionary:
_analyze_spawns(doc, mode, an) _analyze_spawns(doc, mode, an)
_analyze_terrain(doc, mode, an) _analyze_terrain(doc, mode, an)
an["conflicts"] = int(an["objects"]["diff"]) + int(an["gebaeude"]["diff"]) \ an["conflicts"] = int(an["objects"]["diff"]) + int(an["gebaeude"]["diff"]) \
+ int(an["spawns"]["diff"]) + (an["terrain_cells"] as Array).size() + int(an["spawns"]["diff"]) + (an["terrain_cells"] as Array).size() \
+ (an["material_cells"] as Array).size()
an["adds"] = (an["objects"]["singles_low"] as Array).size() \ an["adds"] = (an["objects"]["singles_low"] as Array).size() \
+ (an["objects"]["singles_high"] as Array).size() \ + (an["objects"]["singles_high"] as Array).size() \
+ (an["gebaeude"]["singles_low"] as Array).size() \ + (an["gebaeude"]["singles_low"] as Array).size() \
@ -455,9 +457,8 @@ static func _analyze_terrain(doc: Dictionary, mode: int, an: Dictionary) -> void
var heights := SvmapIO.decode_heights(terrain) var heights := SvmapIO.decode_heights(terrain)
if heights.size() < res * res: if heights.size() < res * res:
return return
var mats := SvmapIO.decode_materials(terrain)
var cells: Array = an["terrain_cells"] var cells: Array = an["terrain_cells"]
var eps := HEIGHT_EPS_M # absolut in Metern — Material als Byte exakt vergleichen var eps := HEIGHT_EPS_M # absolut in Metern
for j in res: for j in res:
for i in res: for i in res:
var mc := SymmetryCore.mirror_cell(mode, i, j, res) var mc := SymmetryCore.mirror_cell(mode, i, j, res)
@ -470,11 +471,25 @@ static func _analyze_terrain(doc: Dictionary, mode: int, an: Dictionary) -> void
var ia := j * res + i var ia := j * res + i
var ib := mc.y * res + mc.x var ib := mc.y * res + mc.x
var dh := absf(heights[ia] - heights[ib]) var dh := absf(heights[ia] - heights[ib])
var dm: bool = mats[ia] != mats[ib] if dh > eps:
if dh > eps or dm:
cells.append(Vector2i(i, j)) cells.append(Vector2i(i, j))
if cells.size() <= MAX_EXAMPLES: if cells.size() <= MAX_EXAMPLES:
_push_dev(an, "Boden (%d,%d)" % [i, j]) _push_dev(an, "Boden (%d,%d)" % [i, j])
# Material auf seinem eigenen Raster (Byte exakt vergleichen).
var mres := SvmapIO.material_resolution(terrain)
var mats := SvmapIO.decode_materials(terrain)
if mres < 2 or mats.size() < mres * mres:
return
var mcells: Array = an["material_cells"]
for j in mres:
for i in mres:
var mc := SymmetryCore.mirror_cell(mode, i, j, mres)
if not (i < mc.x or (i == mc.x and j < mc.y)):
continue
if mats[j * mres + i] != mats[mc.y * mres + mc.x]:
mcells.append(Vector2i(i, j))
if mcells.size() <= MAX_EXAMPLES:
_push_dev(an, "Boden-Material (%d,%d)" % [i, j])
# ----------------------------------------------------------------- check() -- # ----------------------------------------------------------------- check() --
@ -636,6 +651,7 @@ static func _apply_gebaeude(doc: Dictionary, mode: int, an: Dictionary,
static func _apply_terrain(doc: Dictionary, mode: int, an: Dictionary, static func _apply_terrain(doc: Dictionary, mode: int, an: Dictionary,
quelle_hoch: bool, c: Dictionary) -> void: quelle_hoch: bool, c: Dictionary) -> void:
_apply_materials(doc, mode, an, quelle_hoch, c)
var cells: Array = an["terrain_cells"] var cells: Array = an["terrain_cells"]
if (cells as Array).is_empty(): if (cells as Array).is_empty():
return return
@ -644,7 +660,6 @@ static func _apply_terrain(doc: Dictionary, mode: int, an: Dictionary,
if res < 2: if res < 2:
return return
var heights := SvmapIO.decode_heights(terrain) var heights := SvmapIO.decode_heights(terrain)
var mats := SvmapIO.decode_materials(terrain)
var hmin := float(terrain.get("min_height_m", 0.0)) var hmin := float(terrain.get("min_height_m", 0.0))
var hmax := float(terrain.get("max_height_m", 1.0)) var hmax := float(terrain.get("max_height_m", 1.0))
for cell in cells: for cell in cells:
@ -655,9 +670,28 @@ static func _apply_terrain(doc: Dictionary, mode: int, an: Dictionary,
var si: int = src.y * res + src.x var si: int = src.y * res + src.x
var di: int = dst.y * res + dst.x var di: int = dst.y * res + dst.x
heights[di] = heights[si] heights[di] = heights[si]
mats[di] = mats[si]
c["terrain_cells"] = int(c["terrain_cells"]) + 1 c["terrain_cells"] = int(c["terrain_cells"]) + 1
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, hmin, hmax) terrain["heightmap_r16_b64"] = TerrainField.encode(heights, hmin, hmax)
## Material-Schicht angleichen (eigenes feines Raster, Karte 24.10.).
static func _apply_materials(doc: Dictionary, mode: int, an: Dictionary,
quelle_hoch: bool, c: Dictionary) -> void:
var cells: Array = an.get("material_cells", [])
if cells.is_empty():
return
var terrain: Dictionary = doc.get("terrain", {})
var mres := SvmapIO.material_resolution(terrain)
var mats := SvmapIO.decode_materials(terrain)
if mres < 2 or mats.size() < mres * mres:
return
for cell in cells:
var cc: Vector2i = cell
var mc := SymmetryCore.mirror_cell(mode, cc.x, cc.y, mres)
var src := cc if not quelle_hoch else mc
var dst := mc if not quelle_hoch else cc
mats[dst.y * mres + dst.x] = mats[src.y * mres + src.x]
c["terrain_cells"] = int(c["terrain_cells"]) + 1
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats) terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)

View file

@ -42,6 +42,7 @@ var _mats := PackedByteArray() # Material-Schicht (res²) — seit _refresh_
# _from_doc immer dekodiert (t_55ed041a), # _from_doc immer dekodiert (t_55ed041a),
# Mal-Strich schreibt live hinein. # Mal-Strich schreibt live hinein.
var _res := 0 var _res := 0
var _mres := 0 # Raster der Material-Schicht (feines Raster, Karte 24.10.; alt = _res)
var _sx := 0.0 var _sx := 0.0
var _sz := 0.0 var _sz := 0.0
var _hmin := 0.0 var _hmin := 0.0
@ -122,6 +123,7 @@ func _refresh_from_doc() -> bool:
# beim Heben/Senken/Glätten braucht sie, sonst fällt das Mesh während # beim Heben/Senken/Glätten braucht sie, sonst fällt das Mesh während
# des Strichs auf alles-Gras zurück (Sand/Fels „verschwinden" live). # des Strichs auf alles-Gras zurück (Sand/Fels „verschwinden" live).
_mats = SvmapIO.decode_materials(terrain) _mats = SvmapIO.decode_materials(terrain)
_mres = SvmapIO.material_resolution(terrain)
return _heights.size() >= _res * _res return _heights.size() >= _res * _res
@ -222,6 +224,7 @@ func _begin_stroke(mode: int, material_id: int = -1) -> void:
# (dekodiert — Legacy-Maps ohne Schicht starten mit alles-Gras). # (dekodiert — Legacy-Maps ohne Schicht starten mit alles-Gras).
if mode == TerrainField.Mode.PAINT: if mode == TerrainField.Mode.PAINT:
_mats = SvmapIO.decode_materials(main.doc.get("terrain", {})) _mats = SvmapIO.decode_materials(main.doc.get("terrain", {}))
_mres = SvmapIO.material_resolution(main.doc.get("terrain", {}))
_refresh_ring_mesh() _refresh_ring_mesh()
main.call("_set_status", "%s läuft — Maustaste gedrückt halten. Beim Loslassen wird der ganze Strich EIN Rückgängig-Schritt (Strg+Z)." % _stroke_label()) main.call("_set_status", "%s läuft — Maustaste gedrückt halten. Beim Loslassen wird der ganze Strich EIN Rückgängig-Schritt (Strg+Z)." % _stroke_label())
@ -384,21 +387,31 @@ func _sym_active() -> bool:
## Welt-X/Z → Raster-Koordinaten (gleiche Konvention wie TerrainField.height_at: ## Welt-X/Z → Raster-Koordinaten (gleiche Konvention wie TerrainField.height_at:
## Index 0 = −X/−Z-Ecke, x-Index läuft MIT +WeltX). ## Index 0 = −X/−Z-Ecke, x-Index läuft MIT +WeltX).
func _world_to_grid(x: float, z: float) -> Vector2: func _world_to_grid(x: float, z: float) -> Vector2:
var last := float(_res - 1) return _world_to_grid_r(x, z, _res)
## Wie _world_to_grid, aber auf einem beliebigen Raster (Material: _mres).
func _world_to_grid_r(x: float, z: float, r: int) -> Vector2:
var last := float(r - 1)
return Vector2((x / _sx + 0.5) * last, (z / _sz + 0.5) * last) return Vector2((x / _sx + 0.5) * last, (z / _sz + 0.5) * last)
## Grobes Rechteck um die Raster-Position (g), das den Pinsel-Fußabdruck ## Grobes Rechteck um die Raster-Position (g), das den Pinsel-Fußabdruck
## jedes Modus garantiert abdeckt (Radius in Zellen + Margin). ## jedes Modus garantiert abdeckt (Radius in Zellen + Margin).
func _brush_region(g: Vector2) -> Rect2i: func _brush_region(g: Vector2) -> Rect2i:
var cell_x := _sx / float(_res - 1) return _brush_region_r(g, _res)
var cell_z := _sz / float(_res - 1)
## Wie _brush_region, auf dem Raster r (Material-Schicht: _mres).
func _brush_region_r(g: Vector2, r: int) -> Rect2i:
var cell_x := _sx / float(r - 1)
var cell_z := _sz / float(r - 1)
var rx := brush_radius / cell_x + float(SYM_REGION_MARGIN) var rx := brush_radius / cell_x + float(SYM_REGION_MARGIN)
var rz := brush_radius / cell_z + float(SYM_REGION_MARGIN) var rz := brush_radius / cell_z + float(SYM_REGION_MARGIN)
var x0 := maxi(int(floorf(g.x - rx)), 0) var x0 := maxi(int(floorf(g.x - rx)), 0)
var x1 := mini(int(ceilf(g.x + rx)), _res - 1) var x1 := mini(int(ceilf(g.x + rx)), r - 1)
var z0 := maxi(int(floorf(g.y - rz)), 0) var z0 := maxi(int(floorf(g.y - rz)), 0)
var z1 := mini(int(ceilf(g.y + rz)), _res - 1) var z1 := mini(int(ceilf(g.y + rz)), r - 1)
return Rect2i(x0, z0, x1 - x0 + 1, z1 - z0 + 1) return Rect2i(x0, z0, x1 - x0 + 1, z1 - z0 + 1)
@ -427,14 +440,15 @@ func _height_step(x: float, z: float, amount: float) -> bool:
## Ein Pinsel-Schritt MATERIAL (Malen) inkl. Spiegel-Kopie. ## Ein Pinsel-Schritt MATERIAL (Malen) inkl. Spiegel-Kopie.
func _paint_step(x: float, z: float) -> bool: func _paint_step(x: float, z: float) -> bool:
var g := _world_to_grid(x, z) # Material lebt auf seinem eigenen (feinen) Raster _mres (Karte 24.10.).
var g := _world_to_grid_r(x, z, _mres)
# VOR dem Pinsel: Zellrect des Fußabdrucks merken (deckt Spiegelzellen mit) # VOR dem Pinsel: Zellrect des Fußabdrucks merken (deckt Spiegelzellen mit)
# — der Live-Splat-Refresh (t_7a0a6b79) leitet genau diese Region neu ab. # — der Live-Splat-Refresh (t_7a0a6b79) leitet genau diese Region neu ab.
_splat_rect = _splat_region(g) _splat_rect = _splat_region(g)
var snap := {} var snap := {}
if _sym_active() and _mats.size() >= _res * _res: if _sym_active() and _mats.size() >= _mres * _mres:
snap = _snap_mats(_brush_region(g)) snap = _snap_mats(_brush_region_r(g, _mres))
var changed := TerrainField.apply_material_brush(_mats, _res, _sx, _sz, var changed := TerrainField.apply_material_brush(_mats, _mres, _sx, _sz,
x, z, brush_radius, _stroke_material) x, z, brush_radius, _stroke_material)
if changed and not snap.is_empty(): if changed and not snap.is_empty():
_mirror_mats(snap, g) _mirror_mats(snap, g)
@ -490,28 +504,28 @@ func _snap_mats(rect: Rect2i) -> Dictionary:
var snap := {} var snap := {}
for z: int in range(rect.position.y, rect.end.y): for z: int in range(rect.position.y, rect.end.y):
for x: int in range(rect.position.x, rect.end.x): for x: int in range(rect.position.x, rect.end.x):
snap[Vector2i(x, z)] = int(_mats[z * _res + x]) snap[Vector2i(x, z)] = int(_mats[z * _mres + x])
return snap return snap
## Spiegel-Pass für die Material-Schicht (identische Paar-Logik wie _mirror_heights). ## Spiegel-Pass für die Material-Schicht (identische Paar-Logik wie _mirror_heights).
func _mirror_mats(snap: Dictionary, g: Vector2) -> void: func _mirror_mats(snap: Dictionary, g: Vector2) -> void:
var sym := _sym_mode() var sym := _sym_mode()
var cell_x := _sx / float(_res - 1) var cell_x := _sx / float(_mres - 1)
var cell_z := _sz / float(_res - 1) var cell_z := _sz / float(_mres - 1)
var done := {} var done := {}
var positions: Array = snap.keys() var positions: Array = snap.keys()
positions.sort_custom(func(a: Vector2i, b: Vector2i) -> bool: positions.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
return (a.y * _res + a.x) < (b.y * _res + b.x)) return (a.y * _mres + a.x) < (b.y * _mres + b.x))
for pos: Vector2i in positions: for pos: Vector2i in positions:
if done.has(pos): if done.has(pos):
continue continue
var idx: int = pos.y * _res + pos.x var idx: int = pos.y * _mres + pos.x
var mirror: Vector2i = SymmetryCore.mirror_cell(sym, pos.x, pos.y, _res) var mirror: Vector2i = SymmetryCore.mirror_cell(sym, pos.x, pos.y, _mres)
done[pos] = true done[pos] = true
if mirror == pos: if mirror == pos:
continue continue
var midx: int = mirror.y * _res + mirror.x var midx: int = mirror.y * _mres + mirror.x
var value: int = int(_mats[idx]) var value: int = int(_mats[idx])
if snap.has(mirror): if snap.has(mirror):
if _cell_dist(mirror, g, cell_x, cell_z) < _cell_dist(pos, g, cell_x, cell_z): if _cell_dist(mirror, g, cell_x, cell_z) < _cell_dist(pos, g, cell_x, cell_z):
@ -580,12 +594,13 @@ var _splat_rect := Rect2i()
## Zellrect des Pinsel-Fußabdrucks (∪ Spiegelzellen): dieselben Region- ## Zellrect des Pinsel-Fußabdrucks (∪ Spiegelzellen): dieselben Region-
## Grenzen wie apply_material_brush, mit dem SYM_REGION_MARGIN-Rand — der ## Grenzen wie apply_material_brush, mit dem SYM_REGION_MARGIN-Rand — der
## deckt auch die Kernel-Nachbarschaft der Splat-Ableitung mit ab. ## deckt auch die Kernel-Nachbarschaft der Splat-Ableitung mit ab.
## g und Ergebnis im Material-Raster (_mres) — so rechnet auch die Splat.
func _splat_region(g: Vector2) -> Rect2i: func _splat_region(g: Vector2) -> Rect2i:
var rect := _brush_region(g) var rect := _brush_region_r(g, _mres)
var sym := _sym_mode() var sym := _sym_mode()
if sym == SymmetryCore.Mode.OFF: if sym == SymmetryCore.Mode.OFF:
return rect return rect
var a := SymmetryCore.mirror_cell(sym, rect.position.x, rect.position.y, _res) var a := SymmetryCore.mirror_cell(sym, rect.position.x, rect.position.y, _mres)
var b := SymmetryCore.mirror_cell(sym, rect.end.x - 1, rect.end.y - 1, _res) var b := SymmetryCore.mirror_cell(sym, rect.end.x - 1, rect.end.y - 1, _mres)
return rect.merge(Rect2i(mini(a.x, b.x), mini(a.y, b.y), return rect.merge(Rect2i(mini(a.x, b.x), mini(a.y, b.y),
absi(a.x - b.x) + 1, absi(a.y - b.y) + 1)) absi(a.x - b.x) + 1, absi(a.y - b.y) + 1))

View file

@ -24,8 +24,10 @@ static func create_new(map_name: String, size_m: float, weather: String) -> Dict
var res := DEFAULT_RESOLUTION var res := DEFAULT_RESOLUTION
var zero_heights := PackedByteArray() var zero_heights := PackedByteArray()
zero_heights.resize(res * res * 2) # alles 0 → flaches Terrain auf min_height zero_heights.resize(res * res * 2) # alles 0 → flaches Terrain auf min_height
# Feines Material-Raster (Karte 24.10.): ~1 m pro Punkt statt Höhenraster.
var mres := SvmapIO.material_resolution_for(res, size, size)
var zero_materials := PackedByteArray() var zero_materials := PackedByteArray()
zero_materials.resize(res * res) # alles 0 → komplette Map Gras zero_materials.resize(mres * mres) # alles 0 → komplette Map Gras
var half := size / 2.0 var half := size / 2.0
var data: Dictionary = { var data: Dictionary = {
"format_version": SvmapIO.FORMAT_VERSION, "format_version": SvmapIO.FORMAT_VERSION,
@ -46,6 +48,7 @@ static func create_new(map_name: String, size_m: float, weather: String) -> Dict
"resolution": res, "resolution": res,
"heightmap_r16_b64": Marshalls.raw_to_base64(zero_heights), "heightmap_r16_b64": Marshalls.raw_to_base64(zero_heights),
# v2 (t_603ec1a8): Material-Schicht — neue Maps starten komplett Gras. # v2 (t_603ec1a8): Material-Schicht — neue Maps starten komplett Gras.
"material_resolution": mres,
"materials_r8_b64": Marshalls.raw_to_base64(zero_materials), "materials_r8_b64": Marshalls.raw_to_base64(zero_materials),
}, },
"objects": [], "objects": [],
@ -199,14 +202,15 @@ static func resize_terrain(doc: Dictionary, new_size_x_m: float,
var heights_old := SvmapIO.decode_heights(old_terrain) var heights_old := SvmapIO.decode_heights(old_terrain)
var mats_old := SvmapIO.decode_materials(old_terrain) var mats_old := SvmapIO.decode_materials(old_terrain)
var old_mres := SvmapIO.material_resolution(old_terrain)
var new_mres := SvmapIO.material_resolution_for(new_res, new_sx, new_sz)
var heights_new := PackedFloat32Array() var heights_new := PackedFloat32Array()
heights_new.resize(new_res * new_res) heights_new.resize(new_res * new_res)
var mats_new := PackedByteArray() var mats_new := PackedByteArray()
mats_new.resize(new_res * new_res) mats_new.resize(new_mres * new_mres)
var last_new := float(new_res - 1) var last_new := float(new_res - 1)
var last_old := float(old_res - 1)
var half_x_old := old_sx * 0.5 var half_x_old := old_sx * 0.5
var half_z_old := old_sz * 0.5 var half_z_old := old_sz * 0.5
var eps := 0.0001 var eps := 0.0001
@ -222,10 +226,20 @@ static func resize_terrain(doc: Dictionary, new_size_x_m: float,
# Bestehendes Terrain: Weltposition halten, bilinear sampeln. # Bestehendes Terrain: Weltposition halten, bilinear sampeln.
heights_new[j * new_res + i] = TerrainField.height_at( heights_new[j * new_res + i] = TerrainField.height_at(
heights_old, old_res, old_sx, old_sz, x, z) heights_old, old_res, old_sx, old_sz, x, z)
# Material-Schicht auflösungstreu (nächster alter Gridpunkt). # Material-Schicht auf ihrem eigenen (feinen) Raster, gleiche Regel:
var fx := clampf(roundf((x / old_sx + 0.5) * last_old), 0.0, last_old) # Weltposition halten, nächster alter Punkt; neue Randzone = Gras (0).
var fz := clampf(roundf((z / old_sz + 0.5) * last_old), 0.0, last_old) var last_mnew := float(new_mres - 1)
mats_new[j * new_res + i] = mats_old[int(fz) * old_res + int(fx)] var last_mold := float(old_mres - 1)
for j in new_mres:
var z := (float(j) / last_mnew - 0.5) * new_sz
for i in new_mres:
var x := (float(i) / last_mnew - 0.5) * new_sx
if x < -half_x_old - eps or x > half_x_old + eps \
or z < -half_z_old - eps or z > half_z_old + eps:
continue
var fx := clampf(roundf((x / old_sx + 0.5) * last_mold), 0.0, last_mold)
var fz := clampf(roundf((z / old_sz + 0.5) * last_mold), 0.0, last_mold)
mats_new[j * new_mres + i] = mats_old[int(fz) * old_mres + int(fx)]
var terrain := { var terrain := {
"type": "heightmap", "type": "heightmap",
@ -235,6 +249,7 @@ static func resize_terrain(doc: Dictionary, new_size_x_m: float,
"max_height_m": hmax, "max_height_m": hmax,
"resolution": new_res, "resolution": new_res,
"heightmap_r16_b64": TerrainField.encode(heights_new, hmin, hmax), "heightmap_r16_b64": TerrainField.encode(heights_new, hmin, hmax),
"material_resolution": new_mres,
"materials_r8_b64": SvmapIO.encode_materials(mats_new), "materials_r8_b64": SvmapIO.encode_materials(mats_new),
} }

View file

@ -72,6 +72,13 @@ const MAX_BOUNDS_EXTENT_M := 8192.0 # V14
const BOUNDS_TOLERANCE_M := 1.0 # V9 const BOUNDS_TOLERANCE_M := 1.0 # V9
const MIN_SPAWN_POINTS_PER_TEAM := 4 # V11 (Entscheidung 11) const MIN_SPAWN_POINTS_PER_TEAM := 4 # V11 (Entscheidung 11)
const SYM_KEY_MAX_LEN := 64 # V16 (Symmetrie-Partner-Kennung) const SYM_KEY_MAX_LEN := 64 # V16 (Symmetrie-Partner-Kennung)
## Feines Material-Raster (Karte 24.10., Sly: „feiner malen“): optionales
## terrain.material_resolution — eigenes Raster für materials_r8_b64, feiner als
## das Höhenfeld. Fehlt es, gilt resolution (alte Maps unverändert). Muss
## ≥ resolution sein und auf dem Höhenraster liegen ((m−1) Vielfaches von
## (res−1)), damit jeder Höhenpunkt genau einen Materialpunkt hat.
const MAX_MATERIAL_RESOLUTION := 1025 # V15 (1025² = 1 MB)
const MATERIAL_TARGET_M := 1.0 # Ziel: 1 m pro Materialpunkt
const MANIFEST_PATH := "res://library/objects.json" const MANIFEST_PATH := "res://library/objects.json"
const JSON_INDENT := " " const JSON_INDENT := " "
@ -261,12 +268,21 @@ static func _validate_terrain(terrain: Dictionary) -> PackedStringArray:
errors.append("V5: heightmap_r16_b64 dekodiert zu %d Bytes, erwartet %d (= resolution² × 2)." % [ errors.append("V5: heightmap_r16_b64 dekodiert zu %d Bytes, erwartet %d (= resolution² × 2)." % [
raw.size(), res_i * res_i * 2]) raw.size(), res_i * res_i * 2])
# V15: Material-Schicht (v2, optional — fehlt sie, gilt alles Gras). # V15: Material-Schicht (v2, optional — fehlt sie, gilt alles Gras).
var mres_v = terrain.get("material_resolution", res_i)
var mres_i := res_i
if not (mres_v is int or mres_v is float) or float(mres_v) != floorf(float(mres_v)) \
or int(mres_v) < res_i or int(mres_v) > MAX_MATERIAL_RESOLUTION \
or (res_i > 1 and (int(mres_v) - 1) % (res_i - 1) != 0):
errors.append("V15: terrain.material_resolution muss eine ganze Zahl %d–%d sein, deren (Wert−1) ein Vielfaches von (resolution−1) ist (ist %s)." % [
res_i, MAX_MATERIAL_RESOLUTION, str(mres_v)])
else:
mres_i = int(mres_v)
var mats = terrain.get("materials_r8_b64", "") var mats = terrain.get("materials_r8_b64", "")
if mats is String and (mats as String) != "": if mats is String and (mats as String) != "":
var mraw := Marshalls.base64_to_raw(mats as String) var mraw := Marshalls.base64_to_raw(mats as String)
if mraw.size() != res_i * res_i: if mraw.size() != mres_i * mres_i:
errors.append("V15: materials_r8_b64 dekodiert zu %d Bytes, erwartet %d (= resolution²)." % [ errors.append("V15: materials_r8_b64 dekodiert zu %d Bytes, erwartet %d (= material_resolution²)." % [
mraw.size(), res_i * res_i]) mraw.size(), mres_i * mres_i])
else: else:
for v in mraw: for v in mraw:
if int(v) >= MATERIAL_IDS.size(): if int(v) >= MATERIAL_IDS.size():
@ -579,7 +595,7 @@ static func decode_heights(terrain: Dictionary) -> PackedFloat32Array:
## Fehlt das Feld (Legacy-v1) → alles Gras (Default). Identische Indexierung ## Fehlt das Feld (Legacy-v1) → alles Gras (Default). Identische Indexierung
## wie das Höhenfeld — TerrainField.apply_material_brush arbeitet darauf. ## wie das Höhenfeld — TerrainField.apply_material_brush arbeitet darauf.
static func decode_materials(terrain: Dictionary) -> PackedByteArray: static func decode_materials(terrain: Dictionary) -> PackedByteArray:
var res := int(terrain.get("resolution", 0)) var res := material_resolution(terrain)
var out := PackedByteArray() var out := PackedByteArray()
out.resize(maxi(res * res, 0)) # PackedByteArray füllt mit 0 = Gras out.resize(maxi(res * res, 0)) # PackedByteArray füllt mit 0 = Gras
var mats = terrain.get("materials_r8_b64", "") var mats = terrain.get("materials_r8_b64", "")
@ -592,6 +608,70 @@ static func decode_materials(terrain: Dictionary) -> PackedByteArray:
return out return out
## Raster der Material-Schicht (feines Raster, Karte 24.10.) — ohne Feld
## gilt das Höhenraster (alte Maps).
static func material_resolution(terrain: Dictionary) -> int:
var v = terrain.get("material_resolution", null)
if v is int or v is float:
return int(v)
return int(terrain.get("resolution", 0))
## Ziel-Raster für eine Karte: ~MATERIAL_TARGET_M pro Punkt, auf dem
## Höhenraster ausgerichtet, gedeckelt auf MAX_MATERIAL_RESOLUTION.
## 1024 m / res 129 → 1025 (1 m); 256 m / 129 → 257 (1 m); 2048 m → 1025 (2 m).
static func material_resolution_for(res: int, size_x: float, size_z: float) -> int:
if res < 2:
return res
var cell := maxf(size_x, size_z) / float(res - 1)
var k := maxi(1, int(ceilf(cell / MATERIAL_TARGET_M - 0.001)))
while k > 1 and (res - 1) * k + 1 > MAX_MATERIAL_RESOLUTION:
k -= 1
return (res - 1) * k + 1
## Material-Schicht auf ein anderes Raster umrechnen (nächster Punkt über die
## normierte Position — beide Raster spannen dieselbe Kartenfläche auf).
static func resample_materials(mats: PackedByteArray, from_res: int, to_res: int) -> PackedByteArray:
var out := PackedByteArray()
out.resize(maxi(to_res * to_res, 0))
if from_res < 2 or to_res < 2 or mats.size() < from_res * from_res:
return out
var f := float(from_res - 1) / float(to_res - 1)
for j in to_res:
var sj := mini(int(roundf(float(j) * f)), from_res - 1) * from_res
var row := j * to_res
for i in to_res:
out[row + i] = mats[sj + mini(int(roundf(float(i) * f)), from_res - 1)]
return out
## Alte Karte (Material-Raster = Höhenraster) auf das feine Raster heben.
## true = geändert. Optisch identisch (nächster Punkt), nur ab jetzt feiner malbar.
static func upgrade_material_layer(terrain: Dictionary) -> bool:
var res := int(terrain.get("resolution", 0))
if res < 2:
return false
var target := material_resolution_for(res, float(terrain.get("size_x_m", 0.0)),
float(terrain.get("size_z_m", 0.0)))
var cur := material_resolution(terrain)
if cur >= target:
return false
var mats := resample_materials(decode_materials(terrain), cur, target)
terrain["material_resolution"] = target
terrain["materials_r8_b64"] = encode_materials(mats)
return true
## Material-ID am Höhenpunkt (x, z) des Höhenrasters — für Vertex-Farben.
static func material_at_height_point(mats: PackedByteArray, mres: int, res: int,
x: int, z: int) -> int:
if mres < 2 or res < 2 or mats.size() < mres * mres:
return 0
var f := float(mres - 1) / float(res - 1)
return int(mats[mini(int(roundf(float(z) * f)), mres - 1) * mres + mini(int(roundf(float(x) * f)), mres - 1)])
## Material-Schicht kodieren: uint8 je Sample (Werte 0..MATERIAL_IDS.size()-1). ## Material-Schicht kodieren: uint8 je Sample (Werte 0..MATERIAL_IDS.size()-1).
static func encode_materials(mats: PackedByteArray) -> String: static func encode_materials(mats: PackedByteArray) -> String:
var bytes := PackedByteArray() var bytes := PackedByteArray()

View file

@ -1211,6 +1211,10 @@ func _on_open_selected(path: String) -> void:
_set_status("Laden fehlgeschlagen: %s — versuche eine .svmap-Datei aus dem Map-Editor oder vom Server." % path.get_file(), true) _set_status("Laden fehlgeschlagen: %s — versuche eine .svmap-Datei aus dem Map-Editor oder vom Server." % path.get_file(), true)
return return
doc = result["data"] doc = result["data"]
# Feines Material-Raster (Karte 24.10.): alte Karten beim Öffnen anheben —
# optisch gleich, ab jetzt ~1 m fein malbar. Die Datei ändert sich erst
# beim nächsten Speichern (deshalb kein dirty).
SvmapIO.upgrade_material_layer(doc.get("terrain", {}))
current_path = path current_path = path
dirty = false dirty = false
history.begin(doc) history.begin(doc)
@ -2736,6 +2740,7 @@ func _on_resign_confirmed() -> void:
_set_status("Laden fehlgeschlagen: %s" % _resign_pending_path.get_file(), true) _set_status("Laden fehlgeschlagen: %s" % _resign_pending_path.get_file(), true)
return return
doc = result["data"] doc = result["data"]
SvmapIO.upgrade_material_layer(doc.get("terrain", {})) # feines Material-Raster
current_path = _resign_pending_path current_path = _resign_pending_path
dirty = true dirty = true
history.begin(doc) history.begin(doc)

View file

@ -156,7 +156,7 @@ func _conflict_text(an: Dictionary) -> String:
var obj_n := int(an["objects"]["diff"]) var obj_n := int(an["objects"]["diff"])
var teil_n := int(an["gebaeude"]["diff"]) var teil_n := int(an["gebaeude"]["diff"])
var spawn_n := int(an["spawns"]["diff"]) var spawn_n := int(an["spawns"]["diff"])
var boden_n := (an["terrain_cells"] as Array).size() var boden_n := (an["terrain_cells"] as Array).size() + (an.get("material_cells", []) as Array).size()
if obj_n > 0: if obj_n > 0:
parts.append("%d Objekt-Paare unterscheiden sich" % obj_n) parts.append("%d Objekt-Paare unterscheiden sich" % obj_n)
if teil_n > 0: if teil_n > 0:

View file

@ -260,14 +260,14 @@ func _on_terrain_mode_changed(_pressed: bool) -> void:
## Hinweistext Malen-Modus (pure, testbar): feinste Mal-Einheit = Abstand ## Hinweistext Malen-Modus (pure, testbar): feinste Mal-Einheit = Abstand
## zweier Rasterpunkte der Karte (size / (resolution − 1)). ## zweier Rasterpunkte der Karte (size / (resolution − 1)).
static func paint_hint_text(terrain: Dictionary) -> String: static func paint_hint_text(terrain: Dictionary) -> String:
var res := int(terrain.get("resolution", 0)) var res := SvmapIO.material_resolution(terrain) # feines Material-Raster
var size := maxf(float(terrain.get("size_x_m", 0.0)), float(terrain.get("size_z_m", 0.0))) var size := maxf(float(terrain.get("size_x_m", 0.0)), float(terrain.get("size_z_m", 0.0)))
if res < 2 or size <= 0.0: if res < 2 or size <= 0.0:
return "Malen setzt das Material zellenweise — Stärke gibt es hier nicht." return "Malen setzt das Material zellenweise — Stärke gibt es hier nicht."
var m := size / float(res - 1) var m := size / float(res - 1)
var m_text := str(int(roundf(m))) if absf(m - roundf(m)) < 0.05 \ var m_text := str(int(roundf(m))) if absf(m - roundf(m)) < 0.05 \
else String.num(m, 1).replace(".", ",") else String.num(m, 1).replace(".", ",")
return "Malen setzt das Material zellenweise: feinste Einheit auf dieser Karte ≈ %s m. Stärke gibt es hier nicht — kleiner geht's nur mit einem feineren Kartenraster." % m_text return "Malen setzt das Material zellenweise: feinste Einheit auf dieser Karte ≈ %s m. Stärke gibt es hier nicht." % m_text
## Material-Palette (Karte t_603ec1a8): gewähltes Material an den Pinsel. ## Material-Palette (Karte t_603ec1a8): gewähltes Material an den Pinsel.

View file

@ -210,13 +210,13 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0
_res = res _res = res
_size_x = size_x _size_x = size_x
_size_z = size_z _size_z = size_z
if mats.is_empty() and _mats_cache.size() == res * res: # Material-Schicht hat ihr eigenes (feines) Raster (Karte 24.10.) — mres
# aus der Länge (quadratisch); alte Karten: mres == res.
if mats.is_empty() and not _mats_cache.is_empty():
mats = _mats_cache mats = _mats_cache
elif mats.size() == res * res:
_mats_cache = mats
elif not mats.is_empty(): elif not mats.is_empty():
_mats_cache = mats # Größe passt nicht (res-Wechsel) → trotzdem _mats_cache = mats
# merken; der Index-Clamp unten schützt. var mres := mat_res_of(mats, res)
var last := float(res - 1) var last := float(res - 1)
var positions := PackedVector3Array() var positions := PackedVector3Array()
var normals := PackedVector3Array() var normals := PackedVector3Array()
@ -268,9 +268,8 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0
(hx_l - hx_r) * cell_z, (hx_l - hx_r) * cell_z,
2.0 * cell_x * cell_z, 2.0 * cell_x * cell_z,
(hz_d - hz_u) * cell_x).normalized() (hz_d - hz_u) * cell_x).normalized()
var mat_id := 0 var mat_id := clampi(SvmapIO.material_at_height_point(mats, mres, res, x, z),
if idx < mats.size(): 0, MATERIAL_COLORS.size() - 1)
mat_id = clampi(mats[idx], 0, MATERIAL_COLORS.size() - 1)
# Material-Farbe × Höhen-Kurve × Pseudo-Licht (Relief lesbar). # Material-Farbe × Höhen-Kurve × Pseudo-Licht (Relief lesbar).
var farbe := relief_color(mat_id, heights[idx], hmin, hmax, normals[idx]) var farbe := relief_color(mat_id, heights[idx], hmin, hmax, normals[idx])
# Ufer-Automatik (Karte t_28fdef03): Zellen knapp unter der Wasser- # Ufer-Automatik (Karte t_28fdef03): Zellen knapp unter der Wasser-
@ -319,11 +318,20 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0
var shm := mat as ShaderMaterial var shm := mat as ShaderMaterial
shm.set_shader_parameter("map_size_x", size_x) shm.set_shader_parameter("map_size_x", size_x)
shm.set_shader_parameter("map_size_z", size_z) shm.set_shader_parameter("map_size_z", size_z)
if splat_rect_cells.size.x > 0 and _splat_tex != null and _splat_res == res \ if splat_rect_cells.size.x > 0 and _splat_tex != null and _splat_res == mres \
and _splat_side == TerrainArrays.splat_side_for(res, maxf(size_x, size_z)): and _splat_side == TerrainArrays.splat_side_for(mres, maxf(size_x, size_z)):
_refresh_splat_region(splat_rect_cells) _refresh_splat_region(splat_rect_cells)
else: else:
_build_splat(shm, mats, res, size_x, size_z) _build_splat(shm, mats, mres, size_x, size_z)
## Raster einer Material-Schicht aus ihrer Länge (quadratisch). Unpassend/leer
## → Höhenraster (alte Karten, Legacy ohne Schicht).
static func mat_res_of(mats: PackedByteArray, res: int) -> int:
if mats.is_empty():
return res
var r := int(roundf(sqrt(float(mats.size()))))
return r if r >= 2 and r * r == mats.size() else res
## Höhenlinien-Uniform an die aktuelle Situation anpassen (Pref ∧ Relief ≥ 2 m). ## Höhenlinien-Uniform an die aktuelle Situation anpassen (Pref ∧ Relief ≥ 2 m).

View file

@ -0,0 +1,22 @@
extends SceneTree
## Probe (Karte 24.10.): alte Karte öffnen → feines Raster → 1-m-Sandpfad malen
## → speichern. Ausgabe-Pfad = erstes User-Arg. Für die Client-E2E-Kette.
func _initialize() -> void:
var out := OS.get_cmdline_user_args()[0]
var ld := SvmapIO.load_from_file(ProjectSettings.globalize_path("res://FirstFight.svmap"))
if not bool(ld["ok"]):
print("PROBE_FAIL load ", ld["errors"])
quit(1)
return
var doc: Dictionary = ld["data"]
var t: Dictionary = doc["terrain"]
SvmapIO.upgrade_material_layer(t)
var mres := SvmapIO.material_resolution(t)
var mats := SvmapIO.decode_materials(t)
for i in 40:
TerrainField.apply_material_brush(mats, mres, float(t["size_x_m"]), float(t["size_z_m"]),
-20.0 + float(i), 5.0, 0.6, 2)
t["materials_r8_b64"] = SvmapIO.encode_materials(mats)
var sv := SvmapIO.save_to_file(out, doc)
print("PROBE ", "OK" if bool(sv["ok"]) else "FAIL %s" % str(sv["errors"]), " mres=", mres)
quit(0 if bool(sv["ok"]) else 1)

View file

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

View file

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

128
tests/test_mal_fein.gd Normal file
View file

@ -0,0 +1,128 @@
extends SceneTree
## Editor-Tests (headless): feines Material-Raster (Karte 24.10., Sly: „feiner malen“).
## godot --headless --path . -s res://tests/test_mal_fein.gd
##
## F1 Ziel-Raster: 1024 m/129 → 1025 (1 m), 256 m/129 → 257 (1 m), 2048 m → 1025.
## F2 Neue Map startet mit feinem Raster und ist sofort gültig (V1–V16).
## F3 Alte Karte (Raster = Höhenraster) wird beim Anheben optisch identisch
## übernommen und ist danach gültig.
## F4 Pinsel 2 m auf 1024-m-Map ändert nur ~π·2² Punkte (nicht eine 8-m-Zelle).
## F5 V15 lehnt kaputte material_resolution / falsche Länge ab.
## F6 Speichern → Laden → identische Schicht (Roundtrip mit Prüfsumme).
## F7 Resize (1024 → 2048 m) hält die gemalte Stelle an derselben Weltposition.
var failures := 0
var checks := 0
func _initialize() -> void:
_f1()
_f2_f4_f6_f7()
_f3()
_f5()
print("test_mal_fein: %d/%d ok" % [checks - failures, checks])
quit(1 if failures > 0 else 0)
func _check(cond: bool, label: String) -> void:
checks += 1
print("%s: %s" % ["ok " if cond else "FAIL", label])
if not cond:
failures += 1
func _f1() -> void:
_check(SvmapIO.material_resolution_for(129, 1024.0, 1024.0) == 1025, "F1 1024 m/129 → 1025")
_check(SvmapIO.material_resolution_for(129, 256.0, 256.0) == 257, "F1 256 m/129 → 257")
_check(SvmapIO.material_resolution_for(129, 2048.0, 2048.0) == 1025, "F1 2048 m → gedeckelt 1025")
_check(SvmapIO.material_resolution_for(129, 64.0, 64.0) == 129, "F1 kleine Map bleibt beim Höhenraster")
func _f2_f4_f6_f7() -> void:
var doc := SvmapDocument.create_new("Fein", 1024.0, "mittag")
var t: Dictionary = doc["terrain"]
_check(SvmapIO.material_resolution(t) == 1025, "F2 neue Map: material_resolution 1025")
_check(SvmapIO._validate_core(doc).is_empty(), "F2 neue Map gültig (%s)" % str(SvmapIO._validate_core(doc)))
var mats := SvmapIO.decode_materials(t)
_check(mats.size() == 1025 * 1025, "F2 Schicht hat 1025² Punkte")
# F4: Pinsel 2 m Sand mitten auf die Map
var changed := TerrainField.apply_material_brush(mats, 1025, 1024.0, 1024.0, 10.3, -7.6, 2.0, 1)
var n := 0
for v in mats:
if v == 1:
n += 1
_check(changed and n >= 8 and n <= 20, "F4 2-m-Pinsel ändert %d Punkte (≈ π·2² = 12,6; alt: 1 Zelle à 8 m)" % n)
t["materials_r8_b64"] = SvmapIO.encode_materials(mats)
# F6: Roundtrip
var path := "user://test_mal_fein.svmap"
var sv := SvmapIO.save_to_file(path, doc)
_check(bool(sv["ok"]), "F6 speichern ok (%s)" % str(sv.get("errors", [])))
var ld := SvmapIO.load_from_file(path)
_check(bool(ld["ok"]), "F6 laden ok (%s)" % str(ld.get("errors", [])))
if bool(ld["ok"]):
var m2 := SvmapIO.decode_materials(ld["data"]["terrain"])
_check(m2 == mats, "F6 Schicht bitgleich nach Roundtrip")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
# F7: Resize auf 2048 m — Sand bleibt bei (10.3, -7.6)
var big := SvmapDocument.resize_terrain(doc, 2048.0, 2048.0)
var bt: Dictionary = big["terrain"]
var bm := SvmapIO.material_resolution(bt)
var bmats := SvmapIO.decode_materials(bt)
var gx := int(roundf((10.3 / 2048.0 + 0.5) * float(bm - 1)))
var gz := int(roundf((-7.6 / 2048.0 + 0.5) * float(bm - 1)))
_check(bmats.size() == bm * bm and int(bmats[gz * bm + gx]) == 1,
"F7 Resize 2048 m (Raster %d): Sand an derselben Weltstelle" % bm)
_check(SvmapIO._validate_core(big).is_empty(),
"F7 Resize-Ergebnis gültig")
func _f3() -> void:
# Alte Karte simulieren: Material-Raster = Höhenraster (129), ohne Feld.
var doc := SvmapDocument.create_new("Alt", 1024.0, "mittag")
var t: Dictionary = doc["terrain"]
var old := PackedByteArray()
old.resize(129 * 129)
for i in old.size():
old[i] = (i * 7) % 20
t.erase("material_resolution")
t["materials_r8_b64"] = SvmapIO.encode_materials(old)
_check(SvmapIO._validate_core(doc).is_empty(), "F3 alte Karte (ohne Feld) gültig")
_check(SvmapIO.upgrade_material_layer(t), "F3 Anheben meldet Änderung")
_check(SvmapIO.material_resolution(t) == 1025, "F3 danach Raster 1025")
var m := SvmapIO.decode_materials(t)
var same := true
for z in 129:
for x in 129:
if SvmapIO.material_at_height_point(m, 1025, 129, x, z) != int(old[z * 129 + x]):
same = false
_check(same, "F3 jeder Höhenpunkt behält sein Material (optisch identisch)")
_check(SvmapIO._validate_core(doc).is_empty(), "F3 angehobene Karte gültig")
_check(not SvmapIO.upgrade_material_layer(t), "F3 zweites Anheben ändert nichts")
func _f5() -> void:
var doc := SvmapDocument.create_new("Kaputt", 1024.0, "mittag")
var t: Dictionary = doc["terrain"]
t["material_resolution"] = 1000 # (1000−1) kein Vielfaches von 128
_check(_has_v15(SvmapIO._validate_core(doc)), "F5 material_resolution nicht auf dem Raster → V15")
t["material_resolution"] = 2049
_check(_has_v15(SvmapIO._validate_core(doc)), "F5 material_resolution > 1025 → V15")
t["material_resolution"] = 1025
var short := PackedByteArray()
short.resize(129 * 129)
t["materials_r8_b64"] = SvmapIO.encode_materials(short)
_check(_has_v15(SvmapIO._validate_core(doc)), "F5 Schichtlänge passt nicht → V15")
func _has_v15(errs: PackedStringArray) -> bool:
for e in errs:
if str(e).begins_with("V15"):
return true
return false
func _only_checksum(errs: PackedStringArray) -> bool:
for e in errs:
if not str(e).begins_with("V13"):
return false
return true

View file

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

View file

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

View file

@ -71,9 +71,11 @@ func _asym_doc() -> Dictionary:
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, terrain["heightmap_r16_b64"] = TerrainField.encode(heights,
float(terrain["min_height_m"]), float(terrain["max_height_m"])) float(terrain["min_height_m"]), float(terrain["max_height_m"]))
var mats := SvmapIO.decode_materials(terrain) var mats := SvmapIO.decode_materials(terrain)
for j in range(60, 70): var mres := SvmapIO.material_resolution(terrain) # feines Raster (8 Punkte je Höhenzelle)
for i in range(10, 20): var k := (mres - 1) / (res - 1)
mats[j * res + i] = 1 # Sandfleck nur links for j in range(60 * k, 70 * k):
for i in range(10 * k, 20 * k):
mats[j * mres + i] = 1 # Sandfleck nur links
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats) terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
(doc["objects"] as Array).append(_obj("crate_wood", [-100.0, 0.0, 40.0], 30.0)) # A (0) (doc["objects"] as Array).append(_obj("crate_wood", [-100.0, 0.0, 40.0], 30.0)) # A (0)
(doc["objects"] as Array).append(_obj("crate_wood", [100.0, 0.0, -40.0], 10.0)) # B (1) Widerspruch (doc["objects"] as Array).append(_obj("crate_wood", [100.0, 0.0, -40.0], 10.0)) # B (1) Widerspruch
@ -91,7 +93,6 @@ func _mirror_cell_equal(doc: Dictionary, mode: int) -> bool:
if res < 2: if res < 2:
return true return true
var heights := SvmapIO.decode_heights(terrain) var heights := SvmapIO.decode_heights(terrain)
var mats := SvmapIO.decode_materials(terrain)
for j in res: for j in res:
for i in res: for i in res:
var mc := SymmetryCore.mirror_cell(mode, i, j, res) var mc := SymmetryCore.mirror_cell(mode, i, j, res)
@ -99,7 +100,13 @@ func _mirror_cell_equal(doc: Dictionary, mode: int) -> bool:
continue continue
if absf(heights[j * res + i] - heights[mc.y * res + mc.x]) > 0.0001: if absf(heights[j * res + i] - heights[mc.y * res + mc.x]) > 0.0001:
return false return false
if mats[j * res + i] != mats[mc.y * res + mc.x]: # Material auf seinem eigenen (feinen) Raster (Karte 24.10.).
var mres := SvmapIO.material_resolution(terrain)
var mats := SvmapIO.decode_materials(terrain)
for j in mres:
for i in mres:
var mc := SymmetryCore.mirror_cell(mode, i, j, mres)
if mats[j * mres + i] != mats[mc.y * mres + mc.x]:
return false return false
return true return true
@ -129,7 +136,8 @@ func _test_analyze_asym_map() -> void:
_check((spawns["singles_low"] as Array).size() == 2, "Spawns: 2 Solo-Punkte links (A4 + A9)") _check((spawns["singles_low"] as Array).size() == 2, "Spawns: 2 Solo-Punkte links (A4 + A9)")
_check((an["terrain_cells"] as Array).size() > 0, "Boden: abweichende Zellen erkannt") _check((an["terrain_cells"] as Array).size() > 0, "Boden: abweichende Zellen erkannt")
_check(int(an["conflicts"]) == int(objects["diff"]) + int(spawns["diff"]) _check(int(an["conflicts"]) == int(objects["diff"]) + int(spawns["diff"])
+ (an["terrain_cells"] as Array).size(), "Widerspruchs-Zahl = Objekt- + Spawn- + Boden-Widersprüche") + (an["terrain_cells"] as Array).size() + (an["material_cells"] as Array).size(),
"Widerspruchs-Zahl = Objekt- + Spawn- + Boden-Widersprüche (Höhe + Material)")
_check(int(an["adds"]) == 3, "3 fehlende Zwillinge (C, A4, A9)") _check(int(an["adds"]) == 3, "3 fehlende Zwillinge (C, A4, A9)")
_check(int(an["total_deviations"]) == int(an["_dev_total"]), _check(int(an["total_deviations"]) == int(an["_dev_total"]),
"Abweichungs-Zähler konsistent") "Abweichungs-Zähler konsistent")

View file

@ -278,17 +278,19 @@ func _test_paint_undo_redo() -> void:
hist.begin(doc) hist.begin(doc)
var terrain: Dictionary = doc["terrain"] var terrain: Dictionary = doc["terrain"]
var mats := SvmapIO.decode_materials(terrain) var mats := SvmapIO.decode_materials(terrain)
_check(mats.size() == 129 * 129, "Neue Map: Material-Schicht 129² (alles Gras)") # Feines Material-Raster (Karte 24.10.): neue 1024-m-Maps = 1025² (1 m).
var mres := SvmapIO.material_resolution(terrain)
_check(mres == 1025 and mats.size() == mres * mres, "Neue Map: Material-Schicht 1025² (alles Gras)")
var before: Dictionary = hist.snapshot(["terrain"]) var before: Dictionary = hist.snapshot(["terrain"])
for step in 6: # Streifen von links nach rechts, wie ein Zieh-Stroke for step in 6: # Streifen von links nach rechts, wie ein Zieh-Stroke
TerrainField.apply_material_brush(mats, 129, 1024.0, 1024.0, TerrainField.apply_material_brush(mats, mres, 1024.0, 1024.0,
-300.0 + float(step) * 60.0, 0.0, 24.0, 1) -300.0 + float(step) * 60.0, 0.0, 24.0, 1)
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats) terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
hist.commit("Boden gemalt (sand)", before, hist.snapshot(["terrain"])) hist.commit("Boden gemalt (sand)", before, hist.snapshot(["terrain"]))
_check(hist.entries.size() == 1, "6 Mal-Schritte → EIN History-Eintrag") _check(hist.entries.size() == 1, "6 Mal-Schritte → EIN History-Eintrag")
var painted := SvmapIO.decode_materials(terrain) var painted := SvmapIO.decode_materials(terrain)
_check(painted[64 * 129 + 64] == 1, "Stroke-Mitte = Sand") _check(painted[512 * mres + 512] == 1, "Stroke-Mitte = Sand")
_check(painted[10 * 129 + 10] == 0, "Außerhalb des Streifens bleibt Gras") _check(painted[80 * mres + 80] == 0, "Außerhalb des Streifens bleibt Gras")
var painted_b64 := str(terrain["materials_r8_b64"]) var painted_b64 := str(terrain["materials_r8_b64"])
var desc := hist.undo() var desc := hist.undo()
_check(desc == "Boden gemalt (sand)", "Undo-Desc nennt den Mal-Stroke") _check(desc == "Boden gemalt (sand)", "Undo-Desc nennt den Mal-Stroke")
@ -318,7 +320,8 @@ func _test_material_format() -> void:
var load := SvmapIO.load_from_file(path) var load := SvmapIO.load_from_file(path)
_check(load["ok"], "v2-Map lädt sauber zurück (inkl. Prüfsumme)") _check(load["ok"], "v2-Map lädt sauber zurück (inkl. Prüfsumme)")
var mats := SvmapIO.decode_materials(load["data"]["terrain"]) var mats := SvmapIO.decode_materials(load["data"]["terrain"])
_check(mats.size() == 129 * 129, "v2: Material-Schicht komplett dekodiert") var mres := SvmapIO.material_resolution(load["data"]["terrain"])
_check(mats.size() == mres * mres and mres == 1025, "v2: Material-Schicht komplett dekodiert (1025²)")
# V15: falsche Länge → Ablehnung. # V15: falsche Länge → Ablehnung.
var bad: Dictionary = doc.duplicate(true) var bad: Dictionary = doc.duplicate(true)
@ -332,7 +335,7 @@ func _test_material_format() -> void:
# 20 (erster Wert oberhalb der neuen Obergrenze). # 20 (erster Wert oberhalb der neuen Obergrenze).
bad = doc.duplicate(true) bad = doc.duplicate(true)
var mraw := PackedByteArray() var mraw := PackedByteArray()
mraw.resize(129 * 129) mraw.resize(mres * mres)
mraw[5000] = 20 mraw[5000] = 20
(bad["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(mraw) (bad["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(mraw)
_check(_has_err(SvmapIO.validate_map(bad, {}), "V15"), _check(_has_err(SvmapIO.validate_map(bad, {}), "V15"),
@ -340,7 +343,7 @@ func _test_material_format() -> void:
# Neu: GÜLTIGE neue ID (3 = gras_satt) muss DURCHLASSEN werden. # Neu: GÜLTIGE neue ID (3 = gras_satt) muss DURCHLASSEN werden.
var good_new := doc.duplicate(true) var good_new := doc.duplicate(true)
var gmraw := PackedByteArray() var gmraw := PackedByteArray()
gmraw.resize(129 * 129) gmraw.resize(mres * mres)
gmraw[5000] = 3 gmraw[5000] = 3
(good_new["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(gmraw) (good_new["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(gmraw)
_check(not _has_err(SvmapIO.validate_map(good_new, {}), "V15"), _check(not _has_err(SvmapIO.validate_map(good_new, {}), "V15"),
@ -350,6 +353,7 @@ func _test_material_format() -> void:
var legacy: Dictionary = doc.duplicate(true) var legacy: Dictionary = doc.duplicate(true)
legacy["format_version"] = 1 legacy["format_version"] = 1
(legacy["terrain"] as Dictionary).erase("materials_r8_b64") (legacy["terrain"] as Dictionary).erase("materials_r8_b64")
(legacy["terrain"] as Dictionary).erase("material_resolution") # Alt-Karten kennen das Feld nicht
SvmapIO.sign_map(legacy) SvmapIO.sign_map(legacy)
var legacy_path := "/tmp/scopedvr_test_terrain_legacy_v1.svmap" var legacy_path := "/tmp/scopedvr_test_terrain_legacy_v1.svmap"
var lsave := SvmapIO.save_to_file(legacy_path, legacy) var lsave := SvmapIO.save_to_file(legacy_path, legacy)
@ -459,6 +463,7 @@ func _test_live_material_colors() -> void:
terrain["max_height_m"] = 10.0 terrain["max_height_m"] = 10.0
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, 0.0, 10.0) terrain["heightmap_r16_b64"] = TerrainField.encode(heights, 0.0, 10.0)
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats) terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
terrain.erase("material_resolution") # Test-Raster: Material = Höhenraster (Alt-Karte)
var tmain := ToolMain.new() var tmain := ToolMain.new()
tmain.doc = doc tmain.doc = doc
var builder := ToolBuilder.new() var builder := ToolBuilder.new()
@ -941,6 +946,7 @@ func _test_resize_grow() -> void:
terrain["max_height_m"] = ceilf(TerrainField._max_h(heights) + 1.0) terrain["max_height_m"] = ceilf(TerrainField._max_h(heights) + 1.0)
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, terrain["heightmap_r16_b64"] = TerrainField.encode(heights,
float(terrain["min_height_m"]), float(terrain["max_height_m"])) float(terrain["min_height_m"]), float(terrain["max_height_m"]))
terrain.erase("material_resolution") # Quelle = Alt-Karte (Material = Höhenraster)
var mats := SvmapIO.decode_materials(terrain) var mats := SvmapIO.decode_materials(terrain)
TerrainField.apply_material_brush(mats, 129, 256.0, 256.0, 0.0, 0.0, 40.0, 1) TerrainField.apply_material_brush(mats, 129, 256.0, 256.0, 0.0, 0.0, 40.0, 1)
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats) terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
@ -971,7 +977,9 @@ func _test_resize_grow() -> void:
_check(grown_heights.size() == 257 * 257 _check(grown_heights.size() == 257 * 257
and str(t_new["heightmap_r16_b64"]).length() > 0, and str(t_new["heightmap_r16_b64"]).length() > 0,
"Heights-Bytes an neue res angepasst (257²)") "Heights-Bytes an neue res angepasst (257²)")
_check(grown_mats.size() == 257 * 257, "Material-Bytes an neue res angepasst (257²)") # Feines Raster (24.10.): 512 m / res 257 → 2 m → Material-Raster 513 (1 m).
var gm := SvmapIO.material_resolution(t_new)
_check(gm == 513 and grown_mats.size() == gm * gm, "Material-Bytes auf feines Raster (513²)")
var same_world := true var same_world := true
var max_err := 0.0 var max_err := 0.0
for i in 129 * 129: for i in 129 * 129:
@ -1001,13 +1009,15 @@ func _test_resize_grow() -> void:
var b: int = i * 257 + j var b: int = i * 257 + j
if absf(grown_heights[a] - hmin) > 0.001 or absf(grown_heights[b] - hmin) > 0.001: if absf(grown_heights[a] - hmin) > 0.001 or absf(grown_heights[b] - hmin) > 0.001:
border_flat = false border_flat = false
if int(grown_mats[a]) != 0 or int(grown_mats[b]) != 0: for j in gm:
for i in [0, gm - 1]:
if int(grown_mats[j * gm + i]) != 0 or int(grown_mats[i * gm + j]) != 0:
border_grass = false border_grass = false
_check(border_flat, "Randzone: flach auf min_height (h=%s)" % str(hmin)) _check(border_flat, "Randzone: flach auf min_height (h=%s)" % str(hmin))
_check(border_grass, "Randzone: Material 0 (Gras)") _check(border_grass, "Randzone: Material 0 (Gras)")
# Material-Inhalt resampelt: gemalter Sand-Fleck bleibt Sand. # Material-Inhalt resampelt: gemalter Sand-Fleck bleibt Sand.
_check(int(grown_mats[128 * 257 + 128]) == 1, "Sand-Fleck bleibt Sand (Mitte)") _check(int(grown_mats[256 * gm + 256]) == 1, "Sand-Fleck bleibt Sand (Mitte)")
var old_center_mats := SvmapIO.decode_materials(doc["terrain"]) var old_center_mats := SvmapIO.decode_materials(doc["terrain"])
_check(int(old_center_mats[64 * 129 + 64]) == 1, "Gegenprobe: Quelle wirklich Sand") _check(int(old_center_mats[64 * 129 + 64]) == 1, "Gegenprobe: Quelle wirklich Sand")

View file

@ -110,6 +110,7 @@ func _make_doc() -> Dictionary:
var mats := PackedByteArray() var mats := PackedByteArray()
mats.resize(RES * RES) mats.resize(RES * RES)
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats) terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
terrain.erase("material_resolution") # Test-Raster: Material = Höhenraster (Alt-Karte)
return doc return doc