scopedvr-map-editor/tests/test_object_mirror.gd
Hermes (scopedvr-dev) 5dca5513da U6 t_639135dd: dezente Auswahl — Kontur statt Vollorange (Textur bleibt sichtbar).
Neu shaders/selection_outline.gdshader: Inverted-Hull-Außenhülle (unshaded,
cull_front, Grow entlang der Normalen; Streifen-Uniform für den Zwilling).

Markierung jetzt über Outline-Kind am Mesh (Auswahl/Hover/Partner); Farben +
Breiten aus theme/editor_theme.tres (selection_outline*, *_width_mm) — eine
Quelle. material_override wird von der Markierung nicht mehr angefasst:
GLB-Texturen bleiben im Auswahl-Zustand voll sichtbar, der Tint bleibt beim
Markieren sichtbar (kein "Orange gewinnt" mehr).

Spawn-Zonen: konstante Disc-Deckkraft + dezenter Kontur-Ring (Torus) statt
Aufhellung/Emission; Label-Farbe aus dem Theme statt hartkodiertem Gelb.
Bau-Teile: Kontur um das Bauteil-Mesh statt halbtransparenter Vollbox.
Neu: set_hover()-API (schwächste Stufe) — Anschluss an die Mausbewegung
liegt in interaction.gd (Grenze dieser Karte) und folgt als eigene Karte.

Suite tests/test_selection_look.gd (41 checks: Theme-Quelle, Grundmaterial
unangetastet, Outline nur bei Markierung, restlos weg bei Abwahl, Zwilling
dünner+gestrichelt, Spawn-Ring, Bau-Teil-Kontur). Altsuiten an die neue
Semantik angepasst (test_glb_view: GLB-Auswahl ohne Override; test_map_tint
T3: Kontur statt Orange-gewinnt; test_object_mirror: Partner per Kontur).

Beweise: 4 Renderproben (fass, echtes GLB — vorher Vollorange / nachher
Kontur, je auf Sand und Fels) in /opt/data/scopedvr/work/u6_auswahl_beweise/.
Voll-Batterie 32/32 grün (EXIT 0) inkl. der drei angepassten Suiten.
2026-10-20 10:33:04 +00:00

567 lines
25 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. U6 (t_639135dd):
# Markierung = Außenhüllen-Kontur statt Vollfarbe — Quelle trägt die
# normale Auswahl-Kontur, der Zwilling die dünnere/gestrichelte Partner-
# Kontur in eigener Theme-Farbe.
builder.call("set_selected_multi", [0])
var sel_mat: ShaderMaterial = builder.get("_outline_mat_sel")
var partner_mat: ShaderMaterial = builder.get("_outline_mat_partner")
var markers: Array = builder.get("object_markers")
var k0: Variant = (markers[0] as Node3D).get_meta("outline") \
if (markers[0] as Node3D).has_meta("outline") else null
var k1: Variant = (markers[1] as Node3D).get_meta("outline") \
if (markers[1] as Node3D).has_meta("outline") else null
_check(k0 is MeshInstance3D and (k0 as MeshInstance3D).material_override == sel_mat,
"Auswahl: Quelle trägt die Auswahl-Kontur")
_check(k1 is MeshInstance3D and (k1 as MeshInstance3D).material_override == partner_mat,
"Zwilling: schwächere Partner-Kontur")
_check(partner_mat != sel_mat,
"Partner-Material ist eigenes, abgeschwächtes Material (gleiche Technik)")
# Beide angewählt → beide volle Kontur, kein Partner-Zustand.
builder.call("set_selected_multi", [0, 1])
k0 = (markers[0] as Node3D).get_meta("outline", null)
k1 = (markers[1] as Node3D).get_meta("outline", null)
_check(k0 is MeshInstance3D and (k0 as MeshInstance3D).material_override == sel_mat
and k1 is MeshInstance3D and (k1 as MeshInstance3D).material_override == sel_mat,
"beide angewählt: beide volle Auswahl-Kontur")
# Abwählen: Basiszustand zurück — Konturen restlos weg (Meta entfernt,
# Grundmaterial blieb die ganze Zeit unangetastet).
builder.call("set_selected_multi", [])
_check(not (markers[0] as Node3D).has_meta("outline")
and not (markers[1] as Node3D).has_meta("outline"),
"ohne Auswahl: keine Rest-Markierung (U6)")
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))