diff --git a/src/svmap/map_document.gd b/src/svmap/map_document.gd index 20ca075..4d9091a 100644 --- a/src/svmap/map_document.gd +++ b/src/svmap/map_document.gd @@ -174,10 +174,13 @@ static func resize_m_per_point(res: int, size_m: float) -> float: return size_m / float(maxi(res - 1, 1)) -## Terrain auf neue Kartenmaße resampeln (Vergrößern; Verkleinern folgt in R2). -## R1-Schnitt (PLAN.md Kette): Spawn-Zonen, objects und gebaeude werden -## unverändert ÜBERNOMMEN — auch wenn etwas außerhalb des neuen Geländes -## liegt (Filter + Out-of-Bounds-Dialog kommen in R2). Kein Mutationsspaß: +## Terrain auf neue Kartenmaße resampeln (Vergrößern UND Verkleinern — +## beides geht über dieselbe Anker-Mitte-Resample-Schleife: beim Shrink liegt +## jeder neue Gridpunkt innerhalb der alten Fläche, die Rand-flach-Regel +## greift dann nie). R1/R2-Schnitt (PLAN.md Kette): Spawn-Zonen, objects und +## gebaeude werden unverändert ÜBERNOMMEN — auch wenn etwas außerhalb des +## neuen Geländes liegt (Filter nur mit drop_outside via resize_apply, +## Out-of-Bounds-Dialog-Liste via resize_preview). Kein Mutationsspaß: ## der Eingabe-doc bleibt unangetastet, das Ergebnis ist ein frisches Dict ## ohne Prüfsumme (die alte wäre nach dem Resize ungültig — save_to_file ## signiert beim nächsten Speichern neu). @@ -256,3 +259,172 @@ static func resize_terrain(doc: Dictionary, new_size_x_m: float, }, "weather": doc.get("weather", ""), } + + +# ------------------------------------------------- R2: Out-of-Bounds-Liste ---- +# Karte t_27ba6c37 (PLAN.md „Map-Größe ändern", Punkt 3): beim Verkleinern +# bleiben Objekte/Bauteile/Spawns-Kategorien erhalten, ABER der Dialog listet +# vorher, was komplett außerhalb des neuen Geländes läge. Semantik: +# - Komplett außerhalb → gelistet (resize_apply(drop_outside=true) entfernt). +# - Teils am Rand überlappend → gelten als DRIN, werden unangetastet +# übernommen (kein Clampen-Magie — sie dürfen über die Kante ragen). +# Fußabdruck-Quellen je Kategorie (doc-lokale Fakten, kein Manifest nötig): +# - Objekte: Anker-Punkt pos (svmap speichert keinen Objekt-Fußabdruck; die +# Bibliothek lebt außerhalb der Map-Datei → die Anker-Regel ist die +# dokumentierte Wahrheit, R3 zeigt im Dialog was passiert). +# - Bauteile: das Teil trägt seinen eigenen Fußabdruck ("punkte", lokal, +# mit "rot" gedreht) — EIN Eckpunkt innerhalb der neuen Grenzen = drin. +# - Spawn-Punkte: Punkte (der Zonen-Kreis ist Editor-Visualisierung, kein +# Formatfeld — SpawnOps-Kopf) → Punkt-Regel. +# Grenzfall eps wie resize_terrain: exakt auf der Kante zählt als drin. + + +static func _xz_in_bounds(x: float, z: float, half_x: float, half_z: float, + eps: float) -> bool: + return x >= -half_x - eps and x <= half_x + eps \ + and z >= -half_z - eps and z <= half_z + eps + + +## Out-of-Bounds-Vorschau für den R3-Dialog (keine Mutation, keine Terrain- +## Arbeit). Rückgabe: +## objects_out: Indizes in doc["objects"] (Reihenfolge aufsteigend) +## parts_out: Indizes in doc["gebaeude"] +## spawns_out: Liste {"team": 0|1, "index": int, "id": String} je Punkt +## Die Größen werden wie in resize_terrain geclampt (256–4096 m), damit +## Preview und Apply garantiert dasselbe Gebiet meinen. +static func resize_preview(doc: Dictionary, new_size_x_m: float, + new_size_z_m: float) -> Dictionary: + var half_x := clampf(new_size_x_m, MIN_SIZE_M, MAX_SIZE_M) * 0.5 + var half_z := clampf(new_size_z_m, MIN_SIZE_M, MAX_SIZE_M) * 0.5 + var eps := 0.0001 + + var objects_out: Array = [] + var objects: Array = doc.get("objects", []) + for i in objects.size(): + var obj = objects[i] + if not (obj is Dictionary): + continue + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() < 3: + continue + if not _xz_in_bounds(float(pos[0]), float(pos[2]), half_x, half_z, eps): + objects_out.append(i) + + var parts_out: Array = [] + var pieces: Array = doc.get("gebaeude", []) + for i in pieces.size(): + var teil = pieces[i] + if not (teil is Dictionary): + continue + var pos: Array = teil.get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() < 3: + continue + var punkte = teil.get("punkte", []) + var inside := false + if not (punkte is Array) or (punkte as Array).is_empty(): + # Kein Fußabdruck bekannt → Anker-Punkt entscheidet. + inside = _xz_in_bounds(float(pos[0]), float(pos[2]), + half_x, half_z, eps) + else: + var rot: float = deg_to_rad(float(teil.get("rot", 0.0))) + var c: float = cos(rot) + var s: float = sin(rot) + for p in punkte: + if not (p is Array) or (p as Array).size() < 3: + continue + # Lokaler Fußabdruck-Punkt → Welt (Rotation um +Y, Godot-Basis): + # x' = x·cos + z·sin, z' = −x·sin + z·cos. + var wx: float = float(pos[0]) + float(p[0]) * c + float(p[2]) * s + var wz: float = float(pos[2]) - float(p[0]) * s + float(p[2]) * c + if _xz_in_bounds(wx, wz, half_x, half_z, eps): + inside = true + break + if not inside: + parts_out.append(i) + + var spawns_out: Array = [] + var zones = doc.get("spawn_zones", {}) + if zones is Dictionary: + for team_key in (zones as Dictionary).keys(): + var zone = zones[team_key] + if not (zone is Dictionary): + continue + var points = (zone as Dictionary).get("points", []) + if not (points is Array): + continue + for pi in (points as Array).size(): + var pt = points[pi] + if not (pt is Dictionary): + continue + var pos: Array = pt.get("pos", []) + if (pos as Array).size() < 3: + continue + if not _xz_in_bounds(float(pos[0]), float(pos[2]), + half_x, half_z, eps): + spawns_out.append({ + "team": int(team_key), + "index": pi, + "id": str(pt.get("id", "")), + }) + return { + "objects_out": objects_out, + "parts_out": parts_out, + "spawns_out": spawns_out, + } + + +## Resize anwenden (Terrain resampeln via resize_terrain) und optional die +## Out-of-Bounds-Einträge ENTFERNEN. drop_outside=false → Ergebnis identisch +## zu resize_terrain (reine Preview-Nutzung im Dialog). drop_outside=true → +## die in resize_preview gelisteten Objekte/Bauteile/Spawn-Punkte werden aus +## dem ERGEBNIS entfernt (der Eingabe-doc bleibt auch dann unangetastet — +## Undo-Snapshot-Sicherheit wie in R1). +static func resize_apply(doc: Dictionary, new_size_x_m: float, + new_size_z_m: float, drop_outside: bool) -> Dictionary: + var result := resize_terrain(doc, new_size_x_m, new_size_z_m) + if not drop_outside: + return result + var preview := resize_preview(doc, new_size_x_m, new_size_z_m) + + # Absteigend löschen, damit die Indizes der Preview stabil bleiben. + var out_idx: Array = (preview["objects_out"] as Array).duplicate() + out_idx.sort() + out_idx.reverse() + var objects: Array = result.get("objects", []) + for i in out_idx: + if int(i) < objects.size(): + objects.remove_at(int(i)) + + var parts_idx: Array = (preview["parts_out"] as Array).duplicate() + parts_idx.sort() + parts_idx.reverse() + var gebaeude: Array = result.get("gebaeude", []) + for i in parts_idx: + if int(i) < gebaeude.size(): + gebaeude.remove_at(int(i)) + + # Spawn-Punkte je Team rausfiltern (Struktur bleibt: Teams ohne Punkte + # bleiben als Zone-Block erhalten — der Spawn-Editor füllt sie wieder). + var spawns_out: Array = preview["spawns_out"] + var per_team := {0: {}, 1: {}} # team → {punkt-index: true} + for entry in spawns_out: + if entry is Dictionary: + per_team[int(entry.get("team", -1))][int(entry.get("index", -1))] = true + var zones = result.get("spawn_zones", {}) + if zones is Dictionary: + for team_key in (zones as Dictionary).keys(): + var zone = zones[team_key] + if not (zone is Dictionary): + continue + var points = (zone as Dictionary).get("points", []) + if not (points is Array): + continue + var drop: Dictionary = per_team.get(int(team_key), {}) + if drop.is_empty(): + continue + var kept: Array = [] + for pi in (points as Array).size(): + if not drop.has(pi): + kept.append(points[pi]) + zone["points"] = kept + return result diff --git a/tests/test_terrain.gd b/tests/test_terrain.gd index dc17df0..60d4b96 100644 --- a/tests/test_terrain.gd +++ b/tests/test_terrain.gd @@ -53,6 +53,7 @@ func _run_tests() -> void: _test_relief_shading() _test_grid_toggle() _test_resize_grow() + _test_resize_shrink() # _test_ray_terrain_mapping läuft in _finish() — braucht Viewport im Baum. @@ -1029,3 +1030,174 @@ func _test_resize_grow() -> void: _check(absf(float(clamped["terrain"]["size_x_m"]) - SvmapDocument.MIN_SIZE_M) < 0.0001 and absf(float(clamped["terrain"]["size_z_m"]) - SvmapDocument.MAX_SIZE_M) < 0.0001, "Clamp: 10 m→256 m, 100000 m→4096 m") + + +## R2 (t_27ba6c37): Verkleinern 512→256 m + Out-of-Bounds-Liste + Filter. +## Semantik „Anker Mitte": Terrain behält Weltposition, Heights/Material +## außerhalb fliegen weg. Objekte/Bauteile/Spawns bleiben in resize_terrain +## unangetastet — resize_preview listet, was komplett außerhalb läge, +## resize_apply(drop_outside=true) entfernt genau diese Einträge. +func _test_resize_shrink() -> void: + print("Testgruppe: Resize Verkleinern + Out-of-Bounds-Liste + Filter (R2)") + var doc := SvmapDocument.create_new("Shrink", 512.0, "mittag") + var terrain: Dictionary = doc["terrain"] + var heights := SvmapIO.decode_heights(terrain) + # Geformtes Terrain: Hügel nahe der künftigen +X-Kante (bleibt drin), + # Senke weit draußen bei (-200, 150) — ihr Inhalt fällt beim Shrink weg. + TerrainField.apply_brush(heights, 129, 512.0, 512.0, -100000.0, 100000.0, + 100.0, 0.0, 40.0, TerrainField.Mode.RAISE, 18.0) + TerrainField.apply_brush(heights, 129, 512.0, 512.0, -100000.0, 100000.0, + -200.0, 150.0, 30.0, TerrainField.Mode.LOWER, 9.0) + terrain["min_height_m"] = floorf(TerrainField._min_h(heights) - 1.0) + terrain["max_height_m"] = ceilf(TerrainField._max_h(heights) + 1.0) + terrain["heightmap_r16_b64"] = TerrainField.encode(heights, + float(terrain["min_height_m"]), float(terrain["max_height_m"])) + var mats := SvmapIO.decode_materials(terrain) + TerrainField.apply_material_brush(mats, 129, 512.0, 512.0, 100.0, 0.0, 40.0, 1) + terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats) + + # Objekte drin/draußen gemischt (eins EXAKT auf der Kante = drin, eps). + doc["objects"] = [ + {"id": "crate_wood", "pos": [50.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "crate_wood", "pos": [128.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "crate_wood", "pos": [200.0, 0.0, -80.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "crate_wood", "pos": [-300.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}, + ] + # Bauteile: Wand mit Fußabdruck ÜBER der Kante (Punkte 127→129 m) = drin, + # Fundament komplett draußen, gedrehte Wand über der -Z-Kante = drin. + doc["gebaeude"] = [ + BuildOps.make_piece("wand", Vector3(127.0, 0.0, 10.0), 0.0), + BuildOps.make_piece("fundament", Vector3(150.0, 0.0, -50.0), 0.0), + BuildOps.make_piece("wand", Vector3(0.0, 0.0, -127.0), 90.0), + ] + # Spawn-Punkte drin/draußen gemischt (Default-Zonen von 512 m lägen alle + # bei ±204,8 m draußen — hier bewusst gemischt, damit nach dem Filter + # beide Teams ≥ 4 Punkte behalten und die Map speicherbar bleibt, V11). + doc["spawn_zones"] = { + "0": {"label": "Team Blau", "points": [ + {"id": "A1", "pos": [100.0, 0.2, 0.0]}, + {"id": "A2", "pos": [300.0, 0.2, 0.0]}, + {"id": "A3", "pos": [60.0, 0.2, 40.0]}, + {"id": "A4", "pos": [-100.0, 0.2, -60.0]}, + {"id": "A5", "pos": [20.0, 0.2, -80.0]}, + ]}, + "1": {"label": "Team Rot", "points": [ + {"id": "B1", "pos": [-204.8, 0.2, 0.0]}, + {"id": "B2", "pos": [0.0, 0.2, -100.0]}, + {"id": "B3", "pos": [40.0, 0.2, 80.0]}, + {"id": "B4", "pos": [-50.0, 0.2, 100.0]}, + {"id": "B5", "pos": [-20.0, 0.2, -80.0]}, + ]}, + } + var doc_json_before := JSON.stringify(doc) + + # --- resize_preview: Liste je Kategorie, doc bleibt unangetastet --------- + var preview := SvmapDocument.resize_preview(doc, 256.0, 256.0) + _check(str(doc_json_before) == JSON.stringify(doc), + "Preview: reine Funktion, Eingabe-doc unangetastet") + var objects_out: Array = preview["objects_out"] + _check(objects_out.size() == 2 and int(objects_out[0]) == 2 + and int(objects_out[1]) == 3, + "Preview objects_out: Indizes 2+3 draußen, Kante-Objekt (128 m) drin") + var parts_out: Array = preview["parts_out"] + _check(parts_out.size() == 1 and int(parts_out[0]) == 1, + "Preview parts_out: nur Fundament draußen, Rand-überlappende Wände drin") + var spawns_out: Array = preview["spawns_out"] + var spawn_keys := [] + for entry in spawns_out: + spawn_keys.append("%d:%s" % [int(entry["team"]), str(entry["id"])]) + spawn_keys.sort() + _check(spawn_keys == ["0:A2", "1:B1"], + "Preview spawns_out: A2 (300 m) + B1 (-204,8 m) draußen") + var grow_preview := SvmapDocument.resize_preview(doc, 1024.0, 1024.0) + _check((grow_preview["objects_out"] as Array).is_empty() + and (grow_preview["parts_out"] as Array).is_empty() + and (grow_preview["spawns_out"] as Array).is_empty(), + "Preview beim Vergrößern: nichts draußen") + var clamp_preview := SvmapDocument.resize_preview(doc, 100.0, 512.0) + _check((clamp_preview["objects_out"] as Array).size() == 2, + "Preview clamp: 100 m→256 m zählt wie 256 m") + + # --- resize_apply ohne Filter: wie resize_terrain ------------------------ + var kept := SvmapDocument.resize_apply(doc, 256.0, 256.0, false) + _check((kept["objects"] as Array).size() == 4 + and (kept["gebaeude"] as Array).size() == 3 + and (kept["spawn_zones"]["0"]["points"] as Array).size() == 5, + "Apply ohne Filter: Objekte/Bauteile/Spawns unverändert übernommen") + _check(absf(float(kept["terrain"]["size_x_m"]) - 256.0) < 0.0001 + and absf(float(kept["terrain"]["size_z_m"]) - 256.0) < 0.0001 + and int(kept["terrain"]["resolution"]) == 129, + "Shrink 512→256 m: Größe ok, Dichte 2 m/Punkt hält res 129") + + # Drin bleibt 1:1: alle ALTEN Gridpunkte innerhalb des neuen Gebiets + # (Vierer-Raster = exakte Knoten) stimmen ±0.05 m (R16-Quantisierung). + var old_res := 129 + var old_sx := 512.0 + var old_sz := 512.0 + var new_doc := SvmapDocument.resize_apply(doc, 256.0, 256.0, true) + var max_err := 0.0 + var world_kept := true + for zi in old_res: + for xi in old_res: + var x := (float(xi) / float(old_res - 1) - 0.5) * old_sx + var z := (float(zi) / float(old_res - 1) - 0.5) * old_sz + if absf(x) > 124.0 or absf(z) > 124.0: + continue + var expected := heights[zi * old_res + xi] + var actual := SvmapDocument.terrain_height_at(new_doc, x, z) + var err := absf(actual - expected) + if err > max_err: + max_err = err + if err > 0.05: + world_kept = false + _check(world_kept, + "Anker Mitte beim Shrink: alter Grid drin ±0.05 m (max %.4f m)" % max_err) + _check(absf(SvmapDocument.terrain_height_at(new_doc, 100.0, 0.0) + - SvmapDocument.terrain_height_at(doc, 100.0, 0.0)) < 0.05, + "Hügel an der künftigen Kante (100 m) an Weltposition erhalten") + # Terrain außerhalb fliegt weg: der Hügel ragt über die neue Kante hinaus + # (RAISE bei 100 m, Radius 40) — der neue Rand trägt die ALTE Höhe an + # derselben Weltposition (Anker Mitte), ist aber NICHT flach abgeschnitten. + _check(absf(SvmapDocument.terrain_height_at(new_doc, 128.0, 0.0) + - SvmapDocument.terrain_height_at(doc, 128.0, 0.0)) < 0.05 + and SvmapDocument.terrain_height_at(new_doc, 128.0, 0.0) + > float(new_doc["terrain"]["min_height_m"]) + 0.5, + "Kante beim Shrink: neuer Rand = alte Höhe an Weltposition (kein flacher Schnitt)") + + # --- Filter: genau die gelisteten Einträge raus, Rest 1:1 ---------------- + var filtered_objects: Array = new_doc["objects"] + _check(filtered_objects.size() == 2 + and str(filtered_objects[0]["pos"][0]) == "50.0" + and absf(float(filtered_objects[1]["pos"][0]) - 128.0) < 0.0001, + "Filter: 2 Objekte raus, drin bleibt 1:1 (Position unangetastet)") + var filtered_parts: Array = new_doc["gebaeude"] + _check(filtered_parts.size() == 2 + and str(filtered_parts[0]["id"]) == "wand" + and absf(float(filtered_parts[0]["pos"][0]) - 127.0) < 0.0001 + and absf(float(filtered_parts[1]["rot"]) - 90.0) < 0.0001, + "Filter: Fundament raus, Rand-Wände bleiben (kein Clampen)") + var zones: Dictionary = new_doc["spawn_zones"] + _check((zones["0"]["points"] as Array).size() == 4 + and (zones["1"]["points"] as Array).size() == 4 + and str(zones["0"]["label"]) == "Team Blau", + "Filter: A2/B1 raus, Team-Struktur bleibt (je 4 Punkte, V11)") + # Reihenfolge/IDs der verbleibenden Spawn-Punkte unangetastet. + var ids0 := [] + for p in zones["0"]["points"]: + ids0.append(str(p["id"])) + _check(ids0 == ["A1", "A3", "A4", "A5"], + "Filter: Spawn-Punkte ohne Umnummerierung entfernt (A2 fehlt)") + + # --- Roundtrip: gefiltertes Shrink-Ergebnis speichert + lädt ------------- + var path := "/tmp/scopedvr_test_terrain_shrink.svmap" + var save := SvmapIO.save_to_file(path, new_doc) + _check(save["ok"], "Gefiltertes Shrink-Ergebnis speichert valide (V1–V14)") + var load := SvmapIO.load_from_file(path) + _check(load["ok"], "Gefiltertes Shrink-Ergebnis lädt mit gültiger Prüfsumme") + if load["ok"]: + _check(str(load["data"]["terrain"]["heightmap_r16_b64"]) + == str(new_doc["terrain"]["heightmap_r16_b64"]), + "Shrink-Roundtrip: Höhenfeld byte-identisch") + _check((load["data"]["objects"] as Array).size() == 2 + and (load["data"]["gebaeude"] as Array).size() == 2, + "Shrink-Roundtrip: gefilterte Objekte/Bauteile bleiben gefiltert")