scopedvr-map-core/editor/object_ops.gd
slyphax 17a8927fec Map-Kern aus dem Editor übernommen (Stand e8ab954)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:30:18 +01:00

384 lines
17 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,
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