scopedvr-map-editor/src/editor/object_ops.gd
Hermes (godot-vr) 095af5ff3c Shortcuts 1/3 (t_c46d31c2): Freelook-Flug + F-Focus + Esc-Policy + Entf-Mehrfachauswahl + Strg+D (+2 m X) + Strg+LMB-Mehrfachauswahl.
- InputMap-Actions mit physischen KEY_*-Codes (shortcut_keys.gd, idempotent) — DE-Layout/Windows-unabhängig
- Freelook: RMT halten = Umsehen+Flug (WASD/EQ, Shift x3, Alt x0.3, Rad = Grundtempo dauerhaft), Shift+F toggle; Orbit bleibt Default, Flug-Exit ohne Kamera-Sprung (FlyMath pure)
- F zentriert Auswahl (SelectionMath.focus_center); Esc = pure Prioritätskette (ShortcutPolicy)
- Strg+LMB + Baum SELECT_MULTI: Mehrfachauswahl synchron Szene↔Baum; Entf/Strg+D wirken auf die ganze Auswahl (EIN Undo-Schritt)
- docs/tasten.md = deutsche Source of truth (Karten 2/3 ergänzen)
- Tests: test_shortcuts.gd NEU 70 Checks, Suite 571 grün, Builder-Export 21/0, Parse-Check grün
2026-10-16 17:43:17 +00:00

173 lines
7.8 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

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

extends RefCounted
class_name ObjectOps
## Objekt-Operationen des Editors (Karte t_3a998ae4) — bewusst pure Funktionen
## (kein UI, kein Knoten): Snap-to-Ground und Raster-Snap. main.gd/interaction.gd
## rufen auf, tests/test_library.gd prüft headless.
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 LIFT_STEP_M := 0.25 # Anheben/Absenken: grober Rasterschritt (Karte t_7567a06f)
const DUPLICATE_OFFSET_M := Vector2(2.0, 0.0) # Kopie +2 m in X (Rasterversatz,
# Karte t_c46d31c2)
## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen
## z. B. nach dem Terrain-Formen in der Luft — ein Klick holt sie runter.
## Rückgabe: Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden).
static func snap_to_ground(doc: Dictionary) -> int:
var changed := 0
var objects: Array = doc.get("objects", [])
for obj in objects:
if not (obj is Dictionary):
continue
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
if (pos as Array).size() != 3:
continue
var h := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2]))
if absf(float(pos[1]) - h) > FLOAT_EPSILON:
obj["pos"] = [float(pos[0]), h, float(pos[2])]
changed += 1
return changed
## Eine Koordinate aufs Raster legen (Raster-Snap). grid_m <= 0 oder aus = no-op.
static func snap_coord(value: float, enabled: bool, grid_m := GRID_M) -> float:
if not enabled or grid_m <= 0.0:
return value
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 um DUPLICATE_OFFSET_M versetzt (+2 m in X, Rasterversatz Karte
## t_c46d31c2; mit Raster-Snap eingerastet, wenn an) und behält Drehung/Größe/
## Höhe — der Füßpunkt (pos.y) bleibt exakt. 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
## MEHRERE Objekte auf einmal duplizieren (Karte t_c46d31c2, Strg+D bei
## Mehrfachauswahl): jede Kopie über duplicate_object (+2 m in X), Reihenfolge
## der Quellen bleibt erhalten, alle Indizes sind Originale (nur angehängt).
## Rückgabe: Liste der NEUEN Indizes (in Quell-Reihenfolge). Die UI commitet
## EINEN Undo-Schritt für alle Kopien zusammen.
static func duplicate_objects(doc: Dictionary, indices: Array, grid_enabled: bool) -> Array:
var new_indices: Array = []
for i in indices:
var ni := duplicate_object(doc, int(i), grid_enabled)
if ni >= 0:
new_indices.append(ni)
return new_indices
## 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]))
# ------------------------------------------------- Platzier-Fußpunkt (C) ----
# Karte t_4092e3a0 (Gerätebefund: „Ghost saß an der Unterlage, platziert saß
# das Objekt HÖHER"): Ghost-Preview und Platzierung MÜSSEN denselben Pfad
# nutzen. Diese eine Funktion ist der Pfad:
# 1. Raster-Snap auf X/Z (wenn an),
# 2. Y = Terrain-Höhe am GERASTETEN Punkt (SvmapDocument.terrain_height_at)
# — das ist der Objekt-Fußpunkt (Konvention, README §Konventionen):
# „pos.y = Terrainhöhe an (x, z)“. Der sichtbare Lift (halbe Mesh-Höhe,
# aus der Registry-Kollision via MeshShapes.half_height) ist Aufgabe der
# Darstellung (Viewport-Bauer UND Ghost-Kind-Mesh machen es identisch).
# clamp_to_map: true = Ghost clampft still auf die Map (Vorschau bleibt
# sichtbar am Rand); false = Platzierung bekommt den ungeclampten Punkt und
# die UI meldet „außerhalb der Map“.
static func placement_foot(doc: Dictionary, hit: Vector3, grid_enabled: bool,
half: Vector2, clamp_to_map: bool) -> Vector3:
var pos := hit
pos.x = snap_coord(pos.x, grid_enabled)
pos.z = snap_coord(pos.z, grid_enabled)
if clamp_to_map:
pos.x = clampf(pos.x, -half.x, half.x)
pos.z = clampf(pos.z, -half.y, half.y)
pos.y = SvmapDocument.terrain_height_at(doc, pos.x, pos.z)
return pos