scopedvr-map-editor/src/editor/object_ops.gd
Hermes (godot-vr) 08a75ac430 Shortcuts 2/3 (t_55257792): R-Drehen + Pfeil-Raster + PgUp/Down + Strg+Bild↑ + Greifen + Terrain-Pinsel-Tasten.
- R = Auswahl 90° drehen (Szene um Auswahl-Mitte, Bauen-Bauteil um Fußpunkt, Gruppe komplett; Umschalt+R = -90°), Mausrad-Feindrehen 15° bleibt
- Pfeiltasten = 2-m-Raster-Verschiebung, Richtung = Kamera-Blickrichtung auf nächste Weltachse gerastet (MoveMath.nudge_delta, pure)
- Bild↑/↓ = ±0,25 m auf der GANZEN Auswahl (EIN Undo), Strg+Bild↑ = Fußpunkt aufs Terrain/Boden absetzen
- G = Greifen (Blender-Semantik): Auswahl hängt an der Maus, LMB setzt mit Raster-Snap, Esc rollt byte-genau zurück (ShortcutPolicy cancel_grab)
- Terrain-Pinsel: [ / ] = Preset-Zyklus Klein/Mittel/Groß (Slider/Label/Preset-Buttons wandern mit), 1-4 = Heben/Senken/Glätten/Malen (PAINT-Clamp-Fix, Palette mit Malen sichtbar); Ziffern NUR im Terrain-Werkzeug
- Strg halten = temporärer Snap-Zwang beim Bewegen/Greifen/Platzieren
- Tests: test_shortcuts.gd 157 Checks (R byte-genau, Pfeil-Raster, Lift-Stufen, Boden-Snap, Greifen+Esc, Pinsel-Zyklus, History-Eintrag pro Operation), test_gui_smoke.gd erweitert (38 Checks) und in run_tests.sh aufgenommen; F-Regression seit Karte 1 damit abgedeckt
- Suite 9/9 grün (696 Checks), Builder-Export 21/0, Parse-Check 11 Dateien grün
2026-10-16 17:53:10 +00:00

225 lines
10 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)
## Auswahl per Pfeiltaste im 2-m-Raster verschieben (Karte t_55257792) — pure.
## Genau das gegebene Delta (±2 m pro Tastendruck, Richtung von MoveMath),
## X/Z an der Map geklemmt. Die HÖHE bleibt relativ zum Terrain erhalten
## (t_7567a06f-Semantik wie beim Ziehen): ein angehobenes Objekt schwebt
## weiter, ein bodennahes bleibt bodennah — ein Objekt auf dem 2-m-Raster
## landet wieder exakt auf dem Raster. Rückgabe: Anzahl geänderter Objekte.
static func nudge_objects(doc: Dictionary, indices: Array, delta: Vector3) -> int:
var objects: Array = doc.get("objects", [])
var half := _terrain_half_size(doc)
var changed := 0
for i in indices:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var obj: Dictionary = objects[i]
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
if (pos as Array).size() != 3:
continue
var x := clampf(float(pos[0]) + delta.x, -half.x, half.x)
var z := clampf(float(pos[2]) + delta.z, -half.y, half.y)
var lift := float(pos[1]) - SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2]))
var y := SvmapDocument.terrain_height_at(doc, x, z) + lift
if absf(x - float(pos[0])) > 0.0001 or absf(z - float(pos[2])) > 0.0001:
changed += 1
obj["pos"] = [x, y, z]
return changed
## Die AUSWAHL auf den Boden absetzen (Strg+Bild↑, Karte t_55257792 — das
## Godot-"Snap Object to Floor"-Äquivalent fürs Fußpunkt-Modell): pos.y wird
## exakt die Terrain-Höhe an (x, z). X/Z bleiben unangetastet. Rückgabe:
## Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden).
static func snap_selection_to_floor(doc: Dictionary, indices: Array) -> int:
var objects: Array = doc.get("objects", [])
var changed := 0
for i in indices:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var pos: Array = (objects[i] as Dictionary).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:
(objects[i] as Dictionary)["pos"] = [float(pos[0]), h, float(pos[2])]
changed += 1
return changed
static func _terrain_half_size(doc: Dictionary) -> Vector2:
var terrain: Dictionary = doc.get("terrain", {})
return Vector2(float(terrain.get("size_x_m", 1024.0)), float(terrain.get("size_z_m", 1024.0))) * 0.5
## 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