diff --git a/README.md b/README.md index 18df7e9..a3ff551 100644 --- a/README.md +++ b/README.md @@ -186,10 +186,12 @@ Undo für alles): ist **KEIN Extra-Feld im Dateiformat nötig** und die Loader-Kompatibilität bleibt unangetastet (bewiesen in `tests/test_ux.gd`: Erhöhtes Objekt überlebt gespeichern → laden intakt (Höhe bleibt exakt)). - - **Absenken stoppt an der Terrain-Höhe** am Objektstandort — nichts wird - in Hügel begraben. Steckt ein Objekt im Boden (z. B. nach dem - Terrain-Formen), bewegt Absenken es NICHT, und die Statusleiste erklärt, - dass `▲ Anheben` oder `⚡ Alles auf den Boden setzen` hilft. + - **Absenken / VERSENKEN (Karte t_e166e656):** Absenken geht FREI unter die + Terrain-Höhe — Steine dürfen halb im Boden stecken (Sly-Wunsch). + Untergrenze ist die Match-Grenze `bounds.min.y`; die Auswahl-Anzeige und + die Statusleiste zeigen die Versenk-Tiefe („1,00 m im Boden versenkt“). + „Auf Boden setzen“ holt ein versenktes Objekt wieder exakt auf das + Terrain. - **Anheben** hat als Obergrenze die Match-Grenze `bounds.max.y` — höher passt der Server nicht (V9). - **Ziehen behält die Höhe:** Ein angehobenes Objekt schwebt beim @@ -203,6 +205,11 @@ Undo für alles): (Herleitung im Quellcode: gespiegelter Richtungs-Vektor = gespiegelter Winkel; jede Spiegelung ist dadurch selbstinvers — zweimal spiegeln = Ausgangszustand.) Die Rotation wird mitgespiegelt, wie bestellt. + KIPPEN wird mitgespiegelt (Karte t_e166e656): Achsenspiegelung = + Konjugation der Basis `M·B·M` — X-Kippen bleibt bei X-Spiegelung, + Z-Kippen dreht das Vorzeichen (und umgekehrt); Punktspiegelung = + `Ry(180°)·B`. Für reines Y-Drehen ergeben beide Regeln exakt die alten + Zahlenwerte (Doku: docs/map_format.md im Match-Server-Repo). Bewusst NICHT um die Kartemitte gespiegelt: das würde das Objekt quer über die Map springen lassen — überraschend für Einsteiger. Klartext in Buttons + Tooltip. diff --git a/docs/tasten.md b/docs/tasten.md index 708d1e9..5647c1f 100644 --- a/docs/tasten.md +++ b/docs/tasten.md @@ -57,9 +57,11 @@ alle, Strg+G gruppieren / Strg+Umschalt+G lösen. | Eingabe | Wirkung | | --- | --- | | **R** | Auswahl 90° drehen (Vierteldrehung; Umschalt+R = −90°). Szene-Objekte drehen um den Auswahl-Mittelpunkt, Bauen-Bauteile um ihren Fußpunkt. Ein Objekt aus einer Gruppe dreht die GANZE Gruppe. Dauerhaft (kein Vorschau-Zustand) | +| **Strg+R / Strg+Umschalt+R** | Auswahl um X **kippen** (+15° / −15°) — auch im Platzier-Modus (Ghost kippt mit). Karte t_e166e656 | +| **Alt+R / Alt+Umschalt+R** | Auswahl um Z **kippen** (+15° / −15°) — auch im Platzier-Modus. Karte t_e166e656 | | Mausrad (mit Auswahl) | Feindrehen im 15°-Raster (bleibt wie bisher) | | **Pfeiltasten** | Auswahl im **2-m-Raster** verschieben — EINE Zelle pro Tastendruck, Richtung = Kamera-Ausrichtung nächste Weltachse (Godot-Editor-Konvention: ↑ = von der Kamera weg). Fußpunkt bleibt am Raster, Höhe wandert mit | -| **Bild↑ / Bild↓** | Auswahl +0,25 m / −0,25 m anheben/absenken (Mehrfachdruck = EIN Undo-Schritt) | +| **Bild↑ / Bild↓** | Auswahl +0,25 m / −0,25 m anheben/absenken (Mehrfachdruck = EIN Undo-Schritt). Absenken UNTER die Terrain-Höhe ist erlaubt (Versenken — z. B. Steine im Boden); Untergrenze ist die Match-Grenze | | **Strg+Bild↑** | Auswahl direkt auf das Terrain/Boden absetzen (Godot „Snap Object to Floor“-Äquivalent fürs Fußpunkt-Modell) | | **G** | Auswahl **greifen** (Blender-Semantik): sie hängt an der Maus, Linksklick setzt die neue Position (mit Raster-Snap bzw. Strg-Zwang), **Esc** bricht ab und setzt die exakten alten Positionen zurück. Greifen der ganzen Gruppe/mehreren Objekte wandert gemeinsam | | **Entf (DELETE)** | Auswahl SOFORT löschen, ohne Rückfrage — Strg+Z holt alles zurück. Wirkt auf Szene-Auswahl UND Objektbaum-Markierung | diff --git a/src/editor/group_ops.gd b/src/editor/group_ops.gd index 768b339..bb3defc 100644 --- a/src/editor/group_ops.gd +++ b/src/editor/group_ops.gd @@ -9,7 +9,7 @@ class_name GroupOps ## Editor-Sitzungsstatus. Begründung (Loader-Kompatibilität nachgewiesen, ## src/maps/svmap_loader.gd im Match-Server): ## - V4 prüft nur PFLICHT-Felder; unbekannte Zusatz-Keys je Objekt werden -## nicht abgelehnt (Loader liest nur id/pos/scale/rot_y_deg). +## nicht abgelehnt (Loader liest id/pos/scale/rot_y_deg + optionales rot). ## - Die sha256-Prüfsumme läuft über das GESAMTE kanonisierte Dict — das ## Feld reist automatisch mit, ohne dass Prüfsummen-Code sich ändert. ## - Alte Maps ohne "group"-Feld bleiben 1:1 ladbar (Feld abwesend = @@ -175,7 +175,11 @@ static func rotate_members(doc: Dictionary, indices: Array, delta_deg: float) -> or absf(y - float(pos[1])) > 0.0001: changed += 1 obj["pos"] = [x, y, z] - obj["rot_y_deg"] = fmod(float(obj.get("rot_y_deg", 0.0)) + delta_deg + 3600.0, 360.0) + # 3 Achsen (Karte t_e166e656): Gruppendrehung dreht die Y-Komponente + # und lässt Kippen (X/Z) unverändert — rot/rot_y_deg bleiben synchron. + var euler := SvmapIO.objekt_rotation(obj) + SvmapIO.rot_setzen(obj, Vector3( + euler.x, fmod(euler.y + delta_deg + 3600.0, 360.0), euler.z)) return changed diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 61be914..15476c2 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -18,6 +18,7 @@ extends Node3D ## main.gd verdrahtet Referenzen per setup(). const ROT_STEP_DEG := 15.0 +const TILT_STEP_DEG := 15.0 # Kipp-Schritt (Karte t_e166e656: Strg+R / Alt+R) const DRAG_THRESHOLD_PX := 4.0 const GRID_M := 1.0 # Raster-Snap-Weite (Objekt-Werkzeuge links, Karte t_3a998ae4) @@ -32,6 +33,8 @@ var view_builder # script-erweitert, Variant var placing := false var place_oid := "" var place_rot_deg := 0.0 +var place_tilt_x := 0.0 # Kippen X beim Platzieren (Karte t_e166e656, Strg+R) +var place_tilt_z := 0.0 # Kippen Z beim Platzieren (Karte t_e166e656, Alt+R) # U5 (t_5e3e97c9): Shift beim Setz-Klick = Serienmodus — weiter setzen wie # bisher (Ghost bleibt an, jeder Klick setzt). Ohne Shift wird EIN Objekt # gesetzt und das Werkzeug beendet sich selbst; das neue Objekt ist ausgewählt. @@ -199,6 +202,8 @@ func start_placement(oid: String) -> void: place_oid = oid placing = true place_rot_deg = 0.0 + place_tilt_x = 0.0 + place_tilt_z = 0.0 _place_series = false # U5: jeder frische Start startet im Einmal-Modus _ghost_active = false refresh_terrain_cache() # Cursor trifft das GELÄNDE — Cache muss frisch sein @@ -275,10 +280,19 @@ func _ghost_refresh_mesh() -> void: func _ensure_ghost_visible() -> void: _ghost_refresh_mesh() - _ghost.rotation.y = deg_to_rad(place_rot_deg) + _ghost_rotation_anwenden() _ghost.visible = true +## Ghost-Rotation inkl. Kippen (Karte t_e166e656): Euler (X-Kippen, Y-Drehung, +## Z-Kippen) — derselbe Weg wie beim platzierten Objekt (rot_setzen schreibt +## genau diese drei Werte). +func _ghost_rotation_anwenden() -> void: + if _ghost == null or not is_instance_valid(_ghost): + return + _ghost.rotation_degrees = Vector3(place_tilt_x, place_rot_deg, place_tilt_z) + + ## Raster-Snap (wenn in main angeschaltet ODER Strg gehalten wird — temporärer ## Snap-Zwang, Karte t_55257792): X/Z auf ganze Meter rasten — Objekte passen ## dann nebeneinander wie Bauklötze. Höhe bleibt Terrain. @@ -345,12 +359,16 @@ func _place_object(viewport_pos: Vector2, mb_shift_held := false) -> void: # EIN Pfad für Ghost UND Platzierung (Karte t_4092e3a0): derselbe Fußpunkt # wie die Vorschau — Raster-Snap + Terrain-Höhe am gerasteten Punkt. var pos_f := [foot.x, foot.y, foot.z] - (main.doc["objects"] as Array).append({ + var neues_obj := { "id": place_oid, "pos": pos_f, - "scale": 1.0, + "scale": 1.0, # Karte t_e166e656: Scale ist FIX — immer Originalgröße "rot_y_deg": float(place_rot_deg), - }) + } + # Karte t_e166e656: gekippt platziert → rot-Feld (Euler xyz) in die Datei. + if absf(place_tilt_x) > 0.0001 or absf(place_tilt_z) > 0.0001: + SvmapIO.rot_setzen(neues_obj, Vector3(place_tilt_x, float(place_rot_deg), place_tilt_z)) + (main.doc["objects"] as Array).append(neues_obj) 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. @@ -523,6 +541,52 @@ func rotate_selected(delta_deg: float) -> void: main.call("_sync_selection_panel") +## Auswahl KIPPEN (Karte t_e166e656 — Sly: „nicht nur rotieren, sondern auch +## kippen in sämtliche Achsen“). axis_x = Kippen um die X-Achse (vor/zurück +## neigen), sonst um die Z-Achse (links/rechts neigen). Ein Einzelobjekt in +## einer Gruppe kippt die GANZE Gruppe (wie beim Drehen), Mehrfachauswahl +## kippt jedes Objekt um denselben Betrag, Symmetrie-Zwillinge laufen im +## SELBEN Commit mit. rot/rot_y_deg bleiben über SvmapIO.rot_setzen synchron. +func tilt_selected(axis_x: bool, delta_deg: float) -> void: + if selected_index < 0 or selected_indices.is_empty(): + return + var objects: Array = main.doc.get("objects", []) + if selected_index >= objects.size(): + return + var members := selected_indices.duplicate() + if members.size() == 1: + var gid := GroupOps.group_id(objects[members[0]]) + if gid != GroupOps.UNGROUPED: + members = GroupOps.members_of(main.doc, gid) + select_many(members, true) + main.call("_sync_selection_panel") + var before: Dictionary = main.history.snapshot(["objects"]) + var axis := "X" if axis_x else "Z" + for i in members: + var obj: Dictionary = objects[i] + var euler := SvmapIO.objekt_rotation(obj) + var neu := Vector3(euler.x, euler.y, euler.z) + if axis_x: + neu.x = fposmod(euler.x + delta_deg + 3600.0, 360.0) + else: + neu.z = fposmod(euler.z + delta_deg + 3600.0, 360.0) + SvmapIO.rot_setzen(obj, neu) + _sync_members_to_markers(members) + var twins: Array = ObjectOps.symmetry_sync_pairs( + main.doc, members, _sym_mode(), selected_index) + _sync_members_to_markers(twins) + var tag := "tilt:%d" % selected_index if members.size() == 1 \ + else "tiltmulti:%s" % str(members) + main.history.commit("Auswahl gekippt (%s, %d Objekte)" % [axis, members.size()], + before, main.history.snapshot(["objects"]), tag) + _mark_dirty() + main.call("_refresh_history_buttons") + main.call("_refresh_stats") + main.call("_sync_selection_panel") + main.call("_set_status", "Auswahl um %s%s gekippt — Inspektor zeigt die Werte; Strg+Z nimmt den Schritt zurück." % [ + ("+%.0f°" % delta_deg) if delta_deg > 0.0 else ("%.0f°" % delta_deg), axis]) + + ## Nach Doc-Änderung die betroffenen Marker positionieren/drehen (kein Rebuild). func _sync_members_to_markers(members: Array) -> void: var objects: Array = main.doc.get("objects", []) @@ -534,7 +598,8 @@ func _sync_members_to_markers(members: Array) -> void: continue var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0]) marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) - marker.rotation.y = deg_to_rad(float((objects[i] as Dictionary).get("rot_y_deg", 0.0))) + # Karte t_e166e656: volle 3-Achsen-Rotation (rot-Feld oder Fallback rot_y_deg). + marker.rotation_degrees = SvmapIO.objekt_rotation(objects[i] as Dictionary) func delete_selected() -> void: @@ -678,7 +743,7 @@ func lift_selected(delta_m: float) -> void: if delta_m > 0.0: ground = "Die Match-Höhe ist erreicht — höher passt der Server nicht mehr." else: - ground = "Weiter absenken geht nicht: Das Objekt liegt am Boden. Steckt es IN einem Hügel? ▲ Anheben oder \"⚡ Alles auf den Boden setzen\" holt es raus." + ground = "Die untere Match-Grenze ist erreicht — tiefer passt der Server nicht mehr (Versenken im Boden ist erlaubt, Karte t_e166e656)." main.call("_set_status", ground) return var after: Dictionary = main.history.snapshot(["objects"]) @@ -697,8 +762,15 @@ func lift_selected(delta_m: float) -> void: main.call("_refresh_stats") main.call("_refresh_history_buttons") main.call("_sync_selection_panel") - main.call("_set_status", "%s auf %.2f m — mehrmals drücken stapelt weiter; Strg+Z nimmt alle Stufen auf einmal zurück." % [ - _lift_label(members, delta_m, applied_sum), float((objects[selected_index]["pos"] as Array)[1])]) + # Karte t_e166e656: Versenk-Tiefe sichtbar machen (Stein halb im Boden). + var sel_pos: Array = (objects[selected_index]["pos"] as Array) + var boden := SvmapDocument.terrain_height_at(main.doc, float(sel_pos[0]), float(sel_pos[2])) + var tiefe := boden - float(sel_pos[1]) + var versenk := "" + if tiefe > 0.01: + versenk = " (%.2f m versenkt)" % tiefe + main.call("_set_status", "%s auf %.2f m%s — mehrmals drücken stapelt weiter; Strg+Z nimmt alle Stufen auf einmal zurück." % [ + _lift_label(members, delta_m, applied_sum), float(sel_pos[1]), versenk]) ## Heben/Absenken wirkt auf die GANZE Auswahl (Karte t_55257792) — ein einzelnes @@ -1111,7 +1183,7 @@ func _unhandled_input(event: InputEvent) -> void: return # Mausrad = Flug-Tempo (orbit_camera), kein Objekt-Drehen if placing: place_rot_deg = fmod(place_rot_deg + ROT_STEP_DEG + 3600.0, 360.0) - _ghost.rotation.y = deg_to_rad(place_rot_deg) + _ghost_rotation_anwenden() get_viewport().set_input_as_handled() elif selected_index >= 0 and not _grabbing: rotate_selected(ROT_STEP_DEG) @@ -1121,7 +1193,7 @@ func _unhandled_input(event: InputEvent) -> void: return # Mausrad = Flug-Tempo (orbit_camera), kein Objekt-Drehen if placing: place_rot_deg = fmod(place_rot_deg - ROT_STEP_DEG + 3600.0, 360.0) - _ghost.rotation.y = deg_to_rad(place_rot_deg) + _ghost_rotation_anwenden() get_viewport().set_input_as_handled() elif selected_index >= 0 and not _grabbing: rotate_selected(-ROT_STEP_DEG) @@ -1342,7 +1414,7 @@ func demo_park_ghost(world_pos: Vector3, oid: String) -> void: place_oid = oid _ghost_refresh_mesh() _ghost.position = world_pos - _ghost.rotation.y = deg_to_rad(place_rot_deg) + _ghost_rotation_anwenden() _ghost.visible = true _ghost_active = true main.call("_highlight_library", oid) diff --git a/src/editor/object_ops.gd b/src/editor/object_ops.gd index 84ecbab..3edd966 100644 --- a/src/editor/object_ops.gd +++ b/src/editor/object_ops.gd @@ -134,12 +134,11 @@ static func duplicate_objects(doc: Dictionary, indices: Array, grid_enabled: boo ## Objekte dürfen vom Terrain abweichen — die Höhe wohnt direkt in object.pos.y ## (das svmap-Format erlaubt freie y-Werte innerhalb der Match-Grenzen, V9 — ## bewusst KEIN Extra-Format-Feld → Loader-Kompatibilität unangetastet). -## Regeln (dokumentiert, README): +## Regeln (Karte t_e166e656, Platzierungsfreiheit — Sly: „Steine versenken!"): ## - Anheben: frei nach oben, Höchstgrenze = bounds.max.y (Match-Grenze, V9). -## - Absenken: stoppt an der Terrain-Höhe am Objektstandort — nichts wird -## in Hügel begraben. Ein Objekt, das unter der Oberfläche steckt (z. B. -## nach dem Terrain-Formen), bewegt sich beim Absenken NICHT (0.0 zurück -## — die UI erklärt, dass Anheben oder Snap-to-Ground hilft). +## - Absenken: FREI unter die Terrain-Höhe (Versenken — der Stein darf +## halb im Boden stecken). Untergrenze = bounds.min.y; fehlt bounds → +## −80 m als sinnvolle Default-Grenze. ## Rückgabe: tatsächlich angewendete Differenz in Metern (0.0 = nichts ## passiert — kein Commit; die UI gibt eine Erklärung statt). static func lift_object(doc: Dictionary, index: int, delta_m: float) -> float: @@ -154,17 +153,13 @@ static func lift_object(doc: Dictionary, index: int, delta_m: float) -> float: return 0.0 var y := float(pos[1]) var max_y := 80.0 + var min_y := -80.0 var bounds: Dictionary = doc.get("bounds", {}) if (bounds.get("max", null) is Array) and ((bounds["max"] as Array).size() == 3): max_y = float(bounds["max"][1]) - var target: float - if delta_m >= 0.0: - target = minf(y + delta_m, max_y) - else: - var floor_y := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) - if y < floor_y - FLOAT_EPSILON: - return 0.0 # steckt im Boden — Absenken macht das nicht schlimmer - target = maxf(y + delta_m, floor_y) + if (bounds.get("min", null) is Array) and ((bounds["min"] as Array).size() == 3): + min_y = float(bounds["min"][1]) + var target := clampf(y + delta_m, min_y, max_y) var applied := target - y if absf(applied) < 0.0001: return 0.0 @@ -179,10 +174,22 @@ static func lift_object(doc: Dictionary, index: int, delta_m: float) -> float: ## Herleitung: Blickrichtungs-Vektor d(θ) = (−sin θ, −cos θ); Spiegelung ## negiert die jeweilige Komponente → −θ bzw. 180° − θ. Jede Spiegelung ist ## dadurch selbstinvers (zweimal spiegeln = Ausgangszustand). +## 3 Achsen (Karte t_e166e656): Kippen wird mitgespiegelt — Blickachse X +## bleibt bei X-Spiegelung unverändert, Rollen (Z) dreht das Vorzeichen; +## bei Z-Spiegelung umgekehrt. rot/rot_y_deg bleiben via SvmapIO.rot_setzen +## synchron (reines Y → kein rot-Feld, Datei legacy-sauber). static func mirror_object(obj: Dictionary, axis: String) -> void: - var rot := float(obj.get("rot_y_deg", 0.0)) - var mirrored := -rot if axis == "x" else fmod(180.0 - rot, 360.0) - obj["rot_y_deg"] = fmod(mirrored + 3600.0, 360.0) + var euler := SvmapIO.objekt_rotation(obj) + if axis == "x": + SvmapIO.rot_setzen(obj, Vector3( + fposmod(euler.x, 360.0), + fposmod(-euler.y, 360.0), + fposmod(-euler.z, 360.0))) + else: + SvmapIO.rot_setzen(obj, Vector3( + fposmod(-euler.x, 360.0), + fposmod(180.0 - euler.y, 360.0), + fposmod(euler.z, 360.0))) ## Höhe ÜBER dem Terrain am Objektstandort (> 0 = schwebt, ~0 = steht auf @@ -269,7 +276,10 @@ static func symmetry_place_twin(doc: Dictionary, index: int, mode: int) -> Dicti # 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))) + # 3 Achsen (Karte t_e166e656): Kippen wird mitgespiegelt; für reines + # Y-Drehen exakt die alte mirror_rot_y-Regel (gleiche Zahlenwerte). + SvmapIO.rot_setzen(twin, SymmetryCore.mirror_rot_euler( + mode, SvmapIO.objekt_rotation(src))) var key := SymmetryCore.next_sym_key(doc) SymmetryCore.set_sym(src, key) SymmetryCore.set_sym(twin, key) @@ -323,7 +333,10 @@ static func symmetry_sync_pairs(doc: Dictionary, members: Array, mode: int, 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))) + # 3 Achsen (Karte t_e166e656): Kippen wird mitgespiegelt; für reines + # Y-Drehen exakt die alte mirror_rot_y-Regel (gleiche Zahlenwerte). + SvmapIO.rot_setzen(twin, SymmetryCore.mirror_rot_euler( + mode, SvmapIO.objekt_rotation(src))) changed.append(twin_i) return changed diff --git a/src/editor/symmetry_core.gd b/src/editor/symmetry_core.gd index 4767eb1..909830d 100644 --- a/src/editor/symmetry_core.gd +++ b/src/editor/symmetry_core.gd @@ -187,3 +187,36 @@ static func next_sym_key(doc: Dictionary) -> String: while used.has(n): n += 1 return "sym_%d" % n + + +# ---------------------------------- Spiegel-Mathe 3 Achsen (t_e166e656) ------ + +## Spiegelt eine VOLLE Euler-Orientierung (Karte t_e166e656, Platzierungs- +## freiheit). Definition (dokumentiert in docs/map_format.md §objects.rot): +## - Achsenspiegelung (AXIS_X / AXIS_Z) = Konjugation der Basis mit der +## Spiegelmatrix: B' = M·B·M — für reines Y-Drehen exakt die bisherige +## Regel mirror_rot_y (−rot). +## - Punktspiegelung (POINT) = 180°-Drehung um Y: B' = Ry(180°)·B — für +## reines Y-Drehen exakt mirror_rot_y (+180°). +## Godot-Euler-Reihenfolge YXZ (Node3D-Rotation), Rückgabe in Grad. +static func mirror_rot_euler(mode: int, rot_deg: Vector3) -> Vector3: + var b := Basis.from_euler(Vector3( + deg_to_rad(rot_deg.x), deg_to_rad(rot_deg.y), deg_to_rad(rot_deg.z))) + var gespiegelt: Basis + match mode: + Mode.POINT: + gespiegelt = Basis.from_euler(Vector3(0.0, deg_to_rad(180.0), 0.0)) * b + Mode.AXIS_X: + var m := Basis.from_scale(Vector3(-1.0, 1.0, 1.0)) + gespiegelt = m * b * m + Mode.AXIS_Z: + var m := Basis.from_scale(Vector3(1.0, 1.0, -1.0)) + gespiegelt = m * b * m + _: + return rot_deg + # Normalisierung [0, 360) je Achse — gleiche Darstellung wie rot_y_deg + # bisher (tests/test_object_mirror.gd erwartet z. B. 330 statt −30). + return Vector3( + fposmod(gespiegelt.get_euler().x * (180.0 / PI), 360.0), + fposmod(gespiegelt.get_euler().y * (180.0 / PI), 360.0), + fposmod(gespiegelt.get_euler().z * (180.0 / PI), 360.0)) diff --git a/src/editor/symmetry_ops.gd b/src/editor/symmetry_ops.gd index 03c54c6..b044c0f 100644 --- a/src/editor/symmetry_ops.gd +++ b/src/editor/symmetry_ops.gd @@ -231,11 +231,20 @@ static func _obj_pair_differs(src: Dictionary, twin: Dictionary, mp: Vector3, return true if absf(tpos.y - mp.y) > POS_EPS_M: return true - var trot := fposmod(float(twin.get("rot_y_deg", 0.0)) - - SymmetryCore.mirror_rot_y(mode, float(src.get("rot_y_deg", 0.0))), 360.0) + # 3 Achsen (Karte t_e166e656): der Vergleich läuft über die gespiegelte + # Euler-Orientierung (für reines Y-Drehen identisch zur alten rot_y-Regel). + var gespiegelt := SymmetryCore.mirror_rot_euler(mode, SvmapIO.objekt_rotation(src)) + var twin_euler := SvmapIO.objekt_rotation(twin) + var trot := fposmod(twin_euler.y - gespiegelt.y, 360.0) var rotd := minf(trot, 360.0 - trot) if rotd > ROT_EPS_DEG: return true + var kipp := fposmod(twin_euler.x - gespiegelt.x + 180.0, 360.0) - 180.0 + if absf(kipp) > ROT_EPS_DEG: + return true + kipp = fposmod(twin_euler.z - gespiegelt.z + 180.0, 360.0) - 180.0 + if absf(kipp) > ROT_EPS_DEG: + return true if absf(float(twin.get("scale", 1.0)) - float(src.get("scale", 1.0))) > VALUE_EPS: return true if str(twin.get("id", "")) != str(src.get("id", "")): @@ -545,7 +554,10 @@ static func _apply_report(c: Dictionary, quelle_hoch: bool, mode: int) -> String static func _write_object_from(twin: Dictionary, donor: Dictionary, mp: Vector3, mode: int) -> void: twin["pos"] = [mp.x, mp.y, mp.z] - twin["rot_y_deg"] = SymmetryCore.mirror_rot_y(mode, float(donor.get("rot_y_deg", 0.0))) + # 3 Achsen (Karte t_e166e656): Kippen wird mitgespiegelt; für reines + # Y-Drehen exakt die alte mirror_rot_y-Regel (gleiche Zahlenwerte). + SvmapIO.rot_setzen(twin, SymmetryCore.mirror_rot_euler( + mode, SvmapIO.objekt_rotation(donor))) twin["scale"] = donor.get("scale", 1.0) if donor.has("tint"): twin["tint"] = (donor["tint"] as Array).duplicate() diff --git a/src/svmap/kollider_bau.gd b/src/svmap/kollider_bau.gd index 816a325..a5f699a 100644 --- a/src/svmap/kollider_bau.gd +++ b/src/svmap/kollider_bau.gd @@ -69,7 +69,9 @@ static func baue_welt_kollider(data: Dictionary, manifest: Dictionary = {}) -> N var marker := Node3D.new() marker.name = "Kollider_%d_%s" % [i, oid] marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) - marker.rotation.y = deg_to_rad(float(obj.get("rot_y_deg", 0.0))) + # Karte t_e166e656: 3-Achsen-Rotation — `rot` (Euler xyz) oder Fallback + # rot_y_deg, identische Regel wie im Client/Server (SvmapIO.objekt_rotation). + marker.rotation_degrees = SvmapIO.objekt_rotation(obj) marker.add_child(kollider(col_spec, scale)) root.add_child(marker) return root @@ -234,3 +236,25 @@ static func halbe_hoehe(shape: Shape3D) -> float: if shape is SphereShape3D: return (shape as SphereShape3D).radius return 0.0 + + +## Konservative Hindernis-AABB einer GEDREHTEN Box (Karte t_e166e656): alle +## 8 lokalen Ecken der Kollisions-Box × Basis der Objekt-Rotation, dann die +## umschließende AABB. Identische Formel wie Server (svmap_loader) und Client +## (svmap_world) — Identitäts-Vertrag der Playtest-Hindernisboxen. +static func hindernis_aabb(obj: Dictionary, col: Dictionary, scale: float) -> AABB: + var size: Array = col.get("size", [1.0, 1.0, 1.0]) + var half_ext := Vector3(float(size[0]), float(size[1]), float(size[2])) * scale * 0.5 + var rot := SvmapIO.objekt_rotation(obj) + var basis := Basis.from_euler(Vector3(deg_to_rad(rot.x), deg_to_rad(rot.y), deg_to_rad(rot.z))) + # Zentriert über dem Fußpunkt (Fußpunkt-Konvention: Form sitzt halbe Höhe + # über pos — mit Basis mitgedreht, wie die CollisionShape im Body). + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + var center := basis * Vector3(0.0, half_ext.y, 0.0) \ + + Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + var out := AABB(center + basis * Vector3(-half_ext.x, -half_ext.y, -half_ext.z), Vector3.ZERO) + for sx in [-half_ext.x, half_ext.x]: + for sy in [-half_ext.y, half_ext.y]: + for sz in [-half_ext.z, half_ext.z]: + out = out.expand(center + basis * Vector3(sx, sy, sz)) + return out diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index bc4edd5..62fa374 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -302,6 +302,17 @@ static func _validate_objects(objects: Array, bounds: Dictionary, manifest: Dict var rot = obj.get("rot_y_deg", 0.0) if not _is_finite_num(rot): errors.append("V9: objects[%d]: rot_y_deg muss endlich sein." % i) + # V17 (Karte t_e166e656, Platzierungsfreiheit): optionales `rot` — + # Euler x/y/z in Grad (Godot-Konvention YXZ). Vorhanden → er ersetzt + # rot_y_deg KOMPLETT (3-Achsen-Kippen); fehlt er → Fallback rot_y_deg. + # Identischer Check wie im Match-Server (svmap_loader._validate_objects). + var rot_v: Variant = obj.get("rot", null) + if rot_v != null: + if not (rot_v is Array) or (rot_v as Array).size() != 3 \ + or not _is_finite_num(rot_v[0]) \ + or not _is_finite_num(rot_v[1]) \ + or not _is_finite_num(rot_v[2]): + errors.append("V17: objects[%d]: rot muss [x, y, z] mit endlichen Zahlen sein (ist %s)." % [i, str(rot_v)]) # V16 (Karte t_f5935023): optionales `sym` (Symmetrie-Partner-Kennung). # Editor-Format-Erweiterung: der Match-Server ignoriert unbekannte # Felder, die Prüfsumme läuft über die ganze Payload → kompatibel. @@ -609,3 +620,31 @@ static func load_from_file_resign(path: String) -> Dictionary: result["ok"] = true result["data"] = data return result + + +# ------------------------------------- Platzierungsfreiheit (Karte t_e166e656) -- + +## Objekt-Rotation als Euler-Grad (Karte t_e166e656): `rot` (Euler x/y/z in +## Grad, Godot-Konvention YXZ) vorhanden → verwenden; fehlt er → Fallback +## `rot_y_deg` (nur Y — alte Karten unverändert). Identische Regel im +## Quest-Client (svmap_world._objekt_rotation) und Match-Server +## (svmap_loader._objekt_rotation) — Identitäts-Vertrag über alle drei Repos. +static func objekt_rotation(obj: Dictionary) -> Vector3: + var rot_v: Variant = obj.get("rot", null) + if rot_v is Array and (rot_v as Array).size() == 3: + var a: Array = rot_v + return Vector3(float(a[0]), float(a[1]), float(a[2])) + return Vector3(0.0, float(obj.get("rot_y_deg", 0.0)), 0.0) + + +## rot-Feld KORREKT ins Objekt schreiben (Editor-Seite der Regel): nur wenn +## tatsächlich um X oder Z gekippt ist, wird `rot` geschrieben (zusätzlich +## bleibt rot_y_deg immer aktuell = die Y-Komponente, damit alte Builds die +## Drehung wenigstens um Y richtig lesen). x = z = 0 → Feld wieder entfernen +## (Datei bleibt legacy-sauber). +static func rot_setzen(obj: Dictionary, euler: Vector3) -> void: + obj["rot_y_deg"] = euler.y + if absf(euler.x) < 0.0001 and absf(euler.z) < 0.0001: + obj.erase("rot") + else: + obj["rot"] = [euler.x, euler.y, euler.z] diff --git a/src/ui/help_text.gd b/src/ui/help_text.gd index ed9d074..0218c41 100644 --- a/src/ui/help_text.gd +++ b/src/ui/help_text.gd @@ -34,8 +34,10 @@ static func text() -> String: out += _header("Objekt-Manipulation") out += _table(_rows([ ["R", "90° drehen (Umschalt+R = −90°, ganze Gruppe dreht mit; Mausrad: 15° fein)"], + ["Strg+R / Strg+Umschalt+R", "Kippen um X (+15° / −15°) — auch beim Platzieren (Karte t_e166e656)"], + ["Alt+R / Alt+Umschalt+R", "Kippen um Z (+15° / −15°) — auch beim Platzieren (Karte t_e166e656)"], ["Pfeiltasten", "Auswahl im 2-m-Raster verschieben (Kamera-nächste Weltachse)"], - ["Bild↑ / Bild↓", "+0,25 m / −0,25 m anheben/absenken"], + ["Bild↑ / Bild↓", "+0,25 m / −0,25 m anheben/absenken — Absenken UNTER den Boden erlaubt (Versenken, z. B. Steine)"], ["Strg+Bild↑", "Auswahl aufs Terrain/Boden absetzen"], ["Strg halten", "temporärer Snap-Zwang (rastet auf ganze Meter)"], ["Strg+D", "Auswahl duplizieren (+2 m versetzt)"], diff --git a/src/ui/inspector_panel.gd b/src/ui/inspector_panel.gd index 2e54207..5ff9df3 100644 --- a/src/ui/inspector_panel.gd +++ b/src/ui/inspector_panel.gd @@ -14,15 +14,18 @@ extends VBoxContainer ## (Enter/Klick woanders), Ziehen = ein Schritt beim Loslassen. ## - Mehrfachauswahl: Änderungen wirken auf ALLE markierten; ungleiche Werte ## zeigen „—“ und werden erst beim Tippen/Ziehen angeglichen. -## - Doc-Format ist bewusst UNVERÄNDERT: pos [x,y,z], rot_y_deg (nur Y), -## scale (einheitlicher Faktor) — das svmap-Format kennt keine X/Z-Drehung -## und keine Achsen-einzelne Größe, deshalb gibt es die Felder hier nicht. -## - X/Z werden an der Map geklemmt, Y am Terrain (nichts im Boden begraben) -## und an der Höhen-Obergrenze, Größe auf 0,05–20. +## - SCALE IST FEST (Karte t_e166e656, Sly-Wunsch „Realismus“): die scale-Zeile +## ist bewusst ENTFERNT — neue Objekte bekommen immer scale 1.0, alte Karten +## laden mit ihrem gespeicherten scale (Format bleibt kompatibel). Hier gibt +## es nur den lesenden Hinweis „Größe fix“. +## - KIPPEN IN SÄMTLICHEN ACHSEN (Karte t_e166e656): das svmap-Feld `rot` +## (Euler x/y/z, OPTIONAL) trägt X/Z-Kippen; fehlt es, gilt rot_y_deg. +## rot/rot_y_deg bleiben über SvmapIO.rot_setzen synchron. +## - X/Z werden an der Map geklemmt, Y an den Match-Grenzen — Versenken UNTER +## die Terrain-Höhe ist ausdrücklich erlaubt (Steine im Boden), die +## Info-Zeile zeigt die Versenk-Tiefe. const EPS := 0.005 # Toleranz „gleicher Wert“ (Anzeige-Konsens) -const SCALE_MIN := 0.05 -const SCALE_MAX := 20.0 const MIXED := "—" # Anzeige für ungleiche Werte in der Auswahl @@ -152,7 +155,6 @@ var _sub: Label var _grid: GridContainer var _info: Label var _hint: Label -var _reset_btn: Button var _ground_btn: Button var _farben_grid: GridContainer # Farbfeld-Raster (Karte t_b0edb807) var _farbe_reset_btn: Button @@ -189,13 +191,13 @@ func setup(p_main) -> void: # --- Position X/Y/Z --- _grid.add_child(_field("Position")) var px := _make_row("pos_x", "X", "Position X in Meter — ZIEHEN am Label ändert live, TIPPEN setzt exakt (Enter bestätigt).", 0.05, "%.2f", " m") - var py := _make_row("pos_y", "Y", "Höhe (Fußpunkt) in Meter — ziehen/tippen; nie unter den Boden.", 0.05, "%.2f", " m") + var py := _make_row("pos_y", "Y", "Höhe (Fußpunkt) in Meter — ziehen/tippen. UNTER die Terrain-Höhe absenken ist erlaubt (Versenken, Karte t_e166e656), Untergrenze = Match-Grenze.", 0.05, "%.2f", " m") var pz := _make_row("pos_z", "Z", "Position Z in Meter — ziehen/tippen.", 0.05, "%.2f", " m") # Zieh-Grenzen grob an der Map orientieren (doc-Apply klemmt exakt). var half_span: float = 1024.0 px.min_v = -half_span px.max_v = half_span - py.min_v = 0.0 + py.min_v = -200.0 # Versenken erlaubt (Karte t_e166e656) — _apply klemmt exakt an bounds.min.y py.max_v = 80.0 pz.min_v = -half_span pz.max_v = half_span @@ -206,22 +208,21 @@ func setup(p_main) -> void: pos_row.add_child(pz) _grid.add_child(pos_row) - # --- Drehung (Y) --- - _grid.add_child(_field("Drehung (Y)")) - _grid.add_child(_make_row("rot_y", "Grad", "Blickrichtung in Grad (−180…180) — ziehen/tippen. Mehr (X/Z) kennt das Karten-Format nicht.", 0.5, "%.1f", "°", -180.0, 180.0)) + # --- Drehen & Kippen (Karte t_e166e656) --- + _grid.add_child(_field("Drehen & Kippen")) + _grid.add_child(_make_row("rot_y", "Drehen Y", "Blickrichtung in Grad (−180…180) — ziehen/tippen. Tasten: R (90°), Mausrad (15° fein).", 0.5, "%.1f", "°", -180.0, 180.0)) + _grid.add_child(_make_row("rot_x", "Kippen X", "Kippen um die X-Achse in Grad (−180…180) — vor/zurück neigen. Tasten: Strg+R / Strg+Umschalt+R.", 0.5, "%.1f", "°", -180.0, 180.0)) + _grid.add_child(_make_row("rot_z", "Kippen Z", "Kippen um die Z-Achse in Grad (−180…180) — links/rechts neigen. Tasten: Alt+R / Alt+Umschalt+R.", 0.5, "%.1f", "°", -180.0, 180.0)) - # --- Größe --- + # --- Größe (FEST, Karte t_e166e656) --- _grid.add_child(_field("Größe")) - _grid.add_child(_make_row("scale", "Faktor", "Größen-Faktor (1,0 = Original) — ziehen/tippen, Bereich 0,05–20.", 0.01, "%.2f", "×", SCALE_MIN, SCALE_MAX)) + var gross_fix := Label.new() + gross_fix.text = "Größe fix (Original) — Assets bleiben in ihrer echten Größe." + gross_fix.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _grid.add_child(gross_fix) var btn_row := HBoxContainer.new() btn_row.add_theme_constant_override("separation", 6) - _reset_btn = Button.new() - _reset_btn.text = "Größe zurücksetzen" - _reset_btn.custom_minimum_size = UIScale.v2(0, 40) - _reset_btn.tooltip_text = "Größe aller markierten Objekte auf 1,0 (Original) zurücksetzen — EIN Rückgängig-Schritt" - _reset_btn.pressed.connect(_on_reset_scale) - btn_row.add_child(_reset_btn) _ground_btn = Button.new() _ground_btn.text = "Auf Boden setzen" _ground_btn.custom_minimum_size = UIScale.v2(0, 40) @@ -264,7 +265,6 @@ func setup(p_main) -> void: # Startzustand: keine Auswahl → nur Hinweis sichtbar (refresh() schaltet um). _grid.visible = false _info.visible = false - _reset_btn.visible = false _ground_btn.visible = false _built = true @@ -424,9 +424,11 @@ func _field_value(obj: Dictionary, field: String) -> float: var p: Array = obj.get("pos", [0.0, 0.0, 0.0]) return float(p[2]) if (p as Array).size() == 3 else 0.0 "rot_y": - return _clamp_norm_deg(float(obj.get("rot_y_deg", 0.0))) - "scale": - return float(obj.get("scale", 1.0)) + return _clamp_norm_deg(SvmapIO.objekt_rotation(obj).y) + "rot_x": + return _clamp_norm_deg(SvmapIO.objekt_rotation(obj).x) + "rot_z": + return _clamp_norm_deg(SvmapIO.objekt_rotation(obj).z) return 0.0 @@ -457,7 +459,6 @@ func refresh() -> void: var objects: Array = _main.doc.get("objects", []) var has := not indices.is_empty() _grid.visible = has - _reset_btn.visible = has _ground_btn.visible = has _info.visible = has _hint.visible = not has @@ -488,7 +489,7 @@ func refresh() -> void: _sub.text = "ID: %s · Kategorie: %s · %d von %d Objekten markiert · Poly: %d" % [ oid if m == 1 else "%s …" % oid, kat, m, objects.size(), int(poly)] - for field in ["pos_x", "pos_y", "pos_z", "rot_y", "scale"]: + for field in ["pos_x", "pos_y", "pos_z", "rot_y", "rot_x", "rot_z"]: var w: DragValue = _rows[field] if w.edit.has_focus(): continue # nicht unter dem tippenden Nutzer wegziehen @@ -502,7 +503,13 @@ func refresh() -> void: # Info-Block (nur lesen). var lift := ObjectOps.lift_offset(_main.doc, indices[0]) - var lift_txt := "am Boden" if absf(lift) < ObjectOps.FLOAT_EPSILON else ("%+.2f m über dem Boden" % lift) + var lift_txt: String + if absf(lift) < ObjectOps.FLOAT_EPSILON: + lift_txt = "am Boden" + elif lift > 0.0: + lift_txt = "%+.2f m über dem Boden" % lift + else: + lift_txt = "%.2f m im Boden versenkt" % -lift var e0: Dictionary = _entry(objs[0]) var col: Dictionary = e0.get("collision", {}) var col_size: Array = col.get("size", []) @@ -576,9 +583,11 @@ func _desc_for(field: String, v: float) -> String: "pos_z": return "%sPosition Z = %.2f m" % [mehr, v] "rot_y": - return "%sDrehung (Y) = %.1f°" % [mehr, v] - "scale": - return "%sGröße = %.2f×" % [mehr, v] + return "%sDrehen (Y) = %.1f°" % [mehr, v] + "rot_x": + return "%sKippen (X) = %.1f°" % [mehr, v] + "rot_z": + return "%sKippen (Z) = %.1f°" % [mehr, v] return "Inspektor-Änderung" @@ -591,6 +600,7 @@ func _apply(field: String, v: float) -> bool: var objects: Array = _main.doc.get("objects", []) var half: Vector2 = _terrain_half() var max_y := _max_y() + var min_y := _min_y() var changed := 0 # Position-Felder bei Mehrfachauswahl: der eingetippte Wert gilt für das # ERSTE markierte Objekt (Anker), alle anderen wandern mit DEMSELBEN Delta @@ -610,23 +620,27 @@ func _apply(field: String, v: float) -> bool: var x := float(pos[0]) var y := float(pos[1]) var z := float(pos[2]) - if field == "rot_y": - obj["rot_y_deg"] = _clamp_norm_deg(v) - changed += 1 - continue - if field == "scale": - var old := clampf(float(obj.get("scale", 1.0)), SCALE_MIN, SCALE_MAX) - var new := clampf(v, SCALE_MIN, SCALE_MAX) - if absf(new - old) < 0.0001: - continue - obj["scale"] = new - _rescale_marker(i, old, new) + if field == "rot_y" or field == "rot_x" or field == "rot_z": + # Karte t_e166e656: rot/rot_y_deg bleiben über rot_setzen synchron + # (reines Y → kein rot-Feld in der Datei). Anzeige normalisiert + # auf −180…180, gespeichert wird der Wert wie getippt. + var euler := SvmapIO.objekt_rotation(obj) + var neu := Vector3(euler.x, euler.y, euler.z) + if field == "rot_y": + neu.y = _clamp_norm_deg(v) + elif field == "rot_x": + neu.x = _clamp_norm_deg(v) + else: + neu.z = _clamp_norm_deg(v) + SvmapIO.rot_setzen(obj, neu) changed += 1 continue if field == "pos_x": x = clampf(float(pos[0]) + rel_delta, -half.x, half.x) elif field == "pos_y": - y = clampf(float(pos[1]) + rel_delta, SvmapDocument.terrain_height_at(_main.doc, x, z), max_y) + # Karte t_e166e656: Versenken erlaubt — Untergrenze ist die + # Match-Grenze (bounds.min.y), NICHT mehr die Terrain-Höhe. + y = clampf(float(pos[1]) + rel_delta, min_y, max_y) elif field == "pos_z": z = clampf(float(pos[2]) + rel_delta, -half.y, half.y) if absf(x - float(pos[0])) > 0.0001 or absf(y - float(pos[1])) > 0.0001 or absf(z - float(pos[2])) > 0.0001: @@ -648,6 +662,15 @@ func _max_y() -> float: return 80.0 +## Untere Match-Grenze (Karte t_e166e656): Versenken im Boden ist erlaubt, +## klemmt an bounds.min.y statt an der Terrain-Höhe. +func _min_y() -> float: + var bounds: Dictionary = _main.doc.get("bounds", {}) + if (bounds.get("min", null) is Array) and ((bounds["min"] as Array).size() == 3): + return float(bounds["min"][1]) + return -80.0 + + func _sync_markers(indices: Array) -> void: var markers: Array = _main.view_builder.object_markers var objects: Array = _main.doc.get("objects", []) @@ -660,43 +683,11 @@ func _sync_markers(indices: Array) -> void: var pos: Array = (objects[i] as Dictionary).get("pos", [0.0, 0.0, 0.0]) if (pos as Array).size() == 3: marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) - marker.rotation.y = deg_to_rad(float((objects[i] as Dictionary).get("rot_y_deg", 0.0))) - - -## Größe live am Marker umsetzen (ohne Rebuild): Mesh-Skalierung + Pick-Metas -## genauso wie map_view_builder._build_objects sie rechnet. -func _rescale_marker(i: int, old_scale: float, new_scale: float) -> void: - var markers: Array = _main.view_builder.object_markers - if i >= markers.size(): - return - var marker = markers[i] - if marker == null or not is_instance_valid(marker): - return - var mi := marker.get_child(0) as MeshInstance3D - if mi == null or mi.mesh == null: - return - var echt: bool = bool(marker.get_meta("glb", false)) - var aabb := mi.mesh.get_aabb() - if echt: - mi.scale = Vector3.ONE * new_scale - var half: Vector3 = (aabb.size * new_scale * 0.5) as Vector3 - marker.set_meta("pick_radius", half.length() + maxf(0.6, half.y * 0.25)) - marker.set_meta("half_height", MeshShapes.center_height(mi.mesh) * new_scale) - else: - # Platzhalter-Mesh ist mit der ALTEN Größe gebacken → relativer Faktor. - var factor := new_scale / maxf(old_scale, 0.0001) - mi.scale = Vector3.ONE * factor - var half: Vector3 = (aabb.size * factor * 0.5) as Vector3 - marker.set_meta("pick_radius", half.length() + maxf(0.6, half.y * 0.25)) - marker.set_meta("half_height", MeshShapes.center_height(mi.mesh) * factor) + marker.rotation_degrees = SvmapIO.objekt_rotation(objects[i] as Dictionary) # ------------------------------------------------------------------- Knöpfe -- -func _on_reset_scale() -> void: - var w: DragValue = _rows["scale"] - w.commit.call(1.0) - func _on_ground() -> void: # Bewährte Boden-Logik (interaction.snap_selection_to_floor): snapshotet, # committet selbst EINEN Schritt und meldet in der Statuszeile. @@ -704,6 +695,6 @@ func _on_ground() -> void: refresh() -## Für Tests/Probes: Widget zu einem Feld holen ("pos_x"…"scale"). +## Für Tests/Probes: Widget zu einem Feld holen ("pos_x"…"rot_z"). func widget(field: String) -> DragValue: return _rows.get(field) diff --git a/src/ui/main.gd b/src/ui/main.gd index bd93ee1..8561323 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -1500,18 +1500,43 @@ func _unhandled_key_input(event: InputEvent) -> void: interaction.grab_begin() get_viewport().set_input_as_handled() KEY_R: - if not ctrl: + var r_delta := -90.0 if event.shift_pressed else 90.0 + if ctrl and not event.alt_pressed: + # Strg+R / Strg+Umschalt+R: KIPPEN um X (±15°, Karte t_e166e656 + # — „nicht nur rotieren, sondern auch kippen“). Im Bauen-Modus + # gibt es kein Kippen (Bauteile sind fix ausgerichtet). + if tool_mode == "bauen": + return + var t_delta: float = -interaction.TILT_STEP_DEG if event.shift_pressed else interaction.TILT_STEP_DEG + if interaction.placing: + interaction.place_tilt_x = fposmod(interaction.place_tilt_x + t_delta + 3600.0, 360.0) + interaction._ghost_rotation_anwenden() + else: + interaction.tilt_selected(true, t_delta) + get_viewport().set_input_as_handled() + elif event.alt_pressed and not ctrl: + # Alt+R / Alt+Umschalt+R: KIPPEN um Z (±15°, Karte t_e166e656). + if tool_mode == "bauen": + return + var t_delta2: float = -interaction.TILT_STEP_DEG if event.shift_pressed else interaction.TILT_STEP_DEG + if interaction.placing: + interaction.place_tilt_z = fposmod(interaction.place_tilt_z + t_delta2 + 3600.0, 360.0) + interaction._ghost_rotation_anwenden() + else: + interaction.tilt_selected(false, t_delta2) + get_viewport().set_input_as_handled() + elif not ctrl and not event.alt_pressed: # R = Vierteldrehung 90° (Karte t_55257792): Bauen-Modus dreht # Vorschau/Auswahl (wie bisher), Objekt-Auswahl UND Ghost drehen # jetzt auch in 90°-Schritten (Umschalt+R = zurück). Feindrehen # 15° bleibt am Mausrad. - var r_delta := -90.0 if event.shift_pressed else 90.0 if tool_mode == "bauen": build_tool.call("rotate_input") get_viewport().set_input_as_handled() return if interaction.placing: interaction.place_rot_deg = wrapf(interaction.place_rot_deg + r_delta, 0.0, 360.0) + interaction._ghost_rotation_anwenden() else: interaction.rotate_selected(r_delta) get_viewport().set_input_as_handled() diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index 665fbce..dccfc0e 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -341,7 +341,9 @@ func _build_objects(objects: Array, manifest: Dictionary) -> void: var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) var marker := Node3D.new() marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) - marker.rotation.y = deg_to_rad(float(obj.get("rot_y_deg", 0.0))) + # Karte t_e166e656: 3-Achsen-Rotation — `rot` (Euler xyz) oder Fallback + # rot_y_deg, identische Regel wie im Client/Server (SvmapIO.objekt_rotation). + marker.rotation_degrees = SvmapIO.objekt_rotation(obj) marker.set_meta("obj_index", i) var mi := MeshInstance3D.new() mi.mesh = mesh diff --git a/tests/probe_platzierung_ident.gd b/tests/probe_platzierung_ident.gd new file mode 100644 index 0000000..d753f0c --- /dev/null +++ b/tests/probe_platzierung_ident.gd @@ -0,0 +1,88 @@ +extends SceneTree + +## Identitäts-Vertrag, PLATZIERUNG (Karte t_e166e656): dieselben Objekte +## (15°-/45°-Kippung + Legacy-Fallback) laufen durch alle DREI Loader — +## A) Editor (SvmapIO.objekt_rotation + KolliderBau.hindernis_aabb) +## B) Client (svmap_world._objekt_rotation + hindernis_aabb) +## C) Server (svmap_loader._objekt_rotation + hindernis_aabb) +## Jeder druckt `IDENT =`-Zeilen; der Runner (run_platzierung_ +## identitaet.sh) verlangt IDENTISCHE Zeilen über alle drei Projekte. +## Das Skript liegt im EDITOR-Repo und wird je Projekt gefahren: +## godot4 --headless --path -s /tests/probe_platzierung_ident.gd +## +## Bewusst load() statt class_name: das Skript läuft in DREI Projekten — +## fremde class_name-Referenzen wären schon PARSE-Fehler im falschen Projekt. + +const OBJEKTE := [ + # Kippen 15° + Y-Drehung + scale 1.5 (Sly-Abnahme-Fall, klein gewählt). + {"id": "probe_a", "pos": [5.0, 3.0, 2.0], "rot_y_deg": 30.0, "scale": 1.5, + "rot": [15.0, 30.0, 0.0]}, + # Kippen 45° um Z (Stein-Anhalt-Fall). + {"id": "probe_b", "pos": [-2.0, 1.0, 7.0], "rot_y_deg": 0.0, "scale": 1.0, + "rot": [0.0, 0.0, 45.0]}, + # Legacy-Fallback: KEIN rot-Feld → rot_y_deg = 37 (alte Karte unverändert). + {"id": "probe_c", "pos": [0.0, 0.0, 0.0], "rot_y_deg": 37.0, "scale": 2.0}, +] + +const COL := {"shape": "box", "size": [2.0, 4.0, 3.0]} + +## Skript-Pfade je Projekt (config/name → Loader-Skripte). +const SKRIPTE := { + "ScopedVR Map Editor": { + "rotation": "res://src/svmap/svmap_io.gd", + "hindernis": "res://src/svmap/kollider_bau.gd", + "rot_funktion": "objekt_rotation", + }, + "ScopedVR": { + "rotation": "res://scripts/world/svmap_world.gd", + "hindernis": "res://scripts/world/svmap_world.gd", + "rot_funktion": "_objekt_rotation", + }, + "ScopedVR Match-Server": { + "rotation": "res://src/maps/svmap_loader.gd", + "hindernis": "res://src/maps/svmap_loader.gd", + "rot_funktion": "_objekt_rotation", + }, +} + + +func _initialize() -> void: + # Im -s-Modus liefert ProjectSettings config/name teils null — stattdessen + # die project.godot direkt lesen (config/name="..."). + var projekt := "" + var fa := FileAccess.open("res://project.godot", FileAccess.READ) + if fa != null: + for zeile in fa.get_as_text().split("\n"): + var t := zeile.strip_edges() + if t.begins_with("config/name="): + projekt = t.trim_prefix("config/name=").strip_edges().trim_prefix("\"").trim_suffix("\"") + break + var cfg: Dictionary = SKRIPTE.get(projekt, {}) + if cfg.is_empty(): + printerr("IDENT FAIL: unbekanntes Projekt ", projekt) + quit(1) + return + var rot_skr: Script = load(str(cfg["rotation"])) + var hin_skr: Script = load(str(cfg["hindernis"])) + if rot_skr == null or hin_skr == null: + printerr("IDENT FAIL: Loader-Skripte nicht ladbar in ", projekt) + quit(1) + return + var rot_funk: String = str(cfg["rot_funktion"]) + for i in OBJEKTE.size(): + var obj: Dictionary = OBJEKTE[i] + var rot: Vector3 = rot_skr.call(rot_funk, obj) + print("IDENT rot_%d=%s" % [i, _fmt(rot)]) + var aabb: AABB = hin_skr.call("hindernis_aabb", obj, COL, float(obj.get("scale", 1.0))) + print("IDENT aabb_%d=%s" % [i, _fmt_aabb(aabb)]) + print("IDENT_PROJEKT=", projekt) + quit(0) + + +func _fmt(v: Vector3) -> String: + return "%.6f,%.6f,%.6f" % [v.x, v.y, v.z] + + +func _fmt_aabb(a: AABB) -> String: + return "%.6f,%.6f,%.6f|%.6f,%.6f,%.6f" % [ + a.position.x, a.position.y, a.position.z, a.size.x, a.size.y, a.size.z] diff --git a/tests/probe_skybox_render.gd.uid b/tests/probe_skybox_render.gd.uid new file mode 100644 index 0000000..212220e --- /dev/null +++ b/tests/probe_skybox_render.gd.uid @@ -0,0 +1 @@ +uid://cid2gajccffuy diff --git a/tests/run_platzierung_identitaet.sh b/tests/run_platzierung_identitaet.sh new file mode 100755 index 0000000..9affa1d --- /dev/null +++ b/tests/run_platzierung_identitaet.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Identitäts-Vertrag PLATZIERUNG (Karte t_e166e656): dieselben gekippten +# Objekte (15°/45°) laufen durch ALLE DREI Loader — Editor (KolliderBau), +# Quest-Client (svmap_world) und Match-Server (svmap_loader). Die Proben +# drucken `IDENT =`-Zeilen; der Runner verlangt IDENTISCHE +# Zeilen über alle drei Projekte (Rotations-Euler + konservative AABB). +# +# Environment: +# GODOT Godot-Binary (Default: $HOME/bin/godot4, dann $PATH) +# SVR_CLIENT_DIR Client-Projektordner (Default: ../scopedvr-client) +# SVR_SERVER_DIR Server-Projektordner (Default: ../scopedvr-match-server) +# Exit 0 = Identität bestätigt. +set -u +if [ -n "${GODOT:-}" ]; then : +elif [ -x "$HOME/bin/godot4" ]; then + GODOT="$HOME/bin/godot4" +elif command -v godot4 >/dev/null 2>&1; then + GODOT=$(command -v godot4) +else + echo "FAIL: Kein Godot gefunden — GODOT= setzen." >&2 + exit 1 +fi +EDITOR_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +CLIENT_DIR="${SVR_CLIENT_DIR:-$EDITOR_DIR/../scopedvr-client}" +SERVER_DIR="${SVR_SERVER_DIR:-$EDITOR_DIR/../scopedvr-match-server}" +for d in "$CLIENT_DIR" "$SERVER_DIR"; do + if [ ! -f "$d/project.godot" ]; then + echo "FAIL: Projekt nicht gefunden: $d" >&2 + exit 1 + fi +done +export GODOT_SILENCE_ROOT_WARNING=1 +# Server-Projekt: im -s-Modus NICHT hosten (Testclient-Modus, wie test_svmap). +export MATCHD_NO_HOST=1 +LOGDIR="${SVR_PLATZ_LOGDIR:-/tmp/svr_platz_ident_logs_$$}" +mkdir -p "$LOGDIR" +PROBE="$EDITOR_DIR/tests/probe_platzierung_ident.gd" +fail=0 + +for name in editor client server; do + case "$name" in + editor) DIR="$EDITOR_DIR" ;; + client) DIR="$CLIENT_DIR" ;; + server) DIR="$SERVER_DIR" ;; + esac + echo "== Identitäts-Probe: $name ($DIR) ==" + "$GODOT" --headless --path "$DIR" -s "$PROBE" > "$LOGDIR/$name.log" 2>&1 + code=$? + grep -E "^IDENT [a-z_0-9]+=" "$LOGDIR/$name.log" > "$LOGDIR/$name.ident" + if [ $code -ne 0 ]; then + fail=1 + echo "FAIL: $name-Probe rot (exit=$code):" >&2 + cat "$LOGDIR/$name.log" >&2 + fi +done + +echo "== Vergleich ==" +if ! diff -u "$LOGDIR/editor.ident" "$LOGDIR/client.ident"; then + echo "FAIL: Editor ↔ Client weichen ab." >&2 + fail=1 +fi +if ! diff -u "$LOGDIR/editor.ident" "$LOGDIR/server.ident"; then + echo "FAIL: Editor ↔ Server weichen ab." >&2 + fail=1 +fi +n=$(wc -l < "$LOGDIR/editor.ident") +if [ "$n" -lt 6 ]; then + echo "FAIL: Zu wenige IDENT-Zeilen ($n) — Proben gecrasht." >&2 + fail=1 +fi + +if [ $fail -eq 0 ]; then + echo "ALL OK — Identität Editor = Client = Server ($n Zeilen)." + exit 0 +fi +exit 1 diff --git a/tests/test_inspector.gd b/tests/test_inspector.gd index cd8d08e..2f75c62 100644 --- a/tests/test_inspector.gd +++ b/tests/test_inspector.gd @@ -10,7 +10,7 @@ extends SceneTree ## T3 Position ZIEHEN (Probe-Drag wie Maus) → doc geändert; EIN Undo-Schritt. ## T4 Mehrfachauswahl: Drehung tippen → beide Objekte gleich; EIN Undo-Schritt. ## T5 Uneinheitliche Werte → „—“; Tippen gleicht ALLE an. -## T6 Größe: Klemmung 0,05–20 + Marker-Rescale (echtes Modell) + Zurücksetzen. +## T6 Größe FEST (t_e166e656): scale-Zeile + Reset-Knopf entfernt, Wert bleibt. ## T7 „Auf Boden setzen“ nutzt die Boden-Logik; EIN Undo-Schritt. ## T8 Keine Auswahl → Felder aus, Hinweis an. @@ -195,49 +195,18 @@ func _t5_mixed_angleichen() -> void: func _t6_groesse() -> void: + # Karte t_e166e656: Scale ist FEST — die Zeile ist aus dem Inspektor + # entfernt (Sly-Wunsch „Realismus“). Kein Widget, kein Reset-Knopf, der + # gespeicherte Wert bleibt unangetastet. _select(0) - var w = insp.widget("scale") - w.edit.text = "100" - w.edit.text_submitted.emit("100") - _check(absf(float(_sel_obj(0).get("scale", 1.0)) - 20.0) < 0.0001, - "T6 Größe auf 100 geklemmt auf 20") - var marker = main.view_builder.object_markers[0] - var mi: MeshInstance3D = marker.get_child(0) as MeshInstance3D - if glb_ok: - _check(mi != null and mi.scale.length() > 19.9, "T6 Marker-Mesh skaliert live (echtes Modell)") - _check(float(marker.get_meta("half_height", 0.0)) > 0.0 - and absf(float(marker.get_meta("pick_radius", 0.0))) > 0.0, - "T6 Pick-Metas nach Rescale gesetzt") - _check(_undo_steps_to_clear() == 1, "T6 Größen-Tippen = GENAU EIN Undo-Schritt") - # Zurücksetzen-Knopf (Undo hat die Auswahl geleert → neu anwählen). - _select(0) - w = insp.widget("scale") - w.edit.text = "3" - w.edit.text_submitted.emit("3") - _check(absf(float(_sel_obj(0).get("scale", 1.0)) - 3.0) < 0.0001, "T6 Größe 3.0 gesetzt") - insp.get("_reset_btn").pressed.emit() - _check(absf(float(_sel_obj(0).get("scale", 1.0)) - 1.0) < 0.0001, - "T6 Zurücksetzen stellt 1.0 her") - # (Vorher: Tippen 3.0 = 1 Schritt, Zurücksetzen = 1 Schritt → EIN Undo - # holt das Zurücksetzen zurück.) - _check(main.history.can_undo(), "T6 Zurücksetzen erzeugt Undo-Eintrag") - main.call("_do_undo") - _check(absf(float(_sel_obj(0).get("scale", 1.0)) - 3.0) < 0.0001, - "T6 EIN Undo-Schritt holt 3.0 zurück (kein Frame-/Tasten-Splitting)") - _undo_steps_to_clear() - # Platzhalter-Pfad (crate_wood, scale 2.0 → 1.0) - main.interaction.call("select_object", 1) - await process_frame - var w2 = insp.widget("scale") - w2.edit.text = "1" - w2.edit.text_submitted.emit("1") - _check(absf(float(_sel_obj(1).get("scale", 1.0)) - 1.0) < 0.0001, - "T6 Platzhalter-Größe geändert") - var marker2 = main.view_builder.object_markers[1] - var mi2: MeshInstance3D = marker2.get_child(0) as MeshInstance3D - _check(mi2 != null and absf(mi2.scale.x - 0.5) < 0.01, - "T6 Platzhalter-Mesh relativ reskaliert (2.0 → 1.0 = Faktor 0.5)") - _undo_steps_to_clear() + _check(insp.widget("scale") == null, "T6 scale-Zeile ist ENTFERNT (kein Widget)") + _check(insp.get("_reset_btn") == null, "T6 Reset-Knopf entfernt") + var vor: float = float(_sel_obj(0).get("scale", 1.0)) + _check(absf(vor - 1.0) < 0.0001, "T6 Objekt behält sein scale (1.0)") + # Ausgewählt lassen und prüfen: der Inspektor zeigt den lesenden Hinweis. + insp.call("refresh") + _check(insp.get("_ground_btn") != null and insp.get("_ground_btn").visible, + "T6 Boden-Knopf bleibt bedienbar (Größe-Sektion zeigt nur Hinweis)") func _t7_auf_boden() -> void: diff --git a/tests/test_platzierung.gd b/tests/test_platzierung.gd new file mode 100644 index 0000000..f7c9185 --- /dev/null +++ b/tests/test_platzierung.gd @@ -0,0 +1,194 @@ +extends SceneTree + +## Platzierungsfreiheit (Karte t_e166e656) — EDITOR-Seite, pure Funktionen. +## Run: godot4 --headless --path . -s res://tests/test_platzierung.gd +## Exit 0 = alle Tests grün. +## +## Abdeckung: +## - SvmapIO.objekt_rotation: Fallback rot_y_deg (Legacy), rot überschreibt +## - SvmapIO.rot_setzen: rot-Feld nur bei echtem Kippen (Legacy-sauber) +## - SvmapIO-Validierung: V17 (kaputtes rot abgelehnt), gute Map mit rot lädt +## - KolliderBau.hindernis_aabb: 8 gedrehte Ecken (45°-Handrechnung) +## - ObjectOps.lift_object: Versenken unter Terrainhöhe erlaubt (Stein-Testfall) +## - ObjectOps.mirror_object / SymmetryCore.mirror_rot_euler: reines Y bleibt +## exakt das alte Verhalten (330/210), Kippen wird mitgespiegelt + +var _failures: Array[String] = [] +var _checks: int = 0 + + +func _initialize() -> void: + _run_all() + print("--------------------------------------------------") + print("CHECKS: %d FAILURES: %d" % [_checks, _failures.size()]) + for f in _failures: + print("FAIL: " + f) + quit(0 if _failures.is_empty() else 1) + + +func _check(cond: bool, label: String) -> void: + _checks += 1 + if cond: + print(" ok - " + label) + else: + _failures.append(label) + print(" FAIL - " + label) + + +func _map_mit(obj: Dictionary) -> Dictionary: + var res := 17 + var samples := PackedByteArray() + samples.resize(res * res * 2) + for i in res * res: + samples.encode_u16(i * 2, 32768) # flaches Terrain auf halber Höhe (5.0 m) + return { + "format_version": 2, + "info": {"name": "Platzierung", "author": "t", "description": "", + "version": 1, "created_utc": "2026-10-20T12:00:00Z", "target_game_version": "0.1.0"}, + "terrain": {"type": "heightmap", "size_x_m": 200.0, "size_z_m": 200.0, + "min_height_m": 0.0, "max_height_m": 10.0, "resolution": res, + "heightmap_r16_b64": Marshalls.raw_to_base64(samples)}, + "objects": [obj], + "spawn_zones": { + "0": {"points": [{"id": "A1", "pos": [-80.0, 5.0, -80.0]}, + {"id": "A2", "pos": [-84.0, 5.0, -80.0]}, {"id": "A3", "pos": [-80.0, 5.0, -84.0]}, + {"id": "A4", "pos": [-84.0, 5.0, -84.0]}]}, + "1": {"points": [{"id": "B1", "pos": [80.0, 5.0, 80.0]}, + {"id": "B2", "pos": [84.0, 5.0, 80.0]}, {"id": "B3", "pos": [80.0, 5.0, 84.0]}, + {"id": "B4", "pos": [84.0, 5.0, 84.0]}]}, + }, + "bounds": {"min": [-100.0, -20.0, -100.0], "max": [100.0, 60.0, 100.0]}, + "weather": "mittag", + } + + +func _run_all() -> void: + print("== 1) SvmapIO.objekt_rotation (Fallback + Override) ==") + var legacy := {"id": "x", "pos": [0.0, 0.0, 0.0], "rot_y_deg": 25.0, "scale": 1.0} + var e := SvmapIO.objekt_rotation(legacy) + _check(absf(e.y - 25.0) < 0.0001 and absf(e.x) < 0.0001 and absf(e.z) < 0.0001, + "Fallback: rot_y_deg = 25 → Euler (0, 25, 0)") + var gekippt := {"id": "x", "pos": [0.0, 0.0, 0.0], "rot_y_deg": 25.0, "scale": 1.0, + "rot": [20.0, 90.0, 10.0]} + var e2 := SvmapIO.objekt_rotation(gekippt) + _check(absf(e2.x - 20.0) < 0.0001 and absf(e2.y - 90.0) < 0.0001 and absf(e2.z - 10.0) < 0.0001, + "rot present → Euler (20, 90, 10), rot_y_deg ignoriert") + + print("== 2) SvmapIO.rot_setzen (Legacy-sauber) ==") + var obj := {"id": "x", "pos": [0.0, 0.0, 0.0], "rot_y_deg": 45.0, "scale": 1.0} + SvmapIO.rot_setzen(obj, Vector3(0.0, 30.0, 0.0)) + _check(absf(float(obj["rot_y_deg"]) - 30.0) < 0.0001 and not obj.has("rot"), + "reines Y: rot_y_deg aktualisiert, KEIN rot-Feld") + SvmapIO.rot_setzen(obj, Vector3(20.0, 30.0, -5.0)) + _check((obj["rot"] as Array) == [20.0, 30.0, -5.0] + and absf(float(obj["rot_y_deg"]) - 30.0) < 0.0001, + "Kippen: rot=[20,30,-5] geschrieben, rot_y_deg = Y-Komponente") + SvmapIO.rot_setzen(obj, Vector3(0.0, 10.0, 0.0)) + _check(not obj.has("rot") and absf(float(obj["rot_y_deg"]) - 10.0) < 0.0001, + "zurück auf 0-Kippen: rot-Feld wieder ENTFERNT (legacy-sauber)") + + print("== 3) Validierung V17 + Roundtrip ==") + var gut := _map_mit({"id": "crate_wood", "pos": [0.0, 5.0, 0.0], "rot_y_deg": 0.0, + "scale": 1.0, "rot": [20.0, 0.0, 10.0]}) + SvmapIO.sign_map(gut) + _check(SvmapIO.validate_map(gut).is_empty(), "Map mit rot=[20,0,10] validiert") + for bad in [[0.0, 0.0], ["a", "b", "c"]]: + var schlecht := _map_mit({"id": "crate_wood", "pos": [0.0, 5.0, 0.0], "rot_y_deg": 0.0, + "scale": 1.0, "rot": bad}) + var errs := SvmapIO.validate_map(schlecht) + var hat := false + for f in errs: + if str(f).contains("V17"): + hat = true + _check(hat, "V17: rot=%s abgelehnt" % str(bad)) + var pfad := "user://test_platzierung_rot.svmap" + var save := SvmapIO.save_to_file(pfad, gut) + _check(bool(save.get("ok", false)), "save_to_file mit rot ok") + var load := SvmapIO.load_from_file(pfad) + _check(bool(load.get("ok", false)), "Map mit rot lädt zurück") + if bool(load.get("ok", false)): + var objs: Array = (load["data"] as Dictionary).get("objects", []) + _check((objs[0] as Dictionary).get("rot", []) == [20.0, 0.0, 10.0], + "rot überlebt Roundtrip unverändert") + # Alte Karte (mit scale ≠ 1) bleibt ladbar — Format-Kompatibilität. + var alt := _map_mit({"id": "crate_wood", "pos": [0.0, 5.0, 0.0], "rot_y_deg": 15.0, "scale": 2.5}) + SvmapIO.sign_map(alt) + _check(SvmapIO.validate_map(alt).is_empty(), + "alte Karte mit scale=2.5 lädt unverändert (kein Datenverlust)") + + print("== 4) KolliderBau.hindernis_aabb (8 gedrehte Ecken) ==") + var col := {"shape": "box", "size": [2.0, 2.0, 2.0]} + var obj45 := {"id": "x", "pos": [0.0, 5.0, 0.0], "rot": [0.0, 45.0, 0.0], "scale": 1.0} + var aabb45: AABB = KolliderBau.hindernis_aabb(obj45, col, 1.0) + var erwartet := 2.0 * sqrt(2.0) + _check(absf(aabb45.size.x - erwartet) < 0.01 and absf(aabb45.size.z - erwartet) < 0.01 + and absf(aabb45.size.y - 2.0) < 0.01, "45°-Box: AABB 2·√2 × 2 × 2·√2") + _check(absf(aabb45.get_center().y - 6.0) < 0.01, "Fußpunkt-Konvention: Zentrum halbe Höhe über pos") + + print("== 5) Versenken (Stein im Boden) ==") + var doc := _map_mit({"id": "rock_large", "pos": [10.0, 6.0, 10.0], "rot_y_deg": 0.0, "scale": 1.0}) + var boden_hoehe: float = SvmapDocument.terrain_height_at(doc, 10.0, 10.0) + _check(absf(boden_hoehe - 5.0) < 0.01, "Terrain dekodiert (5.0 m am Probe-Punkt)") + var angewandt: float = ObjectOps.lift_object(doc, 0, -2.0) + var versunken: float = float(((doc["objects"] as Array)[0] as Dictionary)["pos"][1]) + _check(absf(angewandt + 2.0) < 0.001, "Absenken wirkt ÜBER die Terrainhöhe HINAUS (Versenken)") + _check(versunken < boden_hoehe - 0.9, "Objekt steht 1 m unter der Terrainhöhe (versenkt)") + var offset: float = ObjectOps.lift_offset(doc, 0) + _check(offset < -0.9, "lift_offset meldet negative Tiefe (%.2f m versenkt)" % -offset) + # Untergrenze = bounds.min.y (−20): weiter absenken klemmt dort. + var grenze: float = ObjectOps.lift_object(doc, 0, -100.0) + var y_ende: float = float(((doc["objects"] as Array)[0] as Dictionary)["pos"][1]) + _check(absf(y_ende - (-20.0)) < 0.001 and grenze < -0.1, + "Untergrenze = bounds.min.y (−20 m), nicht tiefer") + + print("== 6) Spiegel: Legacy-Verhalten rein-Y + Kippen ==") + # Pure-Y-Objekte: exakt die alten Zahlenwerte (tests/test_object_mirror-Kontrakt). + var nur_y := {"id": "x", "pos": [0.0, 0.0, 0.0], "rot_y_deg": 30.0, "scale": 1.0} + SvmapIO.rot_setzen(nur_y, Vector3(0.0, 30.0, 0.0)) # ohne rot-Feld + ObjectOps.mirror_object(nur_y, "x") + _check(absf(float(nur_y["rot_y_deg"]) - 330.0) < 0.0001 and not nur_y.has("rot"), + "Spiegel X, pure-Y: 30 → 330 (legacy-identisch), kein rot-Feld") + ObjectOps.mirror_object(nur_y, "z") + _check(absf(float(nur_y["rot_y_deg"]) - 210.0) < 0.0001, + "Spiegel Z, pure-Y: 330 → 210 (180 − 330, legacy-identisch)") + # Kippen: X-Spiegel dreht Y- UND Z-Komponente, X-Kippen bleibt. + var gekippt2 := {"id": "x", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot": [20.0, 30.0, 10.0]} + ObjectOps.mirror_object(gekippt2, "x") + var mx := SvmapIO.objekt_rotation(gekippt2) + _check(absf(mx.x - 20.0) < 0.0001 and absf(mx.y - 330.0) < 0.0001 and absf(mx.z - 350.0) < 0.0001, + "Spiegel X mit Kippen: (20, 30, 10) → (20, 330, 350) — X-Kippen bleibt, Y/Z drehen Vorzeichen") + # Symmetrie-Kern: pure-Y-Kompatibilität. + var e30 := SymmetryCore.mirror_rot_euler(SymmetryCore.Mode.AXIS_X, Vector3(0.0, 30.0, 0.0)) + _check(absf(e30.y - 330.0) < 0.0001 and absf(e30.x) < 0.0001 and absf(e30.z) < 0.0001, + "mirror_rot_euler AXIS_X, pure-Y: 30 → 330 (identisch zu mirror_rot_y)") + var e30p := SymmetryCore.mirror_rot_euler(SymmetryCore.Mode.POINT, Vector3(0.0, 30.0, 0.0)) + _check(absf(e30p.y - 210.0) < 0.0001, "mirror_rot_euler POINT, pure-Y: 30 → 210") + # Achsenspiegelung ist selbstinvers: zweimal spiegeln = Ausgangslage. + var doppelt := SymmetryCore.mirror_rot_euler(SymmetryCore.Mode.AXIS_X, + SymmetryCore.mirror_rot_euler(SymmetryCore.Mode.AXIS_X, Vector3(20.0, 30.0, 10.0))) + _check(_euler_gleich(doppelt, Vector3(20.0, 30.0, 10.0)), + "AXIS_X-Spiegel ist selbstinvers (zweimal = Ausgangslage)") + + print("== 7) Zwilling erbt gespiegeltes Kippen ==") + var doc2 := _map_mit({"id": "rock_large", "pos": [10.0, 5.0, 10.0], "scale": 1.0, + "rot": [20.0, 30.0, 10.0]}) + var res: Dictionary = ObjectOps.symmetry_place_twin(doc2, 0, SymmetryCore.Mode.AXIS_X) + var twin_i := int(res.get("twin", -1)) + _check(twin_i >= 0, "Zwilling angelegt") + if twin_i >= 0: + var twin: Dictionary = (doc2["objects"] as Array)[twin_i] + var tpos: Array = twin.get("pos", []) + _check(absf(float(tpos[0]) + 10.0) < 0.01, "Zwilling-Position gespiegelt (x → −x)") + var te := SvmapIO.objekt_rotation(twin) + var erwartet2 := SymmetryCore.mirror_rot_euler(SymmetryCore.Mode.AXIS_X, Vector3(20.0, 30.0, 10.0)) + _check(_euler_gleich(te, erwartet2), "Zwilling-Kippen = gespiegelte Euler-Orientierung") + + +func _euler_gleich(a: Vector3, b: Vector3, tol := 0.01) -> bool: + var d := a - b + # Komponenten modulo 360 vergleichen (kürzester Weg). + for k in 3: + var w := fposmod(d[k] + 180.0, 360.0) - 180.0 + if absf(w) > tol: + return false + return true diff --git a/tests/test_platzierung.gd.uid b/tests/test_platzierung.gd.uid new file mode 100644 index 0000000..48f2894 --- /dev/null +++ b/tests/test_platzierung.gd.uid @@ -0,0 +1 @@ +uid://drquctvbwf33j diff --git a/tests/test_shortcuts.gd b/tests/test_shortcuts.gd index e106bdd..fa51edd 100644 --- a/tests/test_shortcuts.gd +++ b/tests/test_shortcuts.gd @@ -786,12 +786,15 @@ func _test_lift_multi() -> void: _check(absf(marker_y - 0.25) < 0.0001, "Bild↑: Marker wandert mit") interaction.lift_selected(-ObjectOps.LIFT_STEP_M) _check(absf(float(objects[0]["pos"][1])) < 0.0001, "Bild↓: wieder −0,25 m") - # Objekt am Boden: weiter absenken geht nicht — kein Commit, Meldung kommt. - var can_undo: bool = (pair[0].history as ActionHistory).can_undo() + # Karte t_e166e656: weiter absenken UNTER den Boden ist ERLAUBT (Versenken, + # z. B. Steine) — es wird committed, Untergrenze ist die Match-Grenze. + var can_undo2: bool = (pair[0].history as ActionHistory).can_undo() interaction.lift_selected(-ObjectOps.LIFT_STEP_M) - _check(absf(float(objects[0]["pos"][1])) < 0.0001, "Bild↓ am Boden: y bleibt 0") - _check((pair[0].history as ActionHistory).can_undo() == can_undo, - "Bild↓ am Boden: nichts committet (Snapshot verworfen)") + _check(absf(float(objects[0]["pos"][1]) + 0.25) < 0.0001, + "Bild↓ unter Boden: y = −0,25 (Versenken erlaubt)") + _check((pair[0].history as ActionHistory).can_undo() != can_undo2 + or (pair[0].history as ActionHistory).can_undo(), + "Bild↓ unter Boden: Schritt ist committet (undo möglich)") func _test_snap_to_floor() -> void: diff --git a/tests/test_ux.gd b/tests/test_ux.gd index 968b3ec..407e4cc 100644 --- a/tests/test_ux.gd +++ b/tests/test_ux.gd @@ -290,7 +290,8 @@ func _test_object_ops() -> void: _check(ObjectOps.duplicate_object(doc, 99, true) == -1, "duplizieren: falscher Index → -1") _check(objects.size() == 3, "duplizieren: falscher Index ändert nichts") - # --- Anheben/Absenken: 0,25-m-Schritte, Deckel = bounds.max, Boden-Stopp --- + # --- Anheben/Absenken: 0,25-m-Schritte, Deckel = bounds.max, UNTergrenze + # = bounds.min (Karte t_e166e656: Versenken im Boden ist ERLAUBT — Steine!) --- var h0: float = SvmapDocument.terrain_height_at(doc, 10.0, 10.0) var up := ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M) _check(absf(up - ObjectOps.LIFT_STEP_M) < 0.0001, "anheben: 0,25 m angewendet") @@ -300,24 +301,30 @@ func _test_object_ops() -> void: var down := ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) _check(absf(down + 0.25) < 0.0001, "absenken: −0,25 m") - var stop := ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M * 10) - _check(absf(stop + 0.25) < 0.0001, "absenken: stoppt genau am Terrain (kein Begraben)") - _check(ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) == 0.0, "absenken am Boden: 0.0 (UI erklärt statt commit)") - _check(absf(float(objects[0]["pos"][1]) - h0) < 0.001, "nach Absenken-Stopp: exakt Boden") + _check(absf(float(objects[0]["pos"][1]) - (h0 + 0.25)) < 0.0001, "absenken: y = 0,25 m") + # Karte t_e166e656: weiter absenken UNTER die Terrainhöhe — Versenken ist + # erlaubt, Untergrenze ist die Match-Grenze (bounds.min.y = −10). + var tief := ObjectOps.lift_object(doc, 0, -1000.0) + _check(absf(float(objects[0]["pos"][1]) - (-10.0)) < 0.0001, + "absenken: Untergrenze = bounds.min.y (−10 m, unter dem Terrain)") + _check(absf(tief - (-10.0 - (h0 + 0.25))) < 0.0001, "absenken: angewendet = Rest bis zur Untergrenze") + _check(ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) == 0.0, "absenken an Untergrenze: 0.0") # Deckel: bounds.max.y = 80 (Neue-Map) → Großsprung wird auf 80 gedeckelt. var cap := ObjectOps.lift_object(doc, 0, 1000.0) _check(absf(float(objects[0]["pos"][1]) - 80.0) < 0.0001, "anheben: Höchstgrenze bounds.max.y (80 m)") - _check(absf(cap - (80.0 - h0)) < 0.0001, "anheben: angewendet = Rest bis zum Deckel") + _check(absf(cap - (80.0 - (-10.0))) < 0.0001, "anheben: angewendet = Rest bis zum Deckel") _check(ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M) == 0.0, "anheben am Deckel: 0.0") - # Im Boden versteckt (nach Terrain-Formen): Absenken bewegt NICHT, - # Anheben hebt schrittweise aus dem Boden. + # Im Boden versenkt (t_e166e656): Absenken geht WEITER runter (Versenken + # ist erlaubt), Anheben hebt schrittweise aus dem Boden. objects[0]["pos"] = [10.0, -3.0, 10.0] - _check(ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) == 0.0, "verborgen: absenken = 0.0") + var tiefer := ObjectOps.lift_object(doc, 0, -ObjectOps.LIFT_STEP_M) + _check(absf(tiefer + ObjectOps.LIFT_STEP_M) < 0.0001, "versenkt: absenken geht tiefer (−0,25 m)") + _check(absf(float(objects[0]["pos"][1]) + 3.25) < 0.0001, "versenkt: y = −3,25 m (unter dem Boden erlaubt)") var out := ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M) - _check(absf(out - ObjectOps.LIFT_STEP_M) < 0.0001, "versteckt: anheben hebt schrittweise (0,25 m ab −3 m)") - _check(absf(float(objects[0]["pos"][1]) + 2.75) < 0.0001, "versteckt: y steigt vom Untergrund aus (kein Sprung-Ruckel)") + _check(absf(out - ObjectOps.LIFT_STEP_M) < 0.0001, "versenkt: anheben hebt schrittweise (0,25 m ab −3,25 m)") + _check(absf(float(objects[0]["pos"][1]) + 3.0) < 0.0001, "versenkt: y zurück auf −3 m (kein Sprung-Ruckel)") # lift_offset (Offset der UI-Anzeige): angehoben → +0,25, am Boden → 0. objects[0]["pos"] = [10.0, h0 + 0.25, 10.0]