Objekt-Ops: Duplizieren (Strg+D) + Anheben/Absenken (Bild hoch/runter) + Spiegeln (X/Z)
Karte t_7567a06f. Hoehe wohnt direkt in object.pos.y — KEIN svmap-Format- Feld noetig (V9 erlaubt freie y-Werte in bounds; Server-Loader geprueft). Absenken stoppt am Terrain (nichts begraben), Anheben deckelt auf bounds.max. Spiegeln am Objekt selbst (Position bleibt): X → −rot, Z → 180°−rot selbstinvers. Alles über ActionHistory (Merge-Tags für Lift-Serien), deutsche UI + Klartext-Status, dragt behält Terrain-Abstand. test_ux.gd: 90 Checks (42 neu), Suite 5/5 grün, xvfb-Smoke UI-Bau ok.
This commit is contained in:
parent
ce1dc4130d
commit
eca11bbf83
7 changed files with 480 additions and 13 deletions
51
README.md
51
README.md
|
|
@ -162,3 +162,54 @@ Auf der Objekt-Bibliothek (t_3a998ae4) aufgebaut, nach dem Chef-Grundsatz:
|
||||||
- **Desktop-Smoke:** `godot … -- --spawns-demo [ordner] [--spawns-demo-quit]`
|
- **Desktop-Smoke:** `godot … -- --spawns-demo [ordner] [--spawns-demo-quit]`
|
||||||
fährt Zonen-Übersicht → Live-Check-Alarm → Undo → „Karte prüfen“
|
fährt Zonen-Übersicht → Live-Check-Alarm → Undo → „Karte prüfen“
|
||||||
programmatisch durch und speichert Screenshots.
|
programmatisch durch und speichert Screenshots.
|
||||||
|
|
||||||
|
## Status (M2, Karte t_7567a06f — Objekt-Ops: Duplizieren + Hoch/Runter + Spiegeln)
|
||||||
|
|
||||||
|
Auf dem UX-Fundament (t_40d855d0) und der Objekt-Bibliothek (t_3a998ae4)
|
||||||
|
aufgebaut, nach dem Chef-Grundsatz (Klick statt Konsole, deutsche UI,
|
||||||
|
Undo für alles):
|
||||||
|
|
||||||
|
- **Duplizieren (`⧉ Duplizieren`-Button + Strg+D):** Die Kopie des
|
||||||
|
ausgewählten Objekts hängt diagonal 1 m versetzt daneben (mit Raster-Snap
|
||||||
|
eingerastet, wenn an) und ist DIREKT angewählt — sie lässt sich gleich
|
||||||
|
weiterziehen/drehen/löschen. Drehung, Größe und Höhe werden übernommen.
|
||||||
|
Poly-Budget und Objekt-Limit werden VOR dem Kopieren geprüft
|
||||||
|
(Klartext-Meldung, was zu tun ist). EIN Undo-Schritt.
|
||||||
|
- **Anheben/Absenken (`▲ Anheben / ▼ Absenken` + Bild↑/Bild↓):** Objekte
|
||||||
|
ändern die Höhe in 0,25-m-Schritten. MEHRERE Klicks in Serie = EIN
|
||||||
|
Undo-Schritt. Design-Entscheidungen, dokumentiert:
|
||||||
|
- Die Höhe wohnt direkt in `object.pos.y` — das svmap-Format erlaubt
|
||||||
|
freie y-Werte innerhalb der Match-Grenzen (Loader-Regel V9), deshalb
|
||||||
|
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.
|
||||||
|
- **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
|
||||||
|
Verschieben in derselben Höhe über dem (neuen) Boden weiter; bodennah
|
||||||
|
bleibt bodennah.
|
||||||
|
- Die Auswahl-Anzeige zeigt die Höhe mit („+0,25 m über dem Boden“).
|
||||||
|
- **Spiegeln (`⇄ Links↔Rechts` / `⇅ Vor↔Zurück`):** Das Objekt wird AN
|
||||||
|
SEINER STelle gespiegelt — Position bleibt, die Blickrichtung dreht:
|
||||||
|
- links↔rechts (Welt-X-Spiegelung): `rot_y_deg → −rot`
|
||||||
|
- vor↔zurück (Welt-Z-Spiegelung): `rot_y_deg → 180° − rot`
|
||||||
|
(Herleitung im Quellcode: gespiegelter Richtungs-Vektor = gespiegelter
|
||||||
|
Winkel; jede Spiegelung ist dadurch selbstinvers — zweimal spiegeln =
|
||||||
|
Ausgangszustand.) Die Rotation wird mitgespiegelt, wie bestellt.
|
||||||
|
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.
|
||||||
|
- **Alles über ActionHistory:** Snapshot vor/nach JEDE Aktion
|
||||||
|
(Duplizieren = EIN Schritt, Anheben-Serie = EIN Schritt via Merge-Tag,
|
||||||
|
Spiegeln = EIN Schritt). Statusleiste erklärt nach jeder Aktion den
|
||||||
|
nächsten Schritt und die Grenzen auf Deutsch.
|
||||||
|
- **Tests:** `tests/test_ux.gd` auf 90 Checks erweitert (42 neu):
|
||||||
|
Duplizieren (Raster/ohne Raster/Undo-Roundtrip/Index-Schutz), Anheben/
|
||||||
|
Absenken (Schrittweite, Boden-Stopp, Deckel bounds.max, versteckt-Regel,
|
||||||
|
Merge-Tags), Spiegeln (X/Z-Mapping, Selbstinvers, Position unverändert),
|
||||||
|
svmap-Roundtrip mit angehobener Höhe. `godot … --quit-after` smoke:
|
||||||
|
UI baut alle neuen Buttons fehlerfrei.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ const ROT_STEP_DEG := 15.0
|
||||||
const DRAG_THRESHOLD_PX := 4.0
|
const DRAG_THRESHOLD_PX := 4.0
|
||||||
const GRID_M := 1.0 # Raster-Snap-Weite (Objekt-Werkzeuge links, Karte t_3a998ae4)
|
const GRID_M := 1.0 # Raster-Snap-Weite (Objekt-Werkzeuge links, Karte t_3a998ae4)
|
||||||
|
|
||||||
|
# Höhe beim Ziehen merken (Karte t_7567a06f): das Objekt behält seinen
|
||||||
|
# Abstand zum Terrain — ein angehobenes Objekt bleibt beim Verschieben oben.
|
||||||
|
|
||||||
var main # Hauptfenster (script-erweitert, Variant — Referenzen per setup())
|
var main # Hauptfenster (script-erweitert, Variant — Referenzen per setup())
|
||||||
var camera: Camera3D
|
var camera: Camera3D
|
||||||
var view_builder # script-erweitert, Variant
|
var view_builder # script-erweitert, Variant
|
||||||
|
|
@ -31,6 +34,7 @@ var _dragging := false
|
||||||
var _drag_moved := false
|
var _drag_moved := false
|
||||||
var _press_pos := Vector2.ZERO
|
var _press_pos := Vector2.ZERO
|
||||||
var _grab_offset := Vector3.ZERO
|
var _grab_offset := Vector3.ZERO
|
||||||
|
var _drag_lift := 0.0 # Terrain-Abstand des Objekts beim Anfassen (Höhe hebt sich mit)
|
||||||
|
|
||||||
|
|
||||||
func setup(p_main, p_camera: Camera3D, p_builder) -> void:
|
func setup(p_main, p_camera: Camera3D, p_builder) -> void:
|
||||||
|
|
@ -272,6 +276,121 @@ func delete_selected() -> void:
|
||||||
main.call("_set_status", "%s gelöscht — Strg+Z (Rückgängig) holt es zurück." % label)
|
main.call("_set_status", "%s gelöscht — Strg+Z (Rückgängig) holt es zurück." % label)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------ Duplizieren/Höhe/Spiegeln ------
|
||||||
|
## (Karte t_7567a06f — alle drei auch bei Sly bedienbar: Button rechts +
|
||||||
|
## Tastenkürzel, alles über ActionHistory, Klartext-Status-Hilfe.)
|
||||||
|
|
||||||
|
## Strg+D / Button "Duplizieren": Kopie diagonal leicht versetzt (mit
|
||||||
|
## Raster-Snap eingerastet, wenn an), direkt angewählt, EIN Undo-Schritt.
|
||||||
|
## Poly-Budget und Objekt-Limit werden VOR dem Kopieren geprüft.
|
||||||
|
func duplicate_selected() -> void:
|
||||||
|
if selected_index < 0:
|
||||||
|
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du kopieren willst.", true)
|
||||||
|
return
|
||||||
|
var objects: Array = main.doc.get("objects", [])
|
||||||
|
if selected_index >= objects.size():
|
||||||
|
clear_selection(true)
|
||||||
|
return
|
||||||
|
if objects.size() >= SvmapIO.MAX_OBJECTS:
|
||||||
|
main.call("_set_status", "Limit erreicht: In eine Map passen maximal %d Objekte — das Löschen von Objekten macht wieder Platz." % SvmapIO.MAX_OBJECTS, true)
|
||||||
|
return
|
||||||
|
var obj: Dictionary = objects[selected_index]
|
||||||
|
var oid := str(obj.get("id", ""))
|
||||||
|
var lib: Dictionary = main.manifest.get("objects", {})
|
||||||
|
var entry: Dictionary = lib.get(oid, {})
|
||||||
|
var scl := float(obj.get("scale", 1.0))
|
||||||
|
var poly := float(entry.get("poly", 0)) * scl * scl
|
||||||
|
var used := SvmapDocument.poly_usage(main.doc, main.manifest)
|
||||||
|
var budget := float(SvmapIO.MAX_TOTAL_POLY)
|
||||||
|
if used + poly > budget:
|
||||||
|
main.call("_set_status", "Für eine Kopie fehlt das Poly-Budget (%s von %s belegt) — Strg+Z nimmt etwas zurück, oder lösche ein anderes Objekt." % [
|
||||||
|
main._fmt_int(int(used)), main._fmt_int(int(budget))], true)
|
||||||
|
return
|
||||||
|
var before: Dictionary = main.history.snapshot(["objects"])
|
||||||
|
var new_index: int = ObjectOps.duplicate_object(main.doc, selected_index, bool(main.get("grid_snap")))
|
||||||
|
if new_index < 0:
|
||||||
|
clear_selection(true)
|
||||||
|
return
|
||||||
|
main.history.commit("%s duplizieren" % main.object_label(oid),
|
||||||
|
before, main.history.snapshot(["objects"]))
|
||||||
|
main.call("_after_structure_change")
|
||||||
|
# Kopie direkt anwählen — Sly kann sie gleich weiterziehen.
|
||||||
|
select_object(new_index)
|
||||||
|
var objects2: Array = main.doc.get("objects", [])
|
||||||
|
var label: String = main.object_label(str(objects2[new_index].get("id", oid)))
|
||||||
|
main.call("_set_status", "%s dupliziert: das neue Objekt ist direkt angewählt — Ziehen verschiebt es, Entf löscht es wieder." % label)
|
||||||
|
|
||||||
|
|
||||||
|
## ▲/▼ / Strg+Bild↑/↓: Objekt in 0,25-m-Schritten anheben/absenken.
|
||||||
|
## Mehrere Klicks in Serie = EIN Undo-Schritt (Merge-Tag, wie beim Drehen).
|
||||||
|
## Absenken stoppt am Terrain (nichts wird begraben), Anheben an der
|
||||||
|
## Match-Grenze (bounds.max) — Regeln in ObjectOps.lift_object.
|
||||||
|
func lift_selected(delta_m: float) -> void:
|
||||||
|
if selected_index < 0:
|
||||||
|
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du anheben oder absenken willst.", true)
|
||||||
|
return
|
||||||
|
if placing:
|
||||||
|
return
|
||||||
|
var objects: Array = main.doc.get("objects", [])
|
||||||
|
if selected_index >= objects.size():
|
||||||
|
clear_selection(true)
|
||||||
|
return
|
||||||
|
var before: Dictionary = main.history.snapshot(["objects"])
|
||||||
|
var applied := ObjectOps.lift_object(main.doc, selected_index, delta_m)
|
||||||
|
if absf(applied) < 0.0001:
|
||||||
|
# Nichts passiert (Snapshot verwerfen): hilfsbereit erklären, WO die Grenze ist.
|
||||||
|
var ground: String
|
||||||
|
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."
|
||||||
|
main.call("_set_status", ground)
|
||||||
|
return
|
||||||
|
var after: Dictionary = main.history.snapshot(["objects"])
|
||||||
|
main.history.commit("Objekt %s (%s)" % [
|
||||||
|
"angehoben" if delta_m > 0.0 else "abgesenkt",
|
||||||
|
("%.2f m" % applied)],
|
||||||
|
before, after, "lift:%d" % selected_index)
|
||||||
|
var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null
|
||||||
|
if marker != null and is_instance_valid(marker):
|
||||||
|
marker.position.y = float((objects[selected_index]["pos"] as Array)[1])
|
||||||
|
main.dirty = true
|
||||||
|
main.call("_refresh_file_label")
|
||||||
|
main.call("_refresh_stats")
|
||||||
|
main.call("_refresh_history_buttons")
|
||||||
|
main.call("_sync_selection_panel")
|
||||||
|
main.call("_set_status", "Objekt %s auf %.2f m — mehrmals drücken stapelt weiter; Strg+Z nimmt alle Stufen auf einmal zurück." % [
|
||||||
|
"angehoben" if delta_m > 0.0 else "abgesenkt", float((objects[selected_index]["pos"] as Array)[1])])
|
||||||
|
|
||||||
|
|
||||||
|
## ⇄ Spiegeln (X: links↔rechts, Z: vor↔zurück) an der Stelle des Objekts —
|
||||||
|
## die Blickrichtung wird gespiegelt (sicher selbstinvers: doppelt = zurück).
|
||||||
|
func mirror_selected(axis: String) -> void:
|
||||||
|
if selected_index < 0:
|
||||||
|
main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du spiegeln willst.", true)
|
||||||
|
return
|
||||||
|
var objects: Array = main.doc.get("objects", [])
|
||||||
|
if selected_index >= objects.size():
|
||||||
|
clear_selection(true)
|
||||||
|
return
|
||||||
|
var before: Dictionary = main.history.snapshot(["objects"])
|
||||||
|
var obj: Dictionary = objects[selected_index]
|
||||||
|
var old_rot := float(obj.get("rot_y_deg", 0.0))
|
||||||
|
ObjectOps.mirror_object(obj, axis)
|
||||||
|
var new_rot := float(obj.get("rot_y_deg", 0.0))
|
||||||
|
var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null
|
||||||
|
if marker != null and is_instance_valid(marker):
|
||||||
|
marker.rotation.y = deg_to_rad(new_rot)
|
||||||
|
var desc := "Objekt gespiegelt (links↔rechts)" if axis == "x" else "Objekt gespiegelt (vor↔zurück)"
|
||||||
|
main.history.commit(desc, before, main.history.snapshot(["objects"]))
|
||||||
|
main.dirty = true
|
||||||
|
main.call("_refresh_file_label")
|
||||||
|
main.call("_refresh_history_buttons")
|
||||||
|
main.call("_sync_selection_panel")
|
||||||
|
main.call("_set_status", "%s — Blickrichtung %.0f° → %.0f°. Strg+Z stellt die alte Richtung wieder her." % [
|
||||||
|
desc, old_rot, new_rot])
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------ Eingaben ------
|
# ------------------------------------------------------------ Eingaben ------
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
|
@ -290,7 +409,9 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||||
var half := _terrain_half()
|
var half := _terrain_half()
|
||||||
new_pos.x = clampf(new_pos.x, -half.x, half.x)
|
new_pos.x = clampf(new_pos.x, -half.x, half.x)
|
||||||
new_pos.z = clampf(new_pos.z, -half.y, half.y)
|
new_pos.z = clampf(new_pos.z, -half.y, half.y)
|
||||||
new_pos.y = SvmapDocument.terrain_height_at(main.doc, new_pos.x, new_pos.z)
|
# Höhe: der Terrain-Abstand bleibt beim Ziehen erhalten (angehoben
|
||||||
|
# schwebt weiter oben) — kein harter Terrain-Clamp mehr, t_7567a06f.
|
||||||
|
new_pos.y = SvmapDocument.terrain_height_at(main.doc, new_pos.x, new_pos.z) + _drag_lift
|
||||||
var dist := Vector2(mm.position.x - _press_pos.x, mm.position.y - _press_pos.y).length()
|
var dist := Vector2(mm.position.x - _press_pos.x, mm.position.y - _press_pos.y).length()
|
||||||
if dist > DRAG_THRESHOLD_PX:
|
if dist > DRAG_THRESHOLD_PX:
|
||||||
_drag_moved = true
|
_drag_moved = true
|
||||||
|
|
@ -345,8 +466,13 @@ func _on_left_press(mb: InputEventMouseButton) -> void:
|
||||||
if p != null and marker != null and is_instance_valid(marker):
|
if p != null and marker != null and is_instance_valid(marker):
|
||||||
_grab_offset = marker.position - (p as Vector3)
|
_grab_offset = marker.position - (p as Vector3)
|
||||||
_grab_offset.y = 0.0
|
_grab_offset.y = 0.0
|
||||||
|
# Terrain-Abstand einfrieren (t_7567a06f): ein Objekt mit Höhe behält
|
||||||
|
# beim Ziehen diesen Abstand zum Boden — bodennah bleibt bodennah.
|
||||||
|
_drag_lift = marker.position.y \
|
||||||
|
- SvmapDocument.terrain_height_at(main.doc, marker.position.x, marker.position.z)
|
||||||
else:
|
else:
|
||||||
_grab_offset = Vector3.ZERO
|
_grab_offset = Vector3.ZERO
|
||||||
|
_drag_lift = 0.0
|
||||||
else:
|
else:
|
||||||
# Leere Stelle: Auswahl aufheben (klicken statt auswendig lernen).
|
# Leere Stelle: Auswahl aufheben (klicken statt auswendig lernen).
|
||||||
if selected_index != -1:
|
if selected_index != -1:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ class_name ObjectOps
|
||||||
|
|
||||||
const GRID_M := 1.0 # Raster-Snap-Weite (1 m — handhabbar für Objekte)
|
const GRID_M := 1.0 # Raster-Snap-Weite (1 m — handhabbar für Objekte)
|
||||||
const FLOAT_EPSILON := 0.01 # ab dieser Abweichung gilt ein Objekt als "schwebend"
|
const FLOAT_EPSILON := 0.01 # ab dieser Abweichung gilt ein Objekt als "schwebend"
|
||||||
|
const LIFT_STEP_M := 0.25 # Anheben/Absenken: grober Rasterschritt (Karte t_7567a06f)
|
||||||
|
const DUPLICATE_OFFSET_M := Vector2(1.0, 1.0) # Kopie diagonal leicht versetzt
|
||||||
|
|
||||||
|
|
||||||
## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen
|
## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen
|
||||||
|
|
@ -32,3 +34,98 @@ static func snap_coord(value: float, enabled: bool, grid_m := GRID_M) -> float:
|
||||||
if not enabled or grid_m <= 0.0:
|
if not enabled or grid_m <= 0.0:
|
||||||
return value
|
return value
|
||||||
return roundf(value / grid_m) * grid_m
|
return roundf(value / grid_m) * grid_m
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------- Objekt-Ops (t_7567a06f) --
|
||||||
|
# Duplizieren · Anheben/Absenken · Spiegeln. Bewusst pure Funktionen wie der
|
||||||
|
# Rest der Datei: die UI (interaction.gd/main.gd) snapshotet, ruft auf, commitet.
|
||||||
|
|
||||||
|
## Duplizieren: Kopie von objects[index] ans ENDE der Liste hängen. Die Kopie
|
||||||
|
## ist diagonal um DUPLICATE_OFFSET_M versetzt (mit Raster-Snap eingerastet,
|
||||||
|
## wenn an) und behält Drehung/Größe/Höhe. Rückgabe: neuer Index (-1 = ungültig).
|
||||||
|
## VOR dem Aufruf prüft die UI Budget/Objekt-Limit (hilfs-Meldung statt Fehler).
|
||||||
|
static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool) -> int:
|
||||||
|
var objects: Array = doc.get("objects", [])
|
||||||
|
if index < 0 or index >= objects.size():
|
||||||
|
return -1
|
||||||
|
var source = objects[index]
|
||||||
|
if not (source is Dictionary):
|
||||||
|
return -1
|
||||||
|
var copy: Dictionary = (source as Dictionary).duplicate(true)
|
||||||
|
var pos: Array = copy.get("pos", [0.0, 0.0, 0.0])
|
||||||
|
if (pos as Array).size() == 3:
|
||||||
|
var nx := float(pos[0]) + DUPLICATE_OFFSET_M.x
|
||||||
|
var nz := float(pos[2]) + DUPLICATE_OFFSET_M.y
|
||||||
|
copy["pos"] = [snap_coord(nx, grid_enabled), float(pos[1]), snap_coord(nz, grid_enabled)]
|
||||||
|
objects.append(copy)
|
||||||
|
return objects.size() - 1
|
||||||
|
|
||||||
|
|
||||||
|
## Anheben/Absenken in LIFT_STEP_M-Schritten (delta_m positiv = anheben).
|
||||||
|
## 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):
|
||||||
|
## - 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).
|
||||||
|
## 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:
|
||||||
|
var objects: Array = doc.get("objects", [])
|
||||||
|
if index < 0 or index >= objects.size():
|
||||||
|
return 0.0
|
||||||
|
var obj = objects[index]
|
||||||
|
if not (obj is Dictionary):
|
||||||
|
return 0.0
|
||||||
|
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
||||||
|
if (pos as Array).size() != 3:
|
||||||
|
return 0.0
|
||||||
|
var y := float(pos[1])
|
||||||
|
var max_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)
|
||||||
|
var applied := target - y
|
||||||
|
if absf(applied) < 0.0001:
|
||||||
|
return 0.0
|
||||||
|
obj["pos"] = [float(pos[0]), target, float(pos[2])]
|
||||||
|
return applied
|
||||||
|
|
||||||
|
|
||||||
|
## Spiegeln des Objekts AN SEINER STELLE (nicht quer über die Map): die
|
||||||
|
## Position bleibt, die Blickrichtung wird gespiegelt:
|
||||||
|
## axis "x": Welt-X-Spiegelung („links↔rechts“) → rot_y_deg → −rot
|
||||||
|
## axis "z": Welt-Z-Spiegelung („vor↔zurück“) → rot_y_deg → 180° − rot
|
||||||
|
## Herleitung: Blickrichtungs-Vektor d(θ) = (−sin θ, −cos θ); Spiegelung
|
||||||
|
## negiert die jeweilige Komponente → −θ bzw. 180° − θ. Jede Spiegelung ist
|
||||||
|
## dadurch selbstinvers (zweimal spiegeln = Ausgangszustand).
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
## Höhe ÜBER dem Terrain am Objektstandort (> 0 = schwebt, ~0 = steht auf
|
||||||
|
## dem Boden). Für die Auswahl-Anzeige.
|
||||||
|
static func lift_offset(doc: Dictionary, index: int) -> float:
|
||||||
|
var objects: Array = doc.get("objects", [])
|
||||||
|
if index < 0 or index >= objects.size():
|
||||||
|
return 0.0
|
||||||
|
var obj = objects[index]
|
||||||
|
if not (obj is Dictionary):
|
||||||
|
return 0.0
|
||||||
|
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
||||||
|
if (pos as Array).size() != 3:
|
||||||
|
return 0.0
|
||||||
|
return float(pos[1]) - SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2]))
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ var redo_btn: Button
|
||||||
var sel_label: Label
|
var sel_label: Label
|
||||||
var sel_rotate_btn: Button
|
var sel_rotate_btn: Button
|
||||||
var sel_delete_btn: Button
|
var sel_delete_btn: Button
|
||||||
|
var sel_dup_btn: Button # Objekt-Ops (Karte t_7567a06f)
|
||||||
|
var sel_mirror_x_btn: Button
|
||||||
|
var sel_mirror_z_btn: Button
|
||||||
|
var sel_raise_btn: Button
|
||||||
|
var sel_lower_btn: Button
|
||||||
|
|
||||||
# Eigenschaften-Panel
|
# Eigenschaften-Panel
|
||||||
var prop_name: LineEdit
|
var prop_name: LineEdit
|
||||||
|
|
@ -396,7 +401,7 @@ func _build_center_viewport() -> SubViewportContainer:
|
||||||
|
|
||||||
# Bedien-Hinweis im Viewport (Overlay)
|
# Bedien-Hinweis im Viewport (Overlay)
|
||||||
help_label = Label.new()
|
help_label = Label.new()
|
||||||
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
|
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück"
|
||||||
help_label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
help_label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
||||||
help_label.offset_left = 12
|
help_label.offset_left = 12
|
||||||
help_label.offset_bottom = -8
|
help_label.offset_bottom = -8
|
||||||
|
|
@ -439,6 +444,39 @@ func _build_right_panel() -> PanelContainer:
|
||||||
sel_delete_btn.pressed.connect(interaction.delete_selected)
|
sel_delete_btn.pressed.connect(interaction.delete_selected)
|
||||||
sel_btns.add_child(sel_delete_btn)
|
sel_btns.add_child(sel_delete_btn)
|
||||||
|
|
||||||
|
# Objekt-Ops (Karte t_7567a06f): Duplizieren · Spiegeln · Anheben/Absenken.
|
||||||
|
var dup_row := HBoxContainer.new()
|
||||||
|
dup_row.add_theme_constant_override("separation", 8)
|
||||||
|
vbox.add_child(dup_row)
|
||||||
|
sel_dup_btn = _sel_op_button("⧉ Duplizieren (Strg+D)",
|
||||||
|
"Legt eine Kopie daneben — leicht versetzt, direkt angewählt. Strg+D geht auch.",
|
||||||
|
func() -> void: interaction.duplicate_selected())
|
||||||
|
dup_row.add_child(sel_dup_btn)
|
||||||
|
|
||||||
|
var mirror_row := HBoxContainer.new()
|
||||||
|
mirror_row.add_theme_constant_override("separation", 8)
|
||||||
|
vbox.add_child(mirror_row)
|
||||||
|
sel_mirror_x_btn = _sel_op_button("⇄ Links↔Rechts",
|
||||||
|
"Spiegelt das Objekt an seiner Stelle (links↔rechts, X-Achse) — die Blickrichtung wird mitgespiegelt.",
|
||||||
|
func() -> void: interaction.mirror_selected("x"))
|
||||||
|
mirror_row.add_child(sel_mirror_x_btn)
|
||||||
|
sel_mirror_z_btn = _sel_op_button("⇅ Vor↔Zurück",
|
||||||
|
"Spiegelt das Objekt an seiner Stelle (vor↔zurück, Z-Achse) — die Blickrichtung wird mitgespiegelt.",
|
||||||
|
func() -> void: interaction.mirror_selected("z"))
|
||||||
|
mirror_row.add_child(sel_mirror_z_btn)
|
||||||
|
|
||||||
|
var lift_row := HBoxContainer.new()
|
||||||
|
lift_row.add_theme_constant_override("separation", 8)
|
||||||
|
vbox.add_child(lift_row)
|
||||||
|
sel_raise_btn = _sel_op_button("▲ Anheben (Bild↑)",
|
||||||
|
"Objekt 0,25 m anheben — es schwebt dann, auch wenn der Boden darunter anders ist. Bild↑ geht auch.",
|
||||||
|
func() -> void: interaction.lift_selected(ObjectOps.LIFT_STEP_M))
|
||||||
|
lift_row.add_child(sel_raise_btn)
|
||||||
|
sel_lower_btn = _sel_op_button("▼ Absenken (Bild↓)",
|
||||||
|
"Objekt 0,25 m absenken — stoppt am Boden (nichts wird in Hügel begraben). Bild↓ geht auch.",
|
||||||
|
func() -> void: interaction.lift_selected(-ObjectOps.LIFT_STEP_M))
|
||||||
|
lift_row.add_child(sel_lower_btn)
|
||||||
|
|
||||||
vbox.add_child(_separator())
|
vbox.add_child(_separator())
|
||||||
|
|
||||||
# --- Map-Eigenschaften ---
|
# --- Map-Eigenschaften ---
|
||||||
|
|
@ -605,6 +643,15 @@ func _build_right_panel() -> PanelContainer:
|
||||||
return panel
|
return panel
|
||||||
|
|
||||||
|
|
||||||
|
func _sel_op_button(text: String, tip: String, action: Callable) -> Button:
|
||||||
|
var btn := Button.new()
|
||||||
|
btn.text = text
|
||||||
|
btn.custom_minimum_size = Vector2(140, 40)
|
||||||
|
btn.tooltip_text = tip
|
||||||
|
btn.pressed.connect(action)
|
||||||
|
return btn
|
||||||
|
|
||||||
|
|
||||||
func _header_label(text: String) -> Label:
|
func _header_label(text: String) -> Label:
|
||||||
var label := Label.new()
|
var label := Label.new()
|
||||||
label.text = text
|
label.text = text
|
||||||
|
|
@ -812,6 +859,20 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||||
else:
|
else:
|
||||||
interaction.delete_selected()
|
interaction.delete_selected()
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
KEY_D:
|
||||||
|
if ctrl and tool_mode == "objects":
|
||||||
|
interaction.duplicate_selected()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
KEY_PAGEUP:
|
||||||
|
if not ctrl and tool_mode == "objects" and interaction.selected_index >= 0 \
|
||||||
|
and not interaction.placing:
|
||||||
|
interaction.lift_selected(ObjectOps.LIFT_STEP_M)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
KEY_PAGEDOWN:
|
||||||
|
if not ctrl and tool_mode == "objects" and interaction.selected_index >= 0 \
|
||||||
|
and not interaction.placing:
|
||||||
|
interaction.lift_selected(-ObjectOps.LIFT_STEP_M)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------- Bibliothek ------
|
# --------------------------------------------------------- Bibliothek ------
|
||||||
|
|
@ -959,7 +1020,7 @@ func _leave_spawn_mode() -> void:
|
||||||
|
|
||||||
func _enter_objects_mode() -> void:
|
func _enter_objects_mode() -> void:
|
||||||
tool_mode = "objects"
|
tool_mode = "objects"
|
||||||
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
|
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück"
|
||||||
_base_tool_status()
|
_base_tool_status()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1014,7 +1075,7 @@ func _recheck_terrain_mode() -> void:
|
||||||
tool_mode = "objects"
|
tool_mode = "objects"
|
||||||
terrain_panel.visible = false
|
terrain_panel.visible = false
|
||||||
_sync_terrain_button()
|
_sync_terrain_button()
|
||||||
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
|
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Strg+D: duplizieren ·\nBild↑/↓: anheben/absenken · Spiegeln rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück"
|
||||||
_set_status("Terrain-Modus aus: Diese Map passt nicht ins Editor-MVP (Terrain-Auflösung über %d) — die Objekte bleiben normal bearbeitbar." % TerrainField.EDITOR_MAX_RESOLUTION, true)
|
_set_status("Terrain-Modus aus: Diese Map passt nicht ins Editor-MVP (Terrain-Auflösung über %d) — die Objekte bleiben normal bearbeitbar." % TerrainField.EDITOR_MAX_RESOLUTION, true)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1148,18 +1209,20 @@ func _mark_tree_selection_walk(item: TreeItem, idx: int) -> void:
|
||||||
func _sync_selection_panel() -> void:
|
func _sync_selection_panel() -> void:
|
||||||
var objects: Array = doc.get("objects", [])
|
var objects: Array = doc.get("objects", [])
|
||||||
var idx: int = interaction.selected_index
|
var idx: int = interaction.selected_index
|
||||||
if idx < 0 or idx >= objects.size():
|
var has_sel := idx >= 0 and idx < objects.size()
|
||||||
|
for btn in [sel_rotate_btn, sel_delete_btn, sel_dup_btn, sel_mirror_x_btn,
|
||||||
|
sel_mirror_z_btn, sel_raise_btn, sel_lower_btn]:
|
||||||
|
btn.disabled = not has_sel
|
||||||
|
if not has_sel:
|
||||||
sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an."
|
sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an."
|
||||||
sel_rotate_btn.disabled = true
|
|
||||||
sel_delete_btn.disabled = true
|
|
||||||
return
|
return
|
||||||
var obj: Dictionary = objects[idx]
|
var obj: Dictionary = objects[idx]
|
||||||
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
||||||
sel_label.text = "%s (Objekt %d von %d)\nPosition: %.0f, %.0f m · Richtung: %.0f°\nVerschieben: Ziehen · Drehen: R oder Mausrad (15°)\nLöschen: Entf-Taste · Strg+Z nimmt zurück" % [
|
var lift := ObjectOps.lift_offset(doc, idx)
|
||||||
|
var lift_text := "am Boden" if absf(lift) < 0.02 else ("%+.2f m über dem Boden" % lift)
|
||||||
|
sel_label.text = "%s (Objekt %d von %d)\nPosition: %.0f, %.0f m · Höhe: %s · Richtung: %.0f°\nZiehen: verschieben · R oder Mausrad: drehen (15°)\nBild↑/↓: anheben/absenken · Strg+D: duplizieren\nSpiegeln/Löschen: Buttons oben · Strg+Z: zurück" % [
|
||||||
object_label(str(obj.get("id", ""))), idx + 1, objects.size(),
|
object_label(str(obj.get("id", ""))), idx + 1, objects.size(),
|
||||||
float(pos[0]), float(pos[2]), float(obj.get("rot_y_deg", 0.0))]
|
float(pos[0]), float(pos[2]), lift_text, float(obj.get("rot_y_deg", 0.0))]
|
||||||
sel_rotate_btn.disabled = false
|
|
||||||
sel_delete_btn.disabled = false
|
|
||||||
|
|
||||||
|
|
||||||
## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster Schritt).
|
## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster Schritt).
|
||||||
|
|
@ -1174,7 +1237,7 @@ func _selection_status(idx: int) -> void:
|
||||||
var budget_warning := ""
|
var budget_warning := ""
|
||||||
if used > budget * 80 / 100:
|
if used > budget * 80 / 100:
|
||||||
budget_warning = " ⚠ Poly-Budget zu %.0f %% voll — bald passt kein Objekt mehr rein." % (100.0 * used / budget)
|
budget_warning = " ⚠ Poly-Budget zu %.0f %% voll — bald passt kein Objekt mehr rein." % (100.0 * used / budget)
|
||||||
_set_status("%s ausgewählt — ziehen zum Verschieben, R oder Mausrad zum Drehen, Entf zum Löschen.%s" % [
|
_set_status("%s ausgewählt — ziehen zum Verschieben, R oder Mausrad zum Drehen, Bild↑/↓ anheben/absenken, Strg+D duplizieren, Entf zum Löschen.%s" % [
|
||||||
object_label(str(obj.get("id", ""))), budget_warning])
|
object_label(str(obj.get("id", ""))), budget_warning])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
130
tests/test_ux.gd
130
tests/test_ux.gd
|
|
@ -64,6 +64,7 @@ func _run_tests() -> void:
|
||||||
_test_history_cap_and_clear()
|
_test_history_cap_and_clear()
|
||||||
_test_terrain_height()
|
_test_terrain_height()
|
||||||
_test_mesh_shapes()
|
_test_mesh_shapes()
|
||||||
|
_test_object_ops()
|
||||||
|
|
||||||
|
|
||||||
## Abnahme-Kriterium: Undo-Roundtrip (setzen → undo → redo) — und analog
|
## Abnahme-Kriterium: Undo-Roundtrip (setzen → undo → redo) — und analog
|
||||||
|
|
@ -238,3 +239,132 @@ func _test_mesh_shapes() -> void:
|
||||||
_check(absf(MeshShapes.half_height(b) - 2.0) < 0.001, "half_height(box 4 m) = 2 m — Fußpunkt-Konvention")
|
_check(absf(MeshShapes.half_height(b) - 2.0) < 0.001, "half_height(box 4 m) = 2 m — Fußpunkt-Konvention")
|
||||||
_check(absf(MeshShapes.half_height(sp) - 1.5) < 0.001, "half_height(sphere) = radius")
|
_check(absf(MeshShapes.half_height(sp) - 1.5) < 0.001, "half_height(sphere) = radius")
|
||||||
_check(absf(MeshShapes.half_height(cyl) - 6.0) < 0.001, "half_height(cylinder 12 m) = 6 m")
|
_check(absf(MeshShapes.half_height(cyl) - 6.0) < 0.001, "half_height(cylinder 12 m) = 6 m")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- Objekt-Ops: Duplizieren/Höhe/Spiegeln -------
|
||||||
|
## (Karte t_7567a06f — pure Funktionen in ObjectOps, UI-Wiring wird im
|
||||||
|
## Desktop-Smoke verifiziert; hier Headless-Beweis der Logik + Undo-Roundtrips.)
|
||||||
|
|
||||||
|
func _test_object_ops() -> void:
|
||||||
|
print("Testgruppe: Objekt-Ops (Duplizieren · Anheben/Absenken · Spiegeln)")
|
||||||
|
var doc := SvmapDocument.create_new("Objekt-Ops-Test", 1024.0, "mittag")
|
||||||
|
var hist := ActionHistory.new()
|
||||||
|
hist.begin(doc)
|
||||||
|
var objects: Array = doc["objects"]
|
||||||
|
|
||||||
|
# --- Duplizieren:stelle diagonal versetzt, Raster-Snap, Undo fähig ---
|
||||||
|
_place(hist, doc, "crate_wood", [10.0, 0.0, 10.0])
|
||||||
|
objects[0]["rot_y_deg"] = 30.0
|
||||||
|
var new_idx := ObjectOps.duplicate_object(doc, 0, true)
|
||||||
|
_check(new_idx == 1, "duplizieren: neuer Index 1")
|
||||||
|
_check(objects.size() == 2, "duplizieren: 1 → 2 Objekte")
|
||||||
|
_check(str(objects[1]["id"]) == "crate_wood", "duplizieren: gleiche ID")
|
||||||
|
_check(float(objects[1]["pos"][0]) == 11.0 and float(objects[1]["pos"][2]) == 11.0,
|
||||||
|
"duplizieren: diagonal +1 m versetzt (Raster-Snap 1 m)")
|
||||||
|
_check(absf(float(objects[1]["rot_y_deg"]) - 30.0) < 0.0001, "duplizieren: Drehung übernommen")
|
||||||
|
_check(float(objects[1]["pos"][1]) == float(objects[0]["pos"][1]), "duplizieren: Höhe übernommen")
|
||||||
|
|
||||||
|
# Kopie ohne Raster: Snap aus → exakt +1/+1 auch auf 0,5-m-Fraktionen.
|
||||||
|
objects[0]["pos"] = [10.4, 0.0, 10.4]
|
||||||
|
var idx2 := ObjectOps.duplicate_object(doc, 0, false)
|
||||||
|
_check(absf(float(objects[idx2]["pos"][0]) - 11.4) < 0.0001, "duplizieren ohne Snap: Fraktion bleibt (+1.0)")
|
||||||
|
# Fraktionsobjekt wieder aufräumen für die History-Tests:
|
||||||
|
objects.remove_at(idx2)
|
||||||
|
|
||||||
|
# Duplizieren + Undo → Kopie weg; Redo → wieder da (EIN Undo-Schritt).
|
||||||
|
var before := hist.snapshot(["objects"])
|
||||||
|
var idx3 := ObjectOps.duplicate_object(doc, 0, true)
|
||||||
|
hist.commit("Test: duplizieren", before, hist.snapshot(["objects"]))
|
||||||
|
_check(objects.size() == 3, "duplizieren über History: Kopie hängt an (3 = Original + Raster-Kopie + neue Kopie)")
|
||||||
|
hist.undo()
|
||||||
|
_check(objects.size() == 2, "duplizieren→undo: Kopie weg (2 bleiben)")
|
||||||
|
hist.redo()
|
||||||
|
_check(objects.size() == 3, "duplizieren→undo→redo: Kopie wieder da")
|
||||||
|
_check(absf(float(objects[2]["pos"][0]) - 11.0) < 0.0001, "duplizieren→redo: Position identisch")
|
||||||
|
|
||||||
|
# Ungültige Indizes schaden nichts:
|
||||||
|
_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 ---
|
||||||
|
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")
|
||||||
|
_check(absf(float(objects[0]["pos"][1]) - (h0 + 0.25)) < 0.0001, "anheben: y = Boden + 0,25 m")
|
||||||
|
ObjectOps.lift_object(doc, 0, ObjectOps.LIFT_STEP_M)
|
||||||
|
_check(absf(float(objects[0]["pos"][1]) - (h0 + 0.5)) < 0.0001, "anheben x2: 0,5 m")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 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(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.
|
||||||
|
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 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)")
|
||||||
|
|
||||||
|
# lift_offset (Offset der UI-Anzeige): angehoben → +0,25, am Boden → 0.
|
||||||
|
objects[0]["pos"] = [10.0, h0 + 0.25, 10.0]
|
||||||
|
_check(absf(ObjectOps.lift_offset(doc, 0) - 0.25) < 0.001, "lift_offset: +0,25 m sichtbar")
|
||||||
|
objects[0]["pos"] = [10.0, h0, 10.0]
|
||||||
|
_check(absf(ObjectOps.lift_offset(doc, 0)) < 0.001, "lift_offset: am Boden = 0")
|
||||||
|
|
||||||
|
# Merge-Tags wie beim Drehen: mehrere Lift-Ticks = EIN History-Eintrag.
|
||||||
|
var entries_before := hist.entries.size()
|
||||||
|
for i in 5:
|
||||||
|
var b := hist.snapshot(["objects"])
|
||||||
|
var y2 := float(objects[0]["pos"][1])
|
||||||
|
objects[0]["pos"] = [10.0, y2 + ObjectOps.LIFT_STEP_M, 10.0]
|
||||||
|
hist.commit("Objekt angehoben", b, hist.snapshot(["objects"]), "lift:0")
|
||||||
|
_check(hist.entries.size() == entries_before + 1, "5 Lift-Ticks → EIN History-Eintrag (Merge)")
|
||||||
|
hist.undo()
|
||||||
|
_check(absf(float(objects[0]["pos"][1]) - h0) < 0.001, "Merge-Undo: alle Stufen auf einmal weg")
|
||||||
|
|
||||||
|
# --- Spiegeln: Rotation wird mitgespiegelt, Position bleibt ---
|
||||||
|
objects[0]["rot_y_deg"] = 30.0
|
||||||
|
ObjectOps.mirror_object(objects[0], "x")
|
||||||
|
_check(absf(float(objects[0]["rot_y_deg"]) - 330.0) < 0.0001, "spiegeln X: 30° → 330° (−30°)")
|
||||||
|
ObjectOps.mirror_object(objects[0], "x")
|
||||||
|
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln X: selbstinvers (330° → 30°)")
|
||||||
|
_check(float(objects[0]["pos"][0]) == 10.0, "spiegeln: Position unverändert (am Objekt, nicht quer über die Map)")
|
||||||
|
|
||||||
|
objects[0]["rot_y_deg"] = 30.0
|
||||||
|
ObjectOps.mirror_object(objects[0], "z")
|
||||||
|
_check(absf(float(objects[0]["rot_y_deg"]) - 150.0) < 0.0001, "spiegeln Z: 30° → 150° (180° − rot)")
|
||||||
|
ObjectOps.mirror_object(objects[0], "z")
|
||||||
|
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln Z: selbstinvers (150° → 30°)")
|
||||||
|
|
||||||
|
# Spiegeln über History: ein Eintrag, Undo stellt die alte Richtung her.
|
||||||
|
objects[0]["rot_y_deg"] = 30.0
|
||||||
|
var before_m := hist.snapshot(["objects"])
|
||||||
|
ObjectOps.mirror_object(doc["objects"][0], "x")
|
||||||
|
hist.commit("Test: spiegeln", before_m, hist.snapshot(["objects"]))
|
||||||
|
hist.undo()
|
||||||
|
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, "spiegeln→undo: alte Richtung zurück")
|
||||||
|
|
||||||
|
# --- svmap-Roundtrip: Höhe + gespiegelte Rotation bleiben 1:1 erhalten ---
|
||||||
|
objects[0]["rot_y_deg"] = 210.0
|
||||||
|
objects[0]["pos"] = [5.0, h0 + 1.75, -6.0]
|
||||||
|
var path := "/tmp/t7567_roundtrip.svmap"
|
||||||
|
var res := SvmapIO.save_to_file(path, doc)
|
||||||
|
_check(res["ok"], "svmap-Roundtrip: angesenkte/gesp. Map speichert (V1–V14 ok)")
|
||||||
|
var loaded := SvmapIO.load_from_file(path)
|
||||||
|
_check(loaded["ok"], "svmap-Roundtrip: laden ok (Prüfsumme intakt)")
|
||||||
|
if loaded["ok"]:
|
||||||
|
var lo: Array = loaded["data"]["objects"]
|
||||||
|
_check(lo.size() == objects.size(), "svmap-Roundtrip: Objekt-Anzahl gleich")
|
||||||
|
_check(absf(float(lo[0]["pos"][1]) - (h0 + 1.75)) < 0.0001, "svmap-Roundtrip: angehobene Höhe bleibt (KEIN Extra-Format-Feld — pos.y ist frei)")
|
||||||
|
_check(absf(float(lo[0]["rot_y_deg"]) - 210.0) < 0.0001, "svmap-Roundtrip: gespiegelte Rotation bleibt")
|
||||||
|
_check(str(lo[1]["id"]) == "crate_wood", "svmap-Roundtrip: Kopie bleibt")
|
||||||
|
|
|
||||||
0
tools/parse_check.sh
Normal file → Executable file
0
tools/parse_check.sh
Normal file → Executable file
0
tools/run_tests.sh
Normal file → Executable file
0
tools/run_tests.sh
Normal file → Executable file
Loading…
Reference in a new issue