From 238ca7bb2fbf29be2fd0b224ffce47c99a8e40e7 Mon Sep 17 00:00:00 2001 From: "Hermes (scopedvr-dev)" Date: Tue, 20 Oct 2026 06:48:27 +0000 Subject: [PATCH] =?UTF-8?q?M3=20(t=5F34908425):=20Objekte=20spiegeln=20?= =?UTF-8?q?=E2=80=94=20Platzieren/Verschieben/Drehen/L=C3=B6schen/Duplizie?= =?UTF-8?q?ren=20inkl.=20Partner-Bindung=20(Spec=20vc27).=20ObjectOps:=20s?= =?UTF-8?q?ymmetry=5Fplace=5Ftwin/symmetry=5Fsync=5Fpairs/symmetry=5Fexpan?= =?UTF-8?q?d=5Fdelete=20(Mathe=20aus=20SymmetryCore=20M1,=20keine=20zweite?= =?UTF-8?q?=20Spiegel-Mathe;=20Mitte/Achse=20nicht=20gedoppelt).=20interac?= =?UTF-8?q?tion:=20Zwilling=20in=20ALLE=20Objekt-Pfade=20integriert=20(Pla?= =?UTF-8?q?tzieren,=20Drehen,=20L=C3=B6schen,=20Duplizieren,=20Pfeiltasten?= =?UTF-8?q?,=20Ziehen=20live+Commit,=20Greifen=20live+Esc-R=C3=BCckabwickl?= =?UTF-8?q?ung,=20Spiegeln-Button)=20=E2=80=94=20jede=20=C3=84nderung=20?= =?UTF-8?q?=3D=20EIN=20Undo-Schritt.=20map=5Fview=5Fbuilder:=20schw=C3=A4c?= =?UTF-8?q?here=20Partner-Markierung=20=C3=BCber=20die=20bestehende=20Ausw?= =?UTF-8?q?ahl-Material-Logik=20(keine=20zweite=20Technik).=20Neu:=20tests?= =?UTF-8?q?/test=5Fobject=5Fmirror.gd=20(74=20checks,=20alle=203=20Modi=20?= =?UTF-8?q?+=20Gegenprobe=20AUS),=20tests/probe=5Fm3=5Fpartner=5Frender.gd?= =?UTF-8?q?=20(Beweis-Bild).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/interaction.gd | 214 +++++++++++- src/editor/object_ops.gd | 123 +++++++ src/viewport/map_view_builder.gd | 47 ++- tests/probe_m3_partner_render.gd | 72 ++++ tests/test_object_mirror.gd | 555 +++++++++++++++++++++++++++++++ tests/test_object_mirror.gd.uid | 1 + 6 files changed, 1001 insertions(+), 11 deletions(-) create mode 100644 tests/probe_m3_partner_render.gd create mode 100644 tests/test_object_mirror.gd create mode 100644 tests/test_object_mirror.gd.uid diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index ccb68e8..7da4925 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -83,6 +83,10 @@ var _grab_primary := -1 var _grab_originals: Dictionary = {} # idx (int) → [x, y, z] exakt (Rückabwicklung) var _grab_lifts: Dictionary = {} # idx (int) → Terrain-Abstand beim Anfassen 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: @@ -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 +## 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 ## (dann zoomt das Rad NICHT)? Im Freelook-Flug (t_c46d31c2) blockt das Rad ## zusätzlich ALLE Objekt-Reaktionen — dort ändert es die Grundgeschwindigkeit. @@ -287,12 +298,20 @@ func _place_object(viewport_pos: Vector2) -> void: return # 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 entry: Dictionary = lib.get(place_oid, {}) var poly := float(entry.get("poly", 0)) var used := SvmapDocument.poly_usage(main.doc, main.manifest) var budget := float(SvmapIO.MAX_TOTAL_POLY) - if used + poly > budget: + if used + poly * sym_factor > budget: 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." % [main._fmt_int(int(used)), main._fmt_int(int(budget))], true) @@ -308,11 +327,24 @@ func _place_object(viewport_pos: Vector2) -> void: "scale": 1.0, "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), before, main.history.snapshot(["objects"])) - var new_index: int = (main.doc["objects"] as Array).size() - 1 main.call("_after_structure_change") - 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)) + 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)) # Platzier-Modus bleibt aktiv (Mehrfach-Platzieren ohne erneutes Klicken), # Auswahl auf das frische Objekt legen? Nein — Ghost bleibt Vorrang; ESC beendet. main.call("_mark_tree_selection", new_index) @@ -443,6 +475,11 @@ func rotate_selected(delta_deg: float) -> void: var before: Dictionary = main.history.snapshot(["objects"]) GroupOps.rotate_members(main.doc, members, delta_deg) _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) main.history.commit("Auswahl gedreht (%d Objekte)" % members.size(), before, main.history.snapshot(["objects"]), tag) @@ -478,6 +515,9 @@ func delete_selected() -> void: if doomed.is_empty(): clear_selection(true) 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 label: String if doomed.size() == 1: @@ -514,16 +554,31 @@ func duplicate_selected() -> void: if sources.is_empty(): main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du kopieren willst.", true) 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) return 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 for i in sources: var src_obj: Dictionary = objects[i] var scl := float(src_obj.get("scale", 1.0)) var entry: Dictionary = lib.get(str(src_obj.get("id", "")), {}) 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 budget := float(SvmapIO.MAX_TOTAL_POLY) if used + poly_sum > budget: @@ -536,6 +591,15 @@ func duplicate_selected() -> void: if new_indices.is_empty(): clear_selection(true) 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 if new_indices.size() == 1: 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() main.history.commit(label, before, main.history.snapshot(["objects"])) main.call("_after_structure_change") - # Kopien direkt anwählen — Sly kann sie gleich weiterziehen. - select_many(new_indices) - 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) + # Kopien direkt anwählen — Sly kann sie gleich weiterziehen. Die frischen + # 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) + 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 @@ -670,6 +740,11 @@ func nudge_selection(delta: Vector3) -> void: if changed == 0: return # am Map-Rand geklemmt — Snapshot verwerfen, kein Commit _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 \ else "%d Objekte verschoben (Pfeiltasten)" % members.size() 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_lifts[i] = float(pos[1]) - SvmapDocument.terrain_height_at( 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 _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.") @@ -741,6 +835,9 @@ func grab_move(hit: Vector3) -> void: var marker: Node3D = view_builder.object_markers[i] if marker != null and is_instance_valid(marker): 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. @@ -755,6 +852,8 @@ func grab_confirm() -> void: _grab_originals = {} _grab_lifts = {} _grab_before = {} + _grab_twin_map = {} + _grab_twin_originals = {} _mark_dirty() main.call("_refresh_history_buttons") main.call("_refresh_stats") @@ -776,13 +875,101 @@ func grab_cancel() -> void: var marker: Node3D = view_builder.object_markers[i] if marker != null and is_instance_valid(marker): 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_originals = {} _grab_lifts = {} _grab_before = {} + _grab_twin_map = {} + _grab_twin_originals = {} 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 — @@ -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 if marker != null and is_instance_valid(marker): 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)" main.history.commit(desc, before, main.history.snapshot(["objects"])) 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) \ + float(_drag_lifts.get(i, 0.0)) 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: @@ -1079,6 +1274,11 @@ func _commit_drag() -> void: for i in moved: var pos: Vector3 = (view_builder.object_markers[i] as Node3D).position 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 if _drag_is_group: desc = "Gruppe verschoben (%d Objekte)" % moved.size() diff --git a/src/editor/object_ops.gd b/src/editor/object_ops.gd index a6eff52..84ecbab 100644 --- a/src/editor/object_ops.gd +++ b/src/editor/object_ops.gd @@ -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.y = SvmapDocument.terrain_height_at(doc, pos.x, pos.z) 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 diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index a707741..0b2ee36 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -37,6 +37,8 @@ var _doc_ref: Dictionary = {} # letztes rebuild-Doc (Auswahl-Markierung var _base_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: @@ -62,6 +64,16 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void: _select_mat.emission_enabled = true _select_mat.emission = SELECT_COLOR * 0.35 _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(): return @@ -272,6 +284,9 @@ func set_selected(idx: int) -> void: func set_selected_multi(indices: Array) -> void: selected_indices = indices.duplicate() 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", []) for marker in object_markers: 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 echt := bool(marker.get_meta("glb", false)) 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 # doc-Farbe (oder — nach „Zurücksetzen“ — wieder die Pack-Farbe). - # Markierte bleiben orange; ihr Tint liegt als doc-Feld bereit und - # erscheint beim Abwählen. - if not ist_sel and idx >= 0 and idx < objects.size() \ + # Markierte (Auswahl ODER Zwilling) bleiben orange; ihr Tint liegt als + # doc-Feld bereit und erscheint beim Abwählen. + if not ist_sel and not ist_partner and idx >= 0 and idx < objects.size() \ and objects[idx] is Dictionary: var obj: Dictionary = objects[idx] if SvmapTint.hat(obj): @@ -294,6 +311,28 @@ func set_selected_multi(indices: Array) -> void: 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- ## Änderung ohne Rebuild). Markierte Objekte zeigen weiter Auswahl-Orange — ## die Farbe liegt im doc und erscheint beim Abwählen (set_selected_multi). diff --git a/tests/probe_m3_partner_render.gd b/tests/probe_m3_partner_render.gd new file mode 100644 index 0000000..aa5251a --- /dev/null +++ b/tests/probe_m3_partner_render.gd @@ -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) diff --git a/tests/test_object_mirror.gd b/tests/test_object_mirror.gd new file mode 100644 index 0000000..df097e1 --- /dev/null +++ b/tests/test_object_mirror.gd @@ -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)) + + diff --git a/tests/test_object_mirror.gd.uid b/tests/test_object_mirror.gd.uid new file mode 100644 index 0000000..8d39566 --- /dev/null +++ b/tests/test_object_mirror.gd.uid @@ -0,0 +1 @@ +uid://hl3ag6m342ea