Platzierungsfreiheit (t_e166e656): Scale-Zeile entfernt (Groesse fix), Kippen X/Z (rot-Feld, Strg+R/Alt+R, Ghost + Platzierung), Versenken unter Terrain erlaubt + Tiefe-Anzeige, Spiegel-Mathe 3 Achsen, KolliderBau rot + hindernis_aabb, Suiten + Identitaets-Runner

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-20 22:32:10 +00:00
parent 78d311c75f
commit 42a205b6ff
21 changed files with 746 additions and 180 deletions

View file

@ -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.

View file

@ -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 |

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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))

View file

@ -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()

View file

@ -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

View file

@ -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]

View file

@ -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)"],

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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 <name>=<wert>`-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 <projekt> -s <abs>/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]

View file

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

View file

@ -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 <name>=<wert>`-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=<pfad> 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

View file

@ -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:

194
tests/test_platzierung.gd Normal file
View file

@ -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

View file

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

View file

@ -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:

View file

@ -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]