M3 (t_34908425): Objekte spiegeln — Platzieren/Verschieben/Drehen/Löschen/Duplizieren inkl. Partner-Bindung (Spec vc27). ObjectOps: symmetry_place_twin/symmetry_sync_pairs/symmetry_expand_delete (Mathe aus SymmetryCore M1, keine zweite Spiegel-Mathe; Mitte/Achse nicht gedoppelt). interaction: Zwilling in ALLE Objekt-Pfade integriert (Platzieren, Drehen, Löschen, Duplizieren, Pfeiltasten, Ziehen live+Commit, Greifen live+Esc-Rückabwicklung, Spiegeln-Button) — jede Änderung = EIN Undo-Schritt. map_view_builder: schwächere Partner-Markierung über die bestehende Auswahl-Material-Logik (keine zweite Technik). Neu: tests/test_object_mirror.gd (74 checks, alle 3 Modi + Gegenprobe AUS), tests/probe_m3_partner_render.gd (Beweis-Bild).

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-20 06:48:27 +00:00
parent c15c606311
commit 238ca7bb2f
6 changed files with 1001 additions and 11 deletions

View file

@ -83,6 +83,10 @@ var _grab_primary := -1
var _grab_originals: Dictionary = {} # idx (int) → [x, y, z] exakt (Rückabwicklung) var _grab_originals: Dictionary = {} # idx (int) → [x, y, z] exakt (Rückabwicklung)
var _grab_lifts: Dictionary = {} # idx (int) → Terrain-Abstand beim Anfassen var _grab_lifts: Dictionary = {} # idx (int) → Terrain-Abstand beim Anfassen
var _grab_before: Dictionary = {} # History-Snapshot VOR dem Greifen var _grab_before: Dictionary = {} # History-Snapshot VOR dem Greifen
# Symmetrie-Zwillinge beim Greifen (M3, t_34908425): Mitglied → Partner-Index;
# Originale der Partner, die NICHT selbst greifen (für die Esc-Rückabwicklung).
var _grab_twin_map: Dictionary = {} # idx (int) → partner index (int)
var _grab_twin_originals: Dictionary = {} # partner idx (int) → [x, y, z] exakt
func setup(p_main, p_camera: Camera3D, p_builder) -> void: func setup(p_main, p_camera: Camera3D, p_builder) -> void:
@ -92,6 +96,13 @@ func setup(p_main, p_camera: Camera3D, p_builder) -> void:
refresh_terrain_cache() # t_4092e3a0: Cursor trifft das Gelände ab der ersten Mausbewegung refresh_terrain_cache() # t_4092e3a0: Cursor trifft das Gelände ab der ersten Mausbewegung
## Symmetrie-Modus aus dem Hauptfenster lesen (M3, t_34908425). Fehlt das Feld
## (ältere Test-Doppel, Setup-Reihenfolgen), gilt Aus — nie ein harter Fehler.
func _sym_mode() -> int:
var v: Variant = main.get("sym_mode") if main != null else null
return int(v) if v is int else SymmetryCore.Mode.OFF
## Kamera fragt: dreht das Mausrad gerade ein Objekt/eine Vorschau ## Kamera fragt: dreht das Mausrad gerade ein Objekt/eine Vorschau
## (dann zoomt das Rad NICHT)? Im Freelook-Flug (t_c46d31c2) blockt das Rad ## (dann zoomt das Rad NICHT)? Im Freelook-Flug (t_c46d31c2) blockt das Rad
## zusätzlich ALLE Objekt-Reaktionen — dort ändert es die Grundgeschwindigkeit. ## zusätzlich ALLE Objekt-Reaktionen — dort ändert es die Grundgeschwindigkeit.
@ -287,12 +298,20 @@ func _place_object(viewport_pos: Vector2) -> void:
return return
# Poly-Budget prüfen, BEVOR etwas passiert (hilfreiche Meldung statt Fehler). # Poly-Budget prüfen, BEVOR etwas passiert (hilfreiche Meldung statt Fehler).
# Symmetrie-Modus (M3, t_34908425): der Zwilling kostet Dasselbe nochmal —
# außer das Objekt liegt auf der Mitte/Achse (wird nicht gedoppelt).
var sym_mode: int = _sym_mode()
var sym_center := SymmetryCore.map_center(main.doc)
var sym_factor := 1.0
if sym_mode != SymmetryCore.Mode.OFF \
and not SymmetryCore.maps_to_self(sym_mode, foot, sym_center):
sym_factor = 2.0
var lib: Dictionary = main.manifest.get("objects", {}) var lib: Dictionary = main.manifest.get("objects", {})
var entry: Dictionary = lib.get(place_oid, {}) var entry: Dictionary = lib.get(place_oid, {})
var poly := float(entry.get("poly", 0)) var poly := float(entry.get("poly", 0))
var used := SvmapDocument.poly_usage(main.doc, main.manifest) var used := SvmapDocument.poly_usage(main.doc, main.manifest)
var budget := float(SvmapIO.MAX_TOTAL_POLY) var budget := float(SvmapIO.MAX_TOTAL_POLY)
if used + poly > budget: if used + poly * sym_factor > budget:
main.call("_set_status", main.call("_set_status",
"Kein Platz mehr im Poly-Budget (%s von %s belegt) — lass ein Objekt weg oder drück Strg+Z, um etwas zurückzunehmen." "Kein Platz mehr im Poly-Budget (%s von %s belegt) — lass ein Objekt weg oder drück Strg+Z, um etwas zurückzunehmen."
% [main._fmt_int(int(used)), main._fmt_int(int(budget))], true) % [main._fmt_int(int(used)), main._fmt_int(int(budget))], true)
@ -308,10 +327,23 @@ func _place_object(viewport_pos: Vector2) -> void:
"scale": 1.0, "scale": 1.0,
"rot_y_deg": float(place_rot_deg), "rot_y_deg": float(place_rot_deg),
}) })
var new_index: int = (main.doc["objects"] as Array).size() - 1
# Symmetrie (M3, t_34908425): der Zwilling wandert in denselben Undo-Schritt
# (ein Commit für Quelle + Zwilling) und bekommt dieselbe sym-Kennung.
var twin_index := -1
var on_axis := false
if sym_mode != SymmetryCore.Mode.OFF:
var symres: Dictionary = ObjectOps.symmetry_place_twin(main.doc, new_index, sym_mode)
twin_index = int(symres.get("twin", -1))
on_axis = bool(symres.get("on_axis", false))
main.history.commit("%s platzieren" % main.object_label(place_oid), main.history.commit("%s platzieren" % main.object_label(place_oid),
before, main.history.snapshot(["objects"])) before, main.history.snapshot(["objects"]))
var new_index: int = (main.doc["objects"] as Array).size() - 1
main.call("_after_structure_change") main.call("_after_structure_change")
if on_axis:
main.call("_set_status", "%s platziert — liegt auf der Symmetrie-Mitte und wird NICHT gedoppelt. Tipp: anklicken zum Verschieben (Ziehen) und Drehen (R oder Mausrad)." % main.object_label(place_oid))
elif twin_index >= 0:
main.call("_set_status", "%s platziert — der Zwilling auf der anderen Seite ist automatisch mitgesetzt (Auswahl zeigt ihn schwächer markiert). Strg+Z nimmt BEIDE zurück." % main.object_label(place_oid))
else:
main.call("_set_status", "%s platziert. Tipp: anklicken zum Verschieben (Ziehen) und Drehen (R oder Mausrad) — Entf löscht es." % main.object_label(place_oid)) main.call("_set_status", "%s platziert. Tipp: anklicken zum Verschieben (Ziehen) und Drehen (R oder Mausrad) — Entf löscht es." % main.object_label(place_oid))
# Platzier-Modus bleibt aktiv (Mehrfach-Platzieren ohne erneutes Klicken), # Platzier-Modus bleibt aktiv (Mehrfach-Platzieren ohne erneutes Klicken),
# Auswahl auf das frische Objekt legen? Nein — Ghost bleibt Vorrang; ESC beendet. # Auswahl auf das frische Objekt legen? Nein — Ghost bleibt Vorrang; ESC beendet.
@ -443,6 +475,11 @@ func rotate_selected(delta_deg: float) -> void:
var before: Dictionary = main.history.snapshot(["objects"]) var before: Dictionary = main.history.snapshot(["objects"])
GroupOps.rotate_members(main.doc, members, delta_deg) GroupOps.rotate_members(main.doc, members, delta_deg)
_sync_members_to_markers(members) _sync_members_to_markers(members)
# Symmetrie (M3, t_34908425): Zwillinge der gedrehten Auswahl auf den exakten
# Spiegel setzen (Punkt: +180°-Basis, Achse: Vorzeichen) — IM SELBEN Commit.
var twins: Array = ObjectOps.symmetry_sync_pairs(
main.doc, members, _sym_mode(), selected_index)
_sync_members_to_markers(twins)
var tag := "rotmulti:%s" % str(members) var tag := "rotmulti:%s" % str(members)
main.history.commit("Auswahl gedreht (%d Objekte)" % members.size(), main.history.commit("Auswahl gedreht (%d Objekte)" % members.size(),
before, main.history.snapshot(["objects"]), tag) before, main.history.snapshot(["objects"]), tag)
@ -478,6 +515,9 @@ func delete_selected() -> void:
if doomed.is_empty(): if doomed.is_empty():
clear_selection(true) clear_selection(true)
return return
# Symmetrie (M3, t_34908425): die Partner der Auswahl löschen sich MIT —
# ein Paar lebt oder stirbt zusammen, IM SELBEN Undo-Schritt.
doomed = ObjectOps.symmetry_expand_delete(main.doc, doomed, _sym_mode())
var before: Dictionary = main.history.snapshot(["objects"]) var before: Dictionary = main.history.snapshot(["objects"])
var label: String var label: String
if doomed.size() == 1: if doomed.size() == 1:
@ -514,16 +554,31 @@ func duplicate_selected() -> void:
if sources.is_empty(): if sources.is_empty():
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du kopieren willst.", true) main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du kopieren willst.", true)
return return
if objects.size() + sources.size() > SvmapIO.MAX_OBJECTS: if objects.size() + sources.size() > SvmapIO.MAX_OBJECTS \
and _sym_mode() == SymmetryCore.Mode.OFF:
main.call("_set_status", "Limit erreicht: In eine Map passen maximal %d Objekte — das Löschen von Objekten macht wieder Platz." % SvmapIO.MAX_OBJECTS, true) main.call("_set_status", "Limit erreicht: In eine Map passen maximal %d Objekte — das Löschen von Objekten macht wieder Platz." % SvmapIO.MAX_OBJECTS, true)
return return
var lib: Dictionary = main.manifest.get("objects", {}) var lib: Dictionary = main.manifest.get("objects", {})
var sym_mode: int = _sym_mode()
var sym_center := SymmetryCore.map_center(main.doc)
var twins_planned := 0
var poly_sum := 0.0 var poly_sum := 0.0
for i in sources: for i in sources:
var src_obj: Dictionary = objects[i] var src_obj: Dictionary = objects[i]
var scl := float(src_obj.get("scale", 1.0)) var scl := float(src_obj.get("scale", 1.0))
var entry: Dictionary = lib.get(str(src_obj.get("id", "")), {}) var entry: Dictionary = lib.get(str(src_obj.get("id", "")), {})
poly_sum += float(entry.get("poly", 0)) * scl * scl poly_sum += float(entry.get("poly", 0)) * scl * scl
# Symmetrie (M3, t_34908425): JEDE Kopie bekommt einen Zwilling (außer
# auf der Mitte/Achse) — Budget und Objekt-Limit VORAB mitrechnen.
if sym_mode != SymmetryCore.Mode.OFF:
var spos: Array = src_obj.get("pos", [0.0, 0.0, 0.0])
if (spos as Array).size() == 3 and not SymmetryCore.maps_to_self(
sym_mode, Vector3(float(spos[0]), float(spos[1]), float(spos[2])), sym_center):
twins_planned += 1
poly_sum += float(entry.get("poly", 0)) * scl * scl
if twins_planned > 0 and objects.size() + sources.size() + twins_planned > SvmapIO.MAX_OBJECTS:
main.call("_set_status", "Limit erreicht: In eine Map passen maximal %d Objekte — mit Symmetrie-Zwilllingen bräuchte es %d. Das Löschen von Objekten macht wieder Platz." % [SvmapIO.MAX_OBJECTS, objects.size() + sources.size() + twins_planned], true)
return
var used := SvmapDocument.poly_usage(main.doc, main.manifest) var used := SvmapDocument.poly_usage(main.doc, main.manifest)
var budget := float(SvmapIO.MAX_TOTAL_POLY) var budget := float(SvmapIO.MAX_TOTAL_POLY)
if used + poly_sum > budget: if used + poly_sum > budget:
@ -536,6 +591,15 @@ func duplicate_selected() -> void:
if new_indices.is_empty(): if new_indices.is_empty():
clear_selection(true) clear_selection(true)
return return
# Symmetrie (M3, t_34908425): jede Kopie bekommt ihren Zwilling (exakter
# Spiegel der KOPIE, neue sym-Kennung) — im selben Commit wie die Kopien.
var sym_twins: Array = []
if sym_mode != SymmetryCore.Mode.OFF:
for c in new_indices:
if c is int:
var sres: Dictionary = ObjectOps.symmetry_place_twin(main.doc, c, sym_mode)
if int(sres.get("twin", -1)) >= 0:
sym_twins.append(int(sres["twin"]))
var label: String var label: String
if new_indices.size() == 1: if new_indices.size() == 1:
label = "%s duplizieren" % main.object_label(str((objects[sources[0]] as Dictionary).get("id", ""))) label = "%s duplizieren" % main.object_label(str((objects[sources[0]] as Dictionary).get("id", "")))
@ -543,9 +607,15 @@ func duplicate_selected() -> void:
label = "%d Objekte duplizieren" % new_indices.size() label = "%d Objekte duplizieren" % new_indices.size()
main.history.commit(label, before, main.history.snapshot(["objects"])) main.history.commit(label, before, main.history.snapshot(["objects"]))
main.call("_after_structure_change") main.call("_after_structure_change")
# Kopien direkt anwählen — Sly kann sie gleich weiterziehen. # Kopien direkt anwählen — Sly kann sie gleich weiterziehen. Die frischen
select_many(new_indices) # Zwillinge wandern mit in die Auswahl (beide Seiten sichtbar markiert).
var selected_new: Array = new_indices.duplicate()
selected_new.append_array(sym_twins)
select_many(selected_new)
if sym_twins.is_empty():
main.call("_set_status", "%s: die Kopie(n) liegen +2 m versetzt und sind direkt angewählt — Ziehen verschiebt, Entf löscht, Strg+Z nimmt ALLE Kopien auf einmal zurück." % label) main.call("_set_status", "%s: die Kopie(n) liegen +2 m versetzt und sind direkt angewählt — Ziehen verschiebt, Entf löscht, Strg+Z nimmt ALLE Kopien auf einmal zurück." % label)
else:
main.call("_set_status", "%s: %d Kopie(n) + %d Zwilling(e) sind direkt angewählt — jede Kopie bildet mit ihrem Zwilling ein neues Paar. Strg+Z nimmt ALLE auf einmal zurück." % [label, new_indices.size(), sym_twins.size()])
## ▲/▼ / Strg+Bild↑/↓: AUSWAHL in 0,25-m-Schritten anheben/absenken ## ▲/▼ / Strg+Bild↑/↓: AUSWAHL in 0,25-m-Schritten anheben/absenken
@ -670,6 +740,11 @@ func nudge_selection(delta: Vector3) -> void:
if changed == 0: if changed == 0:
return # am Map-Rand geklemmt — Snapshot verwerfen, kein Commit return # am Map-Rand geklemmt — Snapshot verwerfen, kein Commit
_sync_members_to_markers(members) _sync_members_to_markers(members)
# Symmetrie (M3, t_34908425): Zwillinge mitführen — exakter Spiegel der
# verschobenen Quelle, im selben Commit (Pfeiltasten = 1 Undo-Schritt).
var twins: Array = ObjectOps.symmetry_sync_pairs(
main.doc, members, _sym_mode())
_sync_members_to_markers(twins)
var desc := "Objekt verschoben (Pfeiltaste)" if members.size() == 1 \ var desc := "Objekt verschoben (Pfeiltaste)" if members.size() == 1 \
else "%d Objekte verschoben (Pfeiltasten)" % members.size() else "%d Objekte verschoben (Pfeiltasten)" % members.size()
main.history.commit(desc, before, main.history.snapshot(["objects"])) main.history.commit(desc, before, main.history.snapshot(["objects"]))
@ -713,6 +788,25 @@ func grab_begin() -> bool:
_grab_originals[i] = [float(pos[0]), float(pos[1]), float(pos[2])] _grab_originals[i] = [float(pos[0]), float(pos[1]), float(pos[2])]
_grab_lifts[i] = float(pos[1]) - SvmapDocument.terrain_height_at( _grab_lifts[i] = float(pos[1]) - SvmapDocument.terrain_height_at(
main.doc, float(pos[0]), float(pos[2])) main.doc, float(pos[0]), float(pos[2]))
# Symmetrie (M3, t_34908425): Zwillinge der Greif-Menge merken — sie ziehen
# live mit (exakter Spiegel) und Esc setzt sie EXAKT zurück.
_grab_twin_map = {}
_grab_twin_originals = {}
if _sym_mode() != SymmetryCore.Mode.OFF:
for i in members:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var sym_key := SymmetryCore.sym_of(objects[i] as Dictionary)
if sym_key.is_empty():
continue
var t := SymmetryCore.find_partner_index(objects, sym_key, i)
if t < 0:
continue
_grab_twin_map[i] = t
if not members.has(t) and not _grab_twin_originals.has(t):
var tpos: Array = (objects[t] as Dictionary).get("pos", [0.0, 0.0, 0.0])
if (tpos as Array).size() == 3:
_grab_twin_originals[t] = [float(tpos[0]), float(tpos[1]), float(tpos[2])]
_grabbing = true _grabbing = true
_grab_before = main.history.snapshot(["objects"]) _grab_before = main.history.snapshot(["objects"])
main.call("_set_status", "GREIFEN an: die Auswahl hängt an der Maus — Linksklick setzt sie neu (mit Raster-Snap), Esc bricht ab und setzt zurück.") main.call("_set_status", "GREIFEN an: die Auswahl hängt an der Maus — Linksklick setzt sie neu (mit Raster-Snap), Esc bricht ab und setzt zurück.")
@ -741,6 +835,9 @@ func grab_move(hit: Vector3) -> void:
var marker: Node3D = view_builder.object_markers[i] var marker: Node3D = view_builder.object_markers[i]
if marker != null and is_instance_valid(marker): if marker != null and is_instance_valid(marker):
marker.position = Vector3(nx, ny, nz) marker.position = Vector3(nx, ny, nz)
# Symmetrie (M3, t_34908425): Zwillinge live mitziehen — doc + Marker,
# exakter Spiegel der gegriffenen Quelle.
_sym_grab_twin_move()
## Linksklick im Greifen: Positionen festschreiben — EIN Undo-Schritt. ## Linksklick im Greifen: Positionen festschreiben — EIN Undo-Schritt.
@ -755,6 +852,8 @@ func grab_confirm() -> void:
_grab_originals = {} _grab_originals = {}
_grab_lifts = {} _grab_lifts = {}
_grab_before = {} _grab_before = {}
_grab_twin_map = {}
_grab_twin_originals = {}
_mark_dirty() _mark_dirty()
main.call("_refresh_history_buttons") main.call("_refresh_history_buttons")
main.call("_refresh_stats") main.call("_refresh_stats")
@ -776,13 +875,101 @@ func grab_cancel() -> void:
var marker: Node3D = view_builder.object_markers[i] var marker: Node3D = view_builder.object_markers[i]
if marker != null and is_instance_valid(marker): if marker != null and is_instance_valid(marker):
marker.position = Vector3(float(orig[0]), float(orig[1]), float(orig[2])) marker.position = Vector3(float(orig[0]), float(orig[1]), float(orig[2]))
# Symmetrie (M3, t_34908425): die MITgezogenen Zwillinge exakt zurücksetzen.
for t in _grab_twin_originals:
var torig: Array = _grab_twin_originals[t]
(objects[t] as Dictionary)["pos"] = [float(torig[0]), float(torig[1]), float(torig[2])]
if t < view_builder.object_markers.size():
var tmarker: Node3D = view_builder.object_markers[t]
if tmarker != null and is_instance_valid(tmarker):
tmarker.position = Vector3(float(torig[0]), float(torig[1]), float(torig[2]))
_grab_members = [] _grab_members = []
_grab_originals = {} _grab_originals = {}
_grab_lifts = {} _grab_lifts = {}
_grab_before = {} _grab_before = {}
_grab_twin_map = {}
_grab_twin_originals = {}
main.call("_set_status", "Greifen abgebrochen — alles ist wieder, wie es war.") main.call("_set_status", "Greifen abgebrochen — alles ist wieder, wie es war.")
## Symmetrie (M3): Zwillinge beim GREIFEN live mitziehen — doc + Marker,
## exakter Spiegel der Quelle. Gehören BEIDE Seiten eines Paares zur Greif-
## Menge, ist die Quelle das Primär-Objekt (sonst der niedrigere Index).
func _sym_grab_twin_move() -> void:
var sym_mode: int = _sym_mode()
if sym_mode == SymmetryCore.Mode.OFF or _grab_twin_map.is_empty():
return
var objects: Array = main.doc.get("objects", [])
var center := SymmetryCore.map_center(main.doc)
var handled: Dictionary = {}
for i in _grab_members:
if not _grab_twin_map.has(i):
continue
var t: int = int(_grab_twin_map[i])
var pair_key := "%d|%d" % [mini(i, t), maxi(i, t)]
if handled.has(pair_key):
continue
handled[pair_key] = true
var src_i: int = i
if _grab_members.has(t):
if _grab_primary == t:
src_i = t
elif _grab_primary != i and t < i:
src_i = t
var twin_i: int = t if src_i == i else i
if src_i >= objects.size() or not (objects[src_i] is Dictionary):
continue
var spos: Array = (objects[src_i] as Dictionary).get("pos", [0.0, 0.0, 0.0])
if (spos as Array).size() != 3:
continue
var mp := SymmetryCore.mirror_point(sym_mode,
Vector3(float(spos[0]), float(spos[1]), float(spos[2])), center)
(objects[twin_i] as Dictionary)["pos"] = [mp.x, mp.y, mp.z]
if twin_i < view_builder.object_markers.size():
var tm: Node3D = view_builder.object_markers[twin_i]
if tm != null and is_instance_valid(tm):
tm.position = Vector3(mp.x, mp.y, mp.z)
## Symmetrie (M3): Zwillinge beim ZIEHEN live mitführen (nur Marker — das doc
## schreibt _commit_drag über symmetry_sync_pairs). Quelle = Primär-Objekt,
## wenn beide Seiten eines Paares in der Zieh-Menge sind, sonst niedrigerer Index.
func _sym_drag_sync_live() -> void:
var sym_mode: int = _sym_mode()
if sym_mode == SymmetryCore.Mode.OFF or view_builder == null:
return
var objects: Array = main.doc.get("objects", [])
var center := SymmetryCore.map_center(main.doc)
var handled: Dictionary = {}
for i in _drag_members:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var key := SymmetryCore.sym_of(objects[i] as Dictionary)
if key.is_empty():
continue
var t := SymmetryCore.find_partner_index(objects, key, i)
if t < 0:
continue
var pair_key := "%d|%d" % [mini(i, t), maxi(i, t)]
if handled.has(pair_key):
continue
handled[pair_key] = true
var src_i: int = i
if _drag_members.has(t):
if _drag_primary == t:
src_i = t
elif _drag_primary != i and t < i:
src_i = t
var twin_i: int = t if src_i == i else i
if src_i >= view_builder.object_markers.size() or twin_i >= view_builder.object_markers.size():
continue
var sm: Node3D = view_builder.object_markers[src_i]
var tm: Node3D = view_builder.object_markers[twin_i]
if sm == null or tm == null or not is_instance_valid(sm) or not is_instance_valid(tm):
continue
tm.position = SymmetryCore.mirror_point(sym_mode, sm.position, center)
## ⇄ Spiegeln (X: links↔rechts, Z: vor↔zurück) an der Stelle des Objekts — ## ⇄ Spiegeln (X: links↔rechts, Z: vor↔zurück) an der Stelle des Objekts —
@ -803,6 +990,11 @@ func mirror_selected(axis: String) -> void:
var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null
if marker != null and is_instance_valid(marker): if marker != null and is_instance_valid(marker):
marker.rotation.y = deg_to_rad(new_rot) marker.rotation.y = deg_to_rad(new_rot)
# Symmetrie (M3, t_34908425): der Zwilling dreht MIT (exakter Spiegel der
# neuen Blickrichtung) — im selben Commit wie die Quelle.
var twins: Array = ObjectOps.symmetry_sync_pairs(
main.doc, [selected_index], _sym_mode(), selected_index)
_sync_members_to_markers(twins)
var desc := "Objekt gespiegelt (links↔rechts)" if axis == "x" else "Objekt gespiegelt (vor↔zurück)" var desc := "Objekt gespiegelt (links↔rechts)" if axis == "x" else "Objekt gespiegelt (vor↔zurück)"
main.history.commit(desc, before, main.history.snapshot(["objects"])) main.history.commit(desc, before, main.history.snapshot(["objects"]))
main.dirty = true main.dirty = true
@ -1011,6 +1203,9 @@ func _drag_members_move(viewport_pos: Vector2) -> void:
np.y = SvmapDocument.terrain_height_at(main.doc, np.x, np.z) \ np.y = SvmapDocument.terrain_height_at(main.doc, np.x, np.z) \
+ float(_drag_lifts.get(i, 0.0)) + float(_drag_lifts.get(i, 0.0))
marker.position = np marker.position = np
# Symmetrie (M3, t_34908425): Zwillinge LIVE mitziehen (nur Marker —
# das doc schreibt _commit_drag), exakter Spiegel der gezogenen Quelle.
_sym_drag_sync_live()
func _on_left_release(mb: InputEventMouseButton) -> void: func _on_left_release(mb: InputEventMouseButton) -> void:
@ -1079,6 +1274,11 @@ func _commit_drag() -> void:
for i in moved: for i in moved:
var pos: Vector3 = (view_builder.object_markers[i] as Node3D).position var pos: Vector3 = (view_builder.object_markers[i] as Node3D).position
objects[i]["pos"] = [float(pos.x), float(pos.y), float(pos.z)] objects[i]["pos"] = [float(pos.x), float(pos.y), float(pos.z)]
# Symmetrie (M3, t_34908425): Zwillinge der gezogenen Auswahl auf den exakten
# Spiegel setzen — IM SELBEN Commit (ein Zug = EIN Undo-Schritt für beide).
var twins: Array = ObjectOps.symmetry_sync_pairs(
main.doc, moved, _sym_mode(), _drag_primary)
_sync_members_to_markers(twins)
var desc: String var desc: String
if _drag_is_group: if _drag_is_group:
desc = "Gruppe verschoben (%d Objekte)" % moved.size() desc = "Gruppe verschoben (%d Objekte)" % moved.size()

View file

@ -223,3 +223,126 @@ static func placement_foot(doc: Dictionary, hit: Vector3, grid_enabled: bool,
pos.z = clampf(pos.z, -half.y, half.y) pos.z = clampf(pos.z, -half.y, half.y)
pos.y = SvmapDocument.terrain_height_at(doc, pos.x, pos.z) pos.y = SvmapDocument.terrain_height_at(doc, pos.x, pos.z)
return pos return pos
# --------------------------------------------- Symmetrie — Objekte (M3) -----
# Karte t_34908425 (Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md): im
# Symmetrie-Modus wirkt jede Objekt-Änderung auf beide Seiten. Die Transform-
# Mathe und das sym-Feld kommen aus SymmetryCore (M1, t_f5935023) — hier nur
# die Objekt-Anwendung, KEINE zweite Spiegel-Mathe. Alles static + pure
# (Dokument rein, Mutation im Dokument), headless testbar via
# tests/test_object_mirror.gd. Modus OFF = no-op (Verhalten wie bisher).
#
# Partner-Regel (invariant): der Zwilling ist IMMER der exakte Spiegel seiner
# Quelle — Position via SymmetryCore.mirror_point (y bleibt 1:1, Spec-Tabelle),
# Drehung via SymmetryCore.mirror_rot_y (Punkt: +180°, Achse: Vorzeichen).
#
# ACHTUNG Indizes: symmetry_place_twin hängt den Zwilling ans ENDE der Liste —
# bestehende Indizes bleiben gültig (nur Anhängen, nie Verschieben).
## Zwilling beim PLATZIEREN/DUPLIZIEREN anlegen: objects[index] bekommt eine
## neue sym-Kennung (SymmetryCore.next_sym_key), der Zwilling mit exakt
## gespiegeltem Transform wandert ans ENDE der objects-Liste. Ein Objekt, das
## auf der Achse/im Zentrum liegt (SymmetryCore.maps_to_self), wird NICHT
## gedoppelt. Modus OFF oder ungültiger Index: es passiert nichts.
## Rückgabe: {"twin": int, "on_axis": bool} — twin = Index des Zwillings
## (-1 = keiner angelegt), on_axis = Objekt liegt auf Mitte/Achse.
static func symmetry_place_twin(doc: Dictionary, index: int, mode: int) -> Dictionary:
var out := {"twin": -1, "on_axis": false}
if mode == SymmetryCore.Mode.OFF:
return out
var objects: Array = doc.get("objects", [])
if index < 0 or index >= objects.size() or not (objects[index] is Dictionary):
return out
var src: Dictionary = objects[index]
var pos: Array = src.get("pos", [0.0, 0.0, 0.0])
if (pos as Array).size() != 3:
return out
var center := SymmetryCore.map_center(doc)
var p := Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
if SymmetryCore.maps_to_self(mode, p, center):
out["on_axis"] = true
return out
var twin: Dictionary = src.duplicate(true)
SymmetryCore.clear_sym(twin)
var mp := SymmetryCore.mirror_point(mode, p, center)
# y 1:1 übernommen (Spec vc27: Höhe ist ein Skalar) → der Zwilling ist der
# EXAKTE Spiegel, inklusive Höhe (auf symmetrischem Terrain = auf dem Boden).
twin["pos"] = [mp.x, mp.y, mp.z]
twin["rot_y_deg"] = SymmetryCore.mirror_rot_y(mode, float(src.get("rot_y_deg", 0.0)))
var key := SymmetryCore.next_sym_key(doc)
SymmetryCore.set_sym(src, key)
SymmetryCore.set_sym(twin, key)
objects.append(twin)
out["twin"] = objects.size() - 1
return out
## Nach einer Positions-/Dreh-Änderung ALLE betroffenen Paare synchronisieren:
## der Zwilling wird auf den exakten Spiegel seiner Quelle gesetzt. members =
## Indizes der geänderten Objekte; gehört BEIDE Seiten eines Paares zur
## Änderung, ist die Quelle das primary_hint (das gezogene/gedrehte Primär-
## Objekt), sonst der niedrigere Index (deterministisch). Modus OFF: no-op.
## Rückgabe: Indizes der neu geschriebenen Zwillinge (für die Marker-Synchronisierung).
static func symmetry_sync_pairs(doc: Dictionary, members: Array, mode: int,
primary_hint: int = -1) -> Array:
if mode == SymmetryCore.Mode.OFF:
return []
var objects: Array = doc.get("objects", [])
var center := SymmetryCore.map_center(doc)
var changed: Array = []
var handled: Dictionary = {}
for i in members:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var key := SymmetryCore.sym_of(objects[i] as Dictionary)
if key.is_empty():
continue
var t := SymmetryCore.find_partner_index(objects, key, i)
if t < 0:
continue
var pair_key := "%d|%d" % [mini(i, t), maxi(i, t)]
if handled.has(pair_key):
continue
handled[pair_key] = true
var src_i: int = i
if members.has(t):
# Beide Seiten in der Änderung (z. B. beide markiert und gezogen):
# die Quelle entscheidet, der Zwilling wird neu gespiegelt — so
# bleibt das Paar exakt spiegelbildlich statt auseinanderzulaufen.
if primary_hint == t:
src_i = t
elif primary_hint != i and t < i:
src_i = t
var twin_i: int = t if src_i == i else i
var src: Dictionary = objects[src_i]
var spos: Array = src.get("pos", [0.0, 0.0, 0.0])
if (spos as Array).size() != 3:
continue
var mp := SymmetryCore.mirror_point(mode,
Vector3(float(spos[0]), float(spos[1]), float(spos[2])), center)
var twin: Dictionary = objects[twin_i]
twin["pos"] = [mp.x, mp.y, mp.z]
twin["rot_y_deg"] = SymmetryCore.mirror_rot_y(mode, float(src.get("rot_y_deg", 0.0)))
changed.append(twin_i)
return changed
## Löschen erweitern: zu den gewählten Indizes kommen die sym-Partner dazu
## (jeder Partner genau einmal, auch wenn mehrere Quellen dasselbe Paar
## treffen). Modus OFF: Liste unverändert (Verhalten wie bisher).
static func symmetry_expand_delete(doc: Dictionary, doomed: Array, mode: int) -> Array:
if mode == SymmetryCore.Mode.OFF:
return doomed
var objects: Array = doc.get("objects", [])
var out: Array = doomed.duplicate()
for i in doomed:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var key := SymmetryCore.sym_of(objects[i] as Dictionary)
if key.is_empty():
continue
var t := SymmetryCore.find_partner_index(objects, key, i)
if t >= 0 and not out.has(t):
out.append(t)
return out

View file

@ -37,6 +37,8 @@ var _doc_ref: Dictionary = {} # letztes rebuild-Doc (Auswahl-Markierung
var _base_mat: StandardMaterial3D var _base_mat: StandardMaterial3D
var _select_mat: StandardMaterial3D var _select_mat: StandardMaterial3D
var _partner_mat: StandardMaterial3D # schwächere Auswahl-Farbe des sym-Zwillings (M3)
var _sym_partners: Array = [] # Indizes der Zwillinge der aktuellen Auswahl
func _ready() -> void: func _ready() -> void:
@ -62,6 +64,16 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
_select_mat.emission_enabled = true _select_mat.emission_enabled = true
_select_mat.emission = SELECT_COLOR * 0.35 _select_mat.emission = SELECT_COLOR * 0.35
_select_mat.roughness = 0.7 _select_mat.roughness = 0.7
# sym-Zwilling (M3, t_34908425): GLEICHE Auswahl-Material-Logik, nur eine
# schwächere Farbe (abgedunkeltes Orange, sehr leises Glühen) — keine zweite
# Markierungs-Technik, damit die spätere dezente Auswahl (U6) automatisch
# mitprofitiert.
_partner_mat = StandardMaterial3D.new()
_partner_mat.albedo_color = SELECT_COLOR.darkened(0.45)
_partner_mat.emission_enabled = true
_partner_mat.emission = SELECT_COLOR * 0.12
_partner_mat.roughness = 0.7
_sym_partners = []
if data.is_empty(): if data.is_empty():
return return
@ -272,6 +284,9 @@ func set_selected(idx: int) -> void:
func set_selected_multi(indices: Array) -> void: func set_selected_multi(indices: Array) -> void:
selected_indices = indices.duplicate() selected_indices = indices.duplicate()
selected_index = indices.back() if not indices.is_empty() else -1 selected_index = indices.back() if not indices.is_empty() else -1
# sym-Zwillinge (M3, t_34908425): aus dem doc ableiten — Partner der
# Auswahl, die nicht selbst ausgewählt sind, werden schwächer markiert.
_sym_partners = _sym_partners_of(indices)
var objects: Array = _doc_ref.get("objects", []) var objects: Array = _doc_ref.get("objects", [])
for marker in object_markers: for marker in object_markers:
if marker == null or not is_instance_valid(marker): if marker == null or not is_instance_valid(marker):
@ -280,12 +295,14 @@ func set_selected_multi(indices: Array) -> void:
var mi: MeshInstance3D = marker.get_child(0) var mi: MeshInstance3D = marker.get_child(0)
var echt := bool(marker.get_meta("glb", false)) var echt := bool(marker.get_meta("glb", false))
var ist_sel := _is_selected(idx) var ist_sel := _is_selected(idx)
mi.material_override = _select_mat if ist_sel else (null if echt else _base_mat) var ist_partner := not ist_sel and _is_partner(idx)
mi.material_override = _select_mat if ist_sel \
else (_partner_mat if ist_partner else (null if echt else _base_mat))
# Tint-Abgleich (Karte t_b0edb807): unmarkierte Objekte zeigen ihre # Tint-Abgleich (Karte t_b0edb807): unmarkierte Objekte zeigen ihre
# doc-Farbe (oder — nach „Zurücksetzen“ — wieder die Pack-Farbe). # doc-Farbe (oder — nach „Zurücksetzen“ — wieder die Pack-Farbe).
# Markierte bleiben orange; ihr Tint liegt als doc-Feld bereit und # Markierte (Auswahl ODER Zwilling) bleiben orange; ihr Tint liegt als
# erscheint beim Abwählen. # doc-Feld bereit und erscheint beim Abwählen.
if not ist_sel and idx >= 0 and idx < objects.size() \ if not ist_sel and not ist_partner and idx >= 0 and idx < objects.size() \
and objects[idx] is Dictionary: and objects[idx] is Dictionary:
var obj: Dictionary = objects[idx] var obj: Dictionary = objects[idx]
if SvmapTint.hat(obj): if SvmapTint.hat(obj):
@ -294,6 +311,28 @@ func set_selected_multi(indices: Array) -> void:
SvmapTint.zuruecknehmen(marker) SvmapTint.zuruecknehmen(marker)
func _is_partner(idx: int) -> bool:
return _sym_partners.has(idx)
## sym-Partner der Auswahl aus dem doc bestimmen (M3, t_34908425): Objekte mit
## sym-Kennung, deren Partner NICHT selbst in der Auswahl ist. Keine sym-Felder
## im doc (alte Karten, Modus Aus) → leer, Verhalten wie bisher.
func _sym_partners_of(indices: Array) -> Array:
var objects: Array = _doc_ref.get("objects", [])
var out: Array = []
for i in indices:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var key := SymmetryCore.sym_of(objects[i] as Dictionary)
if key.is_empty():
continue
var t := SymmetryCore.find_partner_index(objects, key, i)
if t >= 0 and not indices.has(t) and not out.has(t):
out.append(t)
return out
## Tint frisch aus dem doc auf die MARKIERTEN Objekte anwenden (Inspector- ## Tint frisch aus dem doc auf die MARKIERTEN Objekte anwenden (Inspector-
## Änderung ohne Rebuild). Markierte Objekte zeigen weiter Auswahl-Orange — ## Änderung ohne Rebuild). Markierte Objekte zeigen weiter Auswahl-Orange —
## die Farbe liegt im doc und erscheint beim Abwählen (set_selected_multi). ## die Farbe liegt im doc und erscheint beim Abwählen (set_selected_multi).

View file

@ -0,0 +1,72 @@
extends SceneTree
## Beweis-Probe (Karte t_34908425, WEGWERF — nicht Teil der Suite):
## M3 Objekte spiegeln — Beweis-Bild mit markierten Partnern. Ein Paar wird
## über den ECHTEN Editor-Pfad erzeugt (ObjectOps.symmetry_place_twin wie in
## _place_object), die Quelle über die echte Auswahl angewählt → der Zwilling
## muss die SCHWÄCHERE Partner-Markierung tragen (gleiche Material-Technik).
## Aufruf: xvfb-run -a godot4 --path . --rendering-driver opengl3 \
## -s res://tests/probe_m3_partner_render.gd
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var scene: PackedScene = load("res://scenes/main.tscn")
var main = scene.instantiate()
root.add_child(main)
await process_frame
await process_frame
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
DisplayServer.window_set_size(Vector2i(2328, 1310))
# Symmetrie-Modus PUNKT (Standard) + Paar über den echten Objekt-Pfad.
main.set("sym_mode", SymmetryCore.Mode.POINT)
(main.doc["objects"] as Array).append({
"id": "crate_wood", "pos": [14.0, 0.0, 6.0], "scale": 1.0, "rot_y_deg": 30.0,
})
var res: Dictionary = ObjectOps.symmetry_place_twin(main.doc, 0, SymmetryCore.Mode.POINT)
print("PRUEF twin_index=%d (soll 1)" % int(res.get("twin", -1)))
main.call("_after_structure_change")
await process_frame
await process_frame
var cam = main.get("camera")
if cam != null:
cam.set("distance", 34.0)
cam.call("focus_on", Vector3(0.0, 0.0, 0.0))
await process_frame
await process_frame
# Echte Auswahl: Quelle anwählen → Zwilling schwächer markiert.
main.interaction.call("select_object", 0)
await process_frame
await process_frame
var builder = main.get("view_builder")
var m0 = (builder.object_markers[0] as Node3D).get_child(0) as MeshInstance3D
var m1 = (builder.object_markers[1] as Node3D).get_child(0) as MeshInstance3D
var select_mat: StandardMaterial3D = builder.get("_select_mat")
var partner_mat: StandardMaterial3D = builder.get("_partner_mat")
var ok := true
if m0.material_override != select_mat:
ok = false
print("PRUEF FAIL: Quelle trägt nicht das Auswahl-Material")
else:
print("PRUEF ok: Quelle = volle Auswahl-Markierung")
if m1.material_override != partner_mat:
ok = false
print("PRUEF FAIL: Zwilling trägt nicht das Partner-Material")
else:
print("PRUEF ok: Zwilling = schwächere Partner-Markierung")
# Klartext-Beleg: die zwei Materialien sind verschieden UND der Zwilling
# dunkler (schwächer) als die Auswahl.
print("PRUEF farben: select=%s partner=%s" % [
str(select_mat.albedo_color), str(partner_mat.albedo_color)])
if not partner_mat.albedo_color.is_equal_approx(select_mat.albedo_color.darkened(0.45)):
ok = false
print("PRUEF FAIL: Partner-Farbe ist nicht die abgeschwächte Auswahl-Farbe")
var pfad := OS.get_environment("PROBE_PNG")
if pfad.is_empty():
pfad = "user://probe_m3_partner.png"
var img := root.get_viewport().get_texture().get_image()
img.save_png(pfad)
print("Bild: %s (%dx%d)" % [pfad, img.get_width(), img.get_height()])
print("PROBE_M3_PARTNER_%s" % ("OK" if ok else "FAIL"))
quit(0 if ok else 1)

555
tests/test_object_mirror.gd Normal file
View file

@ -0,0 +1,555 @@
extends SceneTree
## Editor-Tests (headless): Karte t_34908425 — M3 Objekte spiegeln
## (Platzieren/Verschieben/Drehen/Löschen/Duplizieren inkl. Partner-Bindung).
## Ausführen: godot --headless --path . --script res://tests/test_object_mirror.gd
## Exit 0 = alles grün. Die Transform-Mathe kommt aus SymmetryCore (M1) —
## hier wird die ANWENDUNG auf Objekte geprüft (ObjectOps + interaction-Pfade).
## Die Partner-Markierung (Viewport) prüft der Material-Vergleich am echten
## map_view_builder; die Optik selbst sieht Sly im Desktop-Smoke.
var failures := 0
var checks := 0
func _init() -> void:
# Pure Tests (kein Viewport nötig) laufen direkt; Viewport-Tests
# (Kamera-Projektion) brauchen laufende Frames — wie test_library.
_test_place_twin_modes()
_test_on_axis_not_doubled()
_test_mode_off_noop()
_test_sync_pairs_move()
_test_sync_pairs_rotate()
_test_sync_pairs_both_in_set()
_test_expand_delete()
_test_undo_single_step()
_after_frames()
func _after_frames() -> void:
await process_frame
await process_frame
_test_interaction_placement()
_test_interaction_rotate_delete()
_test_interaction_duplicate_nudge()
_test_interaction_grab()
_test_interaction_mode_off()
_test_partner_marking()
_test_validator()
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 _obj(id: String, pos: Array, rot := 0.0) -> Dictionary:
return {"id": id, "pos": pos, "scale": 1.0, "rot_y_deg": float(rot)}
func _flat_doc() -> Dictionary:
return SvmapDocument.create_new("M3Test", 1024.0, "mittag")
func _pair_doc(mode: int, src_pos: Array, src_rot := 30.0) -> Dictionary:
# Quelle setzen + Zwilling über DEN ECHTEN Pfad anlegen (wie Platzierung).
var doc := _flat_doc()
(doc["objects"] as Array).append(_obj("crate_wood", src_pos, src_rot))
var res: Dictionary = ObjectOps.symmetry_place_twin(doc, 0, mode)
if int(res.get("twin", -1)) < 0:
_check(false, "pair_doc: Zwilling wurde nicht angelegt (mode %d)" % mode)
return doc
func _pos_eq(a: Array, b: Array, label: String) -> void:
var ok := (a as Array).size() == 3 and (b as Array).size() == 3
if ok:
for k in 3:
if absf(float(a[k]) - float(b[k])) > 0.0001:
ok = false
_check(ok, label)
# ------------------------------------------------------------ Pure ObjectOps --
func _test_place_twin_modes() -> void:
print("== Platzieren legt Zwilling an (alle 3 Modi) ==")
# Punkt: x/z negiert, rot +180°.
var doc := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
_check((doc["objects"] as Array).size() == 2, "Punkt: 2 Objekte nach dem Platzieren")
var twin: Dictionary = (doc["objects"] as Array)[1]
_pos_eq(twin.get("pos", []), [-100.0, 0.0, -40.0],
"Punkt: Zwilling-Position exakt gespiegelt (−100/−40, Höhe 1:1)")
_check(absf(float(twin.get("rot_y_deg", 0.0)) - 210.0) < 0.0001,
"Punkt: Zwilling-Drehung 30° + 180° = 210° (ist %.1f)" % float(twin.get("rot_y_deg", 0.0)))
_check(SymmetryCore.sym_of((doc["objects"] as Array)[0]) != ""
and SymmetryCore.sym_of((doc["objects"] as Array)[0]) == SymmetryCore.sym_of(twin),
"Punkt: beide tragen dieselbe sym-Kennung")
# Achse X: x negiert, z bleibt, rot-Vorzeichen.
var docx := _pair_doc(SymmetryCore.Mode.AXIS_X, [100.0, 0.0, 40.0], 30.0)
var twinx: Dictionary = (docx["objects"] as Array)[1]
_pos_eq(twinx.get("pos", []), [-100.0, 0.0, 40.0], "Achse X: x gespiegelt, z bleibt")
_check(absf(float(twinx.get("rot_y_deg", 0.0)) - 330.0) < 0.0001,
"Achse X: Drehung Vorzeichenwechsel 30° → 330° (ist %.1f)" % float(twinx.get("rot_y_deg", 0.0)))
# Achse Z: z negiert, x bleibt.
var docz := _pair_doc(SymmetryCore.Mode.AXIS_Z, [100.0, 0.0, 40.0], 30.0)
var twinz: Dictionary = (docz["objects"] as Array)[1]
_pos_eq(twinz.get("pos", []), [100.0, 0.0, -40.0], "Achse Z: z gespiegelt, x bleibt")
_check(absf(float(twinz.get("rot_y_deg", 0.0)) - 330.0) < 0.0001,
"Achse Z: Drehung Vorzeichenwechsel")
# Zweites Paar → nächste freie Kennung, keine Kollision.
(doc["objects"] as Array).append(_obj("crate_wood", [-200.0, 0.0, 0.0], 0.0))
var res2: Dictionary = ObjectOps.symmetry_place_twin(doc, 2, SymmetryCore.Mode.POINT)
_check(int(res2.get("twin", -1)) == 3, "zweites Paar: Zwilling angehängt (Index 3)")
var k1 := SymmetryCore.sym_of((doc["objects"] as Array)[0])
var k2 := SymmetryCore.sym_of((doc["objects"] as Array)[2])
_check(k1 != k2 and k2 == "sym_2", "zweites Paar: eigene Kennung sym_2 (ist %s)" % k2)
func _test_on_axis_not_doubled() -> void:
print("== Objekt auf der Mitte/Achse wird NICHT gedoppelt ==")
var doc := _flat_doc()
(doc["objects"] as Array).append(_obj("crate_wood", [0.0, 0.0, 0.0]))
var rp: Dictionary = ObjectOps.symmetry_place_twin(doc, 0, SymmetryCore.Mode.POINT)
_check(bool(rp.get("on_axis", false)) and int(rp.get("twin", -1)) == -1
and (doc["objects"] as Array).size() == 1,
"Punkt: Objekt im Zentrum bleibt einzeln (on_axis gemeldet)")
_check(SymmetryCore.sym_of((doc["objects"] as Array)[0]) == "",
"Punkt: Einzelobjekt auf der Mitte bekommt KEINE sym-Kennung")
var docx := _flat_doc()
(docx["objects"] as Array).append(_obj("crate_wood", [0.0, 0.0, 40.0]))
var rx: Dictionary = ObjectOps.symmetry_place_twin(docx, 0, SymmetryCore.Mode.AXIS_X)
_check(bool(rx.get("on_axis", false)) and (docx["objects"] as Array).size() == 1,
"Achse X: Objekt auf der Achse bleibt einzeln")
var docz := _flat_doc()
(docz["objects"] as Array).append(_obj("crate_wood", [40.0, 0.0, 0.0]))
var rz: Dictionary = ObjectOps.symmetry_place_twin(docz, 0, SymmetryCore.Mode.AXIS_Z)
_check(bool(rz.get("on_axis", false)) and (docz["objects"] as Array).size() == 1,
"Achse Z: Objekt auf der Achse bleibt einzeln")
func _test_mode_off_noop() -> void:
print("== Modus AUS = Verhalten exakt wie bisher (Gegenprobe) ==")
var doc := _flat_doc()
(doc["objects"] as Array).append(_obj("crate_wood", [100.0, 0.0, 40.0], 30.0))
var rp: Dictionary = ObjectOps.symmetry_place_twin(doc, 0, SymmetryCore.Mode.OFF)
_check(int(rp.get("twin", -1)) == -1 and not bool(rp.get("on_axis", false))
and (doc["objects"] as Array).size() == 1, "AUS: Platzieren legt KEINEN Zwilling an")
_check(SymmetryCore.sym_of((doc["objects"] as Array)[0]) == "", "AUS: keine sym-Kennung")
var moved: int = ObjectOps.nudge_objects(doc, [0], Vector3(2.0, 0.0, 0.0))
var synced: Array = ObjectOps.symmetry_sync_pairs(doc, [0], SymmetryCore.Mode.OFF)
_check(moved == 1 and synced.is_empty(), "AUS: Verschieben synchronisiert nichts")
var del: Array = ObjectOps.symmetry_expand_delete(doc, [0], SymmetryCore.Mode.OFF)
_check(del == [0], "AUS: Löschen erfasst nur die Auswahl")
func _test_sync_pairs_move() -> void:
print("== Verschieben: Zwilling = exakter Spiegel der Quelle ==")
var doc := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
(doc["objects"] as Array)[0]["pos"] = [120.0, 0.0, 60.0]
var synced: Array = ObjectOps.symmetry_sync_pairs(doc, [0], SymmetryCore.Mode.POINT)
_check(synced == [1], "Verschieben: Zwilling-Index 1 zurückgegeben")
_pos_eq(((doc["objects"] as Array)[1] as Dictionary).get("pos", []),
[-120.0, 0.0, -60.0], "Verschieben: Zwilling exakt mitgespiegelt")
_check(absf(float(((doc["objects"] as Array)[1] as Dictionary).get("rot_y_deg", 0.0)) - 210.0) < 0.0001,
"Verschieben: Drehung unverändert gespiegelt (210°)")
func _test_sync_pairs_rotate() -> void:
print("== Drehen: Punkt +180°-Basis, Achse Vorzeichen ==")
var doc := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
(doc["objects"] as Array)[0]["rot_y_deg"] = 75.0
ObjectOps.symmetry_sync_pairs(doc, [0], SymmetryCore.Mode.POINT)
_check(absf(float(((doc["objects"] as Array)[1] as Dictionary).get("rot_y_deg", 0.0)) - 255.0) < 0.0001,
"Punkt: Quelle 30→75 (+45), Zwilling 210→255 (+45, Basis +180°)")
var docx := _pair_doc(SymmetryCore.Mode.AXIS_X, [100.0, 0.0, 40.0], 30.0)
(docx["objects"] as Array)[0]["rot_y_deg"] = 75.0
ObjectOps.symmetry_sync_pairs(docx, [0], SymmetryCore.Mode.AXIS_X)
_check(absf(float(((docx["objects"] as Array)[1] as Dictionary).get("rot_y_deg", 0.0)) - 285.0) < 0.0001,
"Achse X: Quelle 30→75, Zwilling 330→285 (Vorzeichen)")
func _test_sync_pairs_both_in_set() -> void:
print("== Beide Seiten gemeinsam geändert: Quelle = Primär-Objekt ==")
var doc := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
(doc["objects"] as Array)[1]["pos"] = [-90.0, 0.0, -50.0] # Zwilling wurde mitgezogen
var synced: Array = ObjectOps.symmetry_sync_pairs(
doc, [0, 1], SymmetryCore.Mode.POINT, 1)
_check(synced == [0], "beide in der Änderung: Index 0 wird als Zwilling neu gesetzt")
_pos_eq(((doc["objects"] as Array)[0] as Dictionary).get("pos", []),
[90.0, 0.0, 50.0], "Primär (Index 1) bleibt, Partner = exakter Spiegel")
func _test_expand_delete() -> void:
print("== Löschen: Partner stirbt mit ==")
var doc := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
var doomed: Array = ObjectOps.symmetry_expand_delete(doc, [0], SymmetryCore.Mode.POINT)
_check(doomed == [0, 1], "Löschen: Auswahl 0 erweitert sich um Partner 1")
var both: Array = ObjectOps.symmetry_expand_delete(doc, [1, 0], SymmetryCore.Mode.POINT)
_check(both == [1, 0], "beide schon in der Auswahl: keine Doppel-Einträge")
func _test_undo_single_step() -> void:
print("== EINE Änderung = EIN Undo-Schritt ==")
var doc := _flat_doc()
var hist := ActionHistory.new()
hist.begin(doc)
# Platzieren mit Zwilling: ein Commit.
var before: Dictionary = hist.snapshot(["objects"])
(doc["objects"] as Array).append(_obj("crate_wood", [100.0, 0.0, 40.0], 30.0))
ObjectOps.symmetry_place_twin(doc, 0, SymmetryCore.Mode.POINT)
hist.commit("Test: platzieren", before, hist.snapshot(["objects"]))
_check((doc["objects"] as Array).size() == 2 and hist.entries.size() == 1,
"Platzieren mit Zwilling = EIN Historie-Eintrag (2 Objekte)")
# Verschieben des Paares: ein weiterer Commit.
var b2: Dictionary = hist.snapshot(["objects"])
(doc["objects"] as Array)[0]["pos"] = [150.0, 0.0, 40.0]
ObjectOps.symmetry_sync_pairs(doc, [0], SymmetryCore.Mode.POINT)
hist.commit("Test: verschieben", b2, hist.snapshot(["objects"]))
_check(hist.entries.size() == 2, "Verschieben mit Zwilling = EIN weiterer Eintrag")
# Ein Undo → BEIDE springt auf den Stand vor dem Verschieben zurück.
hist.undo()
_pos_eq(((doc["objects"] as Array)[0] as Dictionary).get("pos", []),
[100.0, 0.0, 40.0], "Ein Undo: Quelle auf dem Ausgangspunkt")
_pos_eq(((doc["objects"] as Array)[1] as Dictionary).get("pos", []),
[-100.0, 0.0, -40.0], "Ein Undo: Zwilling auf dem gespiegelten Ausgangspunkt")
_check((doc["objects"] as Array).size() == 2, "Ein Undo: Paar existiert weiter")
# Zweites Undo → komplettes Platzieren (Quelle + Zwilling) zurück.
hist.undo()
_check((doc["objects"] as Array).is_empty(),
"Zweites Undo: Platzieren nimmt Quelle UND Zwilling komplett zurück")
# ------------------------------------------------------- Interaction-Pfade ----
## Minimales main-Doppel: doc + echte ActionHistory + sym_mode; alle UI-Rufe
## (Statuszeile etc.) landen als No-Op im Doppel und werden mitgeschrieben.
class ProbeMain:
var doc: Dictionary = {}
var manifest: Dictionary = {}
var grid_snap := false
var sym_mode: int = SymmetryCore.Mode.OFF
var dirty := false
var history: ActionHistory = ActionHistory.new()
var last_status := ""
func _set_status(msg: String, _is_err := false) -> void:
last_status = msg
func _base_tool_status() -> void:
pass
func _highlight_library(_oid: String) -> void:
pass
func _after_structure_change() -> void:
pass
func _mark_tree_selection(_idx: int) -> void:
pass
func _mark_tree_selection_multi(_indices: Array) -> void:
pass
func _selection_status(_idx: int) -> void:
pass
func _sync_selection_panel() -> void:
pass
func _refresh_history_buttons() -> void:
pass
func _refresh_stats() -> void:
pass
func _refresh_file_label() -> void:
pass
func object_label(oid: String) -> String:
return oid
func _fmt_int(n: int) -> String:
return str(n)
## Builder-Doppel: interaction liest/schreibt nur object_markers und ruft
## set_selected_multi — Marker sind echte Node3D an der Objekt-Position.
class ProbeBuilder:
var object_markers: Array = []
func set_selected_multi(_indices: Array) -> void:
pass
func sync_markers(doc: Dictionary) -> void:
object_markers.clear()
for obj in (doc.get("objects", []) as Array):
var m := Node3D.new()
var pos: Array = (obj as Dictionary).get("pos", [0.0, 0.0, 0.0])
m.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
m.rotation.y = deg_to_rad(float((obj as Dictionary).get("rot_y_deg", 0.0)))
object_markers.append(m)
func _make_interaction(doc: Dictionary, sym_mode: int, vp: SubViewport,
cam: Camera3D) -> Node3D:
var fake := ProbeMain.new()
fake.doc = doc
fake.manifest = {"objects": {"crate_wood": {"poly": 0, "collision": {"shape": "box", "size": [1.2, 1.2, 1.2]}}}}
fake.sym_mode = sym_mode
fake.history.begin(doc)
var builder := ProbeBuilder.new()
builder.sync_markers(doc)
var interaction := Node3D.new()
interaction.set_script(load("res://src/editor/interaction.gd"))
vp.add_child(interaction)
interaction.setup(fake, cam, builder)
return interaction
func _main_of(interaction: Node3D) -> ProbeMain:
return interaction.get("main") as ProbeMain
func _builder_of(interaction: Node3D) -> ProbeBuilder:
return interaction.get("view_builder") as ProbeBuilder
func _test_interaction_placement() -> void:
print("== Platzieren über interaction._place_object ==")
var vp := SubViewport.new()
vp.size = Vector2i(1280, 720)
root.add_child(vp)
var cam := Camera3D.new()
vp.add_child(cam)
cam.current = true
cam.look_at_from_position(Vector3(-95.0, 115.0, 135.0), Vector3.ZERO, Vector3.UP)
# Modus Punkt: Klick bei (100, 0, 40) → Quelle + Zwilling.
var doc := _flat_doc()
var inter := _make_interaction(doc, SymmetryCore.Mode.POINT, vp, cam)
inter.set("place_oid", "crate_wood")
inter.set("place_rot_deg", 30.0)
inter.set("placing", true)
var h := SvmapDocument.terrain_height_at(doc, 100.0, 40.0)
inter.call("_place_object", cam.unproject_position(Vector3(100.0, h, 40.0)))
var objs: Array = doc.get("objects", [])
_check(objs.size() == 2, "Platzieren (Punkt): 2 Objekte im doc")
if objs.size() == 2:
_pos_eq((objs[1] as Dictionary).get("pos", []), [-100.0, h, -40.0],
"Platzieren (Punkt): Zwilling exakt gespiegelt")
var k := SymmetryCore.sym_of(objs[0])
_check(k != "" and k == SymmetryCore.sym_of(objs[1]), "Platzieren: Paar-Kennung gesetzt")
var fake := _main_of(inter)
_check(fake.history.entries.size() == 1, "Platzieren mit Zwilling = EIN Undo-Schritt")
_check(fake.last_status.contains("Zwilling"), "Statuszeile meldet den Zwilling")
fake.history.undo()
_check((doc["objects"] as Array).is_empty(),
"Ein Strg+Z: Quelle UND Zwilling zurück (Map vorher leer)")
# Auf der Mitte: NICHT gedoppelt, Klartext-Meldung.
var doc2 := _flat_doc()
var inter2 := _make_interaction(doc2, SymmetryCore.Mode.POINT, vp, cam)
inter2.set("place_oid", "crate_wood")
inter2.set("placing", true)
var h0 := SvmapDocument.terrain_height_at(doc2, 0.0, 0.0)
inter2.call("_place_object", cam.unproject_position(Vector3(0.0, h0, 0.0)))
_check((doc2["objects"] as Array).size() == 1, "Mitte: Objekt wird nicht gedoppelt")
_check((_main_of(inter2) as ProbeMain).last_status.to_lower().contains("nicht gedoppelt"),
"Statuszeile meldet „nicht gedoppelt“")
vp.queue_free()
func _test_interaction_rotate_delete() -> void:
print("== Drehen + Löschen über interaction ==")
var vp := SubViewport.new()
vp.size = Vector2i(64, 64)
root.add_child(vp)
var cam := Camera3D.new()
vp.add_child(cam)
cam.current = true
# Drehen: Quelle +15° → Zwilling +15° (Punkt-Basis +180° bleibt).
var doc := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
var inter := _make_interaction(doc, SymmetryCore.Mode.POINT, vp, cam)
inter.call("select_object", 0)
inter.call("rotate_selected", 15.0)
var objs: Array = doc.get("objects", [])
_check(absf(float((objs[0] as Dictionary).get("rot_y_deg", 0.0)) - 45.0) < 0.0001,
"Drehen: Quelle 30→45")
_check(absf(float((objs[1] as Dictionary).get("rot_y_deg", 0.0)) - 225.0) < 0.0001,
"Drehen: Zwilling 210→225 (dreht MIT)")
_check(absf((_builder_of(inter).object_markers[1] as Node3D).rotation.y
- deg_to_rad(225.0)) < 0.001, "Drehen: Zwilling-Marker sichtbar mitgedreht")
var fake := _main_of(inter)
_check(fake.history.entries.size() == 1, "Drehen mit Zwilling = EIN Undo-Schritt")
fake.history.undo()
_check(absf(float(((doc["objects"] as Array)[1] as Dictionary).get("rot_y_deg", 0.0)) - 210.0) < 0.0001,
"Ein Undo: beide Drehungen zurück")
# Löschen: Quelle löschen nimmt den Zwilling mit; EIN Undo holt beide.
var doc2 := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
var inter2 := _make_interaction(doc2, SymmetryCore.Mode.POINT, vp, cam)
inter2.call("select_object", 0)
inter2.call("delete_selected")
_check((doc2["objects"] as Array).is_empty(), "Löschen: Quelle UND Zwilling weg")
var fake2 := _main_of(inter2)
_check(fake2.history.entries.size() == 1, "Löschen mit Zwilling = EIN Undo-Schritt")
fake2.history.undo()
_check((doc2["objects"] as Array).size() == 2, "Ein Undo: beide Objekte zurück")
vp.queue_free()
func _test_interaction_duplicate_nudge() -> void:
print("== Duplizieren + Pfeiltasten über interaction ==")
var vp := SubViewport.new()
vp.size = Vector2i(64, 64)
root.add_child(vp)
var cam := Camera3D.new()
vp.add_child(cam)
cam.current = true
# Duplizieren: Kopie + Zwilling der Kopie = neues Paar, eigene Kennung.
var doc := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
var inter := _make_interaction(doc, SymmetryCore.Mode.POINT, vp, cam)
inter.call("select_object", 0)
inter.call("duplicate_selected")
var objs: Array = doc.get("objects", [])
_check(objs.size() == 4, "Duplizieren: 2 Paare (Kopie + Zwilling der Kopie)")
if objs.size() == 4:
var k_old := SymmetryCore.sym_of(objs[0])
var k_new := SymmetryCore.sym_of(objs[2])
_check(k_old != k_new and k_new != "", "neues Paar mit EIGENER Kennung")
_check(SymmetryCore.sym_of(objs[3]) == k_new, "Kopie + ihr Zwilling tragen dieselbe Kennung")
_pos_eq((objs[2] as Dictionary).get("pos", []), [102.0, 0.0, 40.0],
"Kopie +2 m in X (Rasterversatz)")
_pos_eq((objs[3] as Dictionary).get("pos", []), [-102.0, 0.0, -40.0],
"Zwilling der Kopie exakt gespiegelt")
var fake := _main_of(inter)
_check(fake.history.entries.size() == 1, "Duplizieren mit Zwillingen = EIN Undo-Schritt")
fake.history.undo()
_check((doc["objects"] as Array).size() == 2, "Ein Undo: alle 4 Kopien zurück")
# Pfeiltasten: Quelle +2 m in X → Zwilling −2 m (Punkt spiegelt das Delta).
var doc2 := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
var inter2 := _make_interaction(doc2, SymmetryCore.Mode.POINT, vp, cam)
inter2.call("select_object", 0)
inter2.call("nudge_selection", Vector3(2.0, 0.0, 0.0))
_pos_eq(((doc2["objects"] as Array)[0] as Dictionary).get("pos", []),
[102.0, 0.0, 40.0], "Pfeiltaste: Quelle +2 m")
_pos_eq(((doc2["objects"] as Array)[1] as Dictionary).get("pos", []),
[-102.0, 0.0, -40.0], "Pfeiltaste: Zwilling −2 m (mitgeführt)")
_check((_main_of(inter2) as ProbeMain).history.entries.size() == 1,
"Pfeiltaste mit Zwilling = EIN Undo-Schritt")
vp.queue_free()
func _test_interaction_grab() -> void:
print("== Greifen (G) über interaction ==")
var vp := SubViewport.new()
vp.size = Vector2i(64, 64)
root.add_child(vp)
var cam := Camera3D.new()
vp.add_child(cam)
cam.current = true
var doc := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
var inter := _make_interaction(doc, SymmetryCore.Mode.POINT, vp, cam)
inter.call("select_object", 0)
inter.call("grab_begin")
inter.call("grab_move", Vector3(150.0, 0.0, 20.0))
inter.call("grab_confirm")
_pos_eq(((doc["objects"] as Array)[0] as Dictionary).get("pos", []),
[150.0, 0.0, 20.0], "Greifen: Quelle am Ziel")
_pos_eq(((doc["objects"] as Array)[1] as Dictionary).get("pos", []),
[-150.0, 0.0, -20.0], "Greifen: Zwilling exakt gespiegelt am Ziel")
_check((_main_of(inter) as ProbeMain).history.entries.size() == 1,
"Greifen mit Zwilling = EIN Undo-Schritt")
# Esc bricht ab: Quelle UND Zwilling exakt auf den Ausgangspunkt zurück.
var doc2 := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
var inter2 := _make_interaction(doc2, SymmetryCore.Mode.POINT, vp, cam)
inter2.call("select_object", 0)
inter2.call("grab_begin")
inter2.call("grab_move", Vector3(300.0, 0.0, 300.0))
inter2.call("grab_cancel")
_pos_eq(((doc2["objects"] as Array)[0] as Dictionary).get("pos", []),
[100.0, 0.0, 40.0], "Esc: Quelle exakt zurück")
_pos_eq(((doc2["objects"] as Array)[1] as Dictionary).get("pos", []),
[-100.0, 0.0, -40.0], "Esc: Zwilling exakt zurück")
_check((_main_of(inter2) as ProbeMain).history.entries.size() == 0,
"Esc: kein Historie-Eintrag")
vp.queue_free()
func _test_interaction_mode_off() -> void:
print("== Gegenprobe interaction bei Modus AUS ==")
var vp := SubViewport.new()
vp.size = Vector2i(64, 64)
root.add_child(vp)
var cam := Camera3D.new()
vp.add_child(cam)
cam.current = true
var doc := _flat_doc()
(doc["objects"] as Array).append(_obj("crate_wood", [100.0, 0.0, 40.0], 30.0))
(doc["objects"] as Array).append(_obj("crate_wood", [-100.0, 0.0, -40.0], 210.0))
var inter := _make_interaction(doc, SymmetryCore.Mode.OFF, vp, cam)
# Drehen berührt NUR die Auswahl (kein Zwilling-Mechanismus).
inter.call("select_object", 0)
inter.call("rotate_selected", 15.0)
_check(absf(float(((doc["objects"] as Array)[1] as Dictionary).get("rot_y_deg", 0.0)) - 210.0) < 0.0001,
"AUS: Drehen ändert das zweite Objekt NICHT")
# Löschen nimmt NUR die Auswahl mit.
inter.call("delete_selected")
_check((doc["objects"] as Array).size() == 1, "AUS: Löschen entfernt nur das gewählte Objekt")
# Duplizieren erzeugt NUR die Kopie (+2 m).
inter.call("select_object", 0)
inter.call("duplicate_selected")
_check((doc["objects"] as Array).size() == 2, "AUS: Duplizieren = eine Kopie, kein Zwilling")
vp.queue_free()
func _test_partner_marking() -> void:
print("== Partner-Markierung (echter Viewport-Bauer) ==")
var doc := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
var builder := Node3D.new()
builder.set_script(load("res://src/viewport/map_view_builder.gd"))
root.add_child(builder)
var manifest := {"objects": {"crate_wood": {"collision": {"shape": "box", "size": [1.2, 1.2, 1.2]}}}}
builder.call("rebuild", doc, manifest)
# Quelle (0) anwählen → Zwilling (1) schwächer markiert.
builder.call("set_selected_multi", [0])
var select_mat: StandardMaterial3D = builder.get("_select_mat")
var partner_mat: StandardMaterial3D = builder.get("_partner_mat")
var m0: MeshInstance3D = (builder.get("object_markers") as Array)[0].get_child(0)
var m1: MeshInstance3D = (builder.get("object_markers") as Array)[1].get_child(0)
_check(m0.material_override == select_mat, "Auswahl: Quelle volle Orange-Markierung")
_check(m1.material_override == partner_mat, "Zwilling: schwächere Partner-Markierung")
_check(partner_mat != select_mat and partner_mat != builder.get("_base_mat"),
"Partner-Material ist eigenes, abgeschwächtes Material (gleiche Technik)")
# Beide angewählt → beide volle Markierung, kein Partner-Zustand.
builder.call("set_selected_multi", [0, 1])
_check(m0.material_override == select_mat and m1.material_override == select_mat,
"beide angewählt: beide volle Auswahl-Markierung")
# Abwählen: Basiszustand zurück (GLB fehlt → Platzhalter mit Basis-Farbe).
builder.call("set_selected_multi", [])
_check(m0.material_override == builder.get("_base_mat")
and m1.material_override == builder.get("_base_mat"),
"ohne Auswahl: Basis-Farben (wie bisher)")
builder.queue_free()
func _test_validator() -> void:
print("== Validator V16 bleibt mit den Paaren zufrieden ==")
var doc := _pair_doc(SymmetryCore.Mode.POINT, [100.0, 0.0, 40.0], 30.0)
(doc["objects"] as Array).append(_obj("crate_wood", [-200.0, 0.0, 0.0], 0.0))
ObjectOps.symmetry_place_twin(doc, 2, SymmetryCore.Mode.POINT)
var errors: PackedStringArray = SvmapIO.validate_map(doc, {})
var sym_errors: Array = []
for e in errors:
if str(e).contains("V16") or str(e).contains("sym"):
sym_errors.append(e)
_check(sym_errors.is_empty(), "2 Paare im doc: keine V16/sym-Fehler (%s)" % str(sym_errors))

View file

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