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