384 lines
17 KiB
GDScript
384 lines
17 KiB
GDScript
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,
|
||
offset_x := DUPLICATE_OFFSET_M.x) -> 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]) + offset_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,
|
||
offset_x := DUPLICATE_OFFSET_M.x) -> Array:
|
||
var new_indices: Array = []
|
||
for i in indices:
|
||
var ni := duplicate_object(doc, int(i), grid_enabled, offset_x)
|
||
if ni >= 0:
|
||
new_indices.append(ni)
|
||
regroup_copies(doc, new_indices)
|
||
return new_indices
|
||
|
||
|
||
## Kopien gruppierter Objekte bilden eine NEUE Gruppe je Original-Gruppe — sonst
|
||
## würde die Kopie eines Gebäudes mit dem Original zu EINER Gruppe verschmelzen
|
||
## (Gruppen-ID wird von duplicate(true) mitkopiert). Name: "<alt> (Kopie)".
|
||
static func regroup_copies(doc: Dictionary, new_indices: Array) -> void:
|
||
var objects: Array = doc.get("objects", [])
|
||
var mapping := {}
|
||
for ni in new_indices:
|
||
var obj: Variant = objects[int(ni)]
|
||
var old_gid := GroupOps.group_id(obj)
|
||
if old_gid == GroupOps.UNGROUPED:
|
||
continue
|
||
if not mapping.has(old_gid):
|
||
var gid := GroupOps.next_group_id(doc)
|
||
mapping[old_gid] = gid
|
||
var old_name := GroupState.display_name(doc, old_gid)
|
||
objects[int(ni)]["group"] = gid # sofort belegen, damit next_group_id weiterzählt
|
||
GroupState.set_group_name(doc, gid, "%s (Kopie)" % old_name)
|
||
(obj as Dictionary)["group"] = int(mapping[old_gid])
|
||
|
||
|
||
## 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 (Karte t_e166e656, Platzierungsfreiheit — Sly: „Steine versenken!"):
|
||
## - Anheben: frei nach oben, Höchstgrenze = bounds.max.y (Match-Grenze, V9).
|
||
## - 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:
|
||
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 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])
|
||
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
|
||
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).
|
||
## 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 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
|
||
## 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
|
||
|
||
|
||
# --------------------------------------------- Symmetrie — Objekte (M3) -----
|
||
# Karte t_34908425 (Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md): im
|
||
# Symmetrie-Modus wirkt jede Objekt-Änderung auf beide Seiten. Die Transform-
|
||
# Mathe und das sym-Feld kommen aus SymmetryCore (M1, t_f5935023) — hier nur
|
||
# die Objekt-Anwendung, KEINE zweite Spiegel-Mathe. Alles static + pure
|
||
# (Dokument rein, Mutation im Dokument), headless testbar via
|
||
# tests/test_object_mirror.gd. Modus OFF = no-op (Verhalten wie bisher).
|
||
#
|
||
# Partner-Regel (invariant): der Zwilling ist IMMER der exakte Spiegel seiner
|
||
# Quelle — Position via SymmetryCore.mirror_point (y bleibt 1:1, Spec-Tabelle),
|
||
# Drehung via SymmetryCore.mirror_rot_y (Punkt: +180°, Achse: Vorzeichen).
|
||
#
|
||
# ACHTUNG Indizes: symmetry_place_twin hängt den Zwilling ans ENDE der Liste —
|
||
# bestehende Indizes bleiben gültig (nur Anhängen, nie Verschieben).
|
||
|
||
## Zwilling beim PLATZIEREN/DUPLIZIEREN anlegen: objects[index] bekommt eine
|
||
## neue sym-Kennung (SymmetryCore.next_sym_key), der Zwilling mit exakt
|
||
## gespiegeltem Transform wandert ans ENDE der objects-Liste. Ein Objekt, das
|
||
## auf der Achse/im Zentrum liegt (SymmetryCore.maps_to_self), wird NICHT
|
||
## gedoppelt. Modus OFF oder ungültiger Index: es passiert nichts.
|
||
## Rückgabe: {"twin": int, "on_axis": bool} — twin = Index des Zwillings
|
||
## (-1 = keiner angelegt), on_axis = Objekt liegt auf Mitte/Achse.
|
||
static func symmetry_place_twin(doc: Dictionary, index: int, mode: int) -> Dictionary:
|
||
var out := {"twin": -1, "on_axis": false}
|
||
if mode == SymmetryCore.Mode.OFF:
|
||
return out
|
||
var objects: Array = doc.get("objects", [])
|
||
if index < 0 or index >= objects.size() or not (objects[index] is Dictionary):
|
||
return out
|
||
var src: Dictionary = objects[index]
|
||
var pos: Array = src.get("pos", [0.0, 0.0, 0.0])
|
||
if (pos as Array).size() != 3:
|
||
return out
|
||
var center := SymmetryCore.map_center(doc)
|
||
var p := Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
|
||
if SymmetryCore.maps_to_self(mode, p, center):
|
||
out["on_axis"] = true
|
||
return out
|
||
var twin: Dictionary = src.duplicate(true)
|
||
SymmetryCore.clear_sym(twin)
|
||
var mp := SymmetryCore.mirror_point(mode, p, center)
|
||
# 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]
|
||
# 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)
|
||
objects.append(twin)
|
||
out["twin"] = objects.size() - 1
|
||
return out
|
||
|
||
|
||
## Nach einer Positions-/Dreh-Änderung ALLE betroffenen Paare synchronisieren:
|
||
## der Zwilling wird auf den exakten Spiegel seiner Quelle gesetzt. members =
|
||
## Indizes der geänderten Objekte; gehört BEIDE Seiten eines Paares zur
|
||
## Änderung, ist die Quelle das primary_hint (das gezogene/gedrehte Primär-
|
||
## Objekt), sonst der niedrigere Index (deterministisch). Modus OFF: no-op.
|
||
## Rückgabe: Indizes der neu geschriebenen Zwillinge (für die Marker-Synchronisierung).
|
||
static func symmetry_sync_pairs(doc: Dictionary, members: Array, mode: int,
|
||
primary_hint: int = -1) -> Array:
|
||
if mode == SymmetryCore.Mode.OFF:
|
||
return []
|
||
var objects: Array = doc.get("objects", [])
|
||
var center := SymmetryCore.map_center(doc)
|
||
var changed: Array = []
|
||
var handled: Dictionary = {}
|
||
for i in members:
|
||
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
|
||
continue
|
||
var key := SymmetryCore.sym_of(objects[i] as Dictionary)
|
||
if key.is_empty():
|
||
continue
|
||
var t := SymmetryCore.find_partner_index(objects, key, i)
|
||
if t < 0:
|
||
continue
|
||
var pair_key := "%d|%d" % [mini(i, t), maxi(i, t)]
|
||
if handled.has(pair_key):
|
||
continue
|
||
handled[pair_key] = true
|
||
var src_i: int = i
|
||
if members.has(t):
|
||
# Beide Seiten in der Änderung (z. B. beide markiert und gezogen):
|
||
# die Quelle entscheidet, der Zwilling wird neu gespiegelt — so
|
||
# bleibt das Paar exakt spiegelbildlich statt auseinanderzulaufen.
|
||
if primary_hint == t:
|
||
src_i = t
|
||
elif primary_hint != i and t < i:
|
||
src_i = t
|
||
var twin_i: int = t if src_i == i else i
|
||
var src: Dictionary = objects[src_i]
|
||
var spos: Array = src.get("pos", [0.0, 0.0, 0.0])
|
||
if (spos as Array).size() != 3:
|
||
continue
|
||
var mp := SymmetryCore.mirror_point(mode,
|
||
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]
|
||
# 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
|
||
|
||
|
||
## Löschen erweitern: zu den gewählten Indizes kommen die sym-Partner dazu
|
||
## (jeder Partner genau einmal, auch wenn mehrere Quellen dasselbe Paar
|
||
## treffen). Modus OFF: Liste unverändert (Verhalten wie bisher).
|
||
static func symmetry_expand_delete(doc: Dictionary, doomed: Array, mode: int) -> Array:
|
||
if mode == SymmetryCore.Mode.OFF:
|
||
return doomed
|
||
var objects: Array = doc.get("objects", [])
|
||
var out: Array = doomed.duplicate()
|
||
for i in doomed:
|
||
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
|
||
continue
|
||
var key := SymmetryCore.sym_of(objects[i] as Dictionary)
|
||
if key.is_empty():
|
||
continue
|
||
var t := SymmetryCore.find_partner_index(objects, key, i)
|
||
if t >= 0 and not out.has(t):
|
||
out.append(t)
|
||
return out
|