scopedvr-map-core/editor/build_snap.gd
2026-11-01 19:31:33 +01:00

256 lines
11 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 BuildSnap
## Raster- und Ebenen-Logik des Bauen-Modus (pure Funktionen, headless testbar).
##
## Warum neu: die alte Logik rastete jedes Teil NUR auf eine Zellecke und nahm
## als Höhe die Klickhöhe — ein Klick auf die Seite einer Wand setzte das neue
## Teil mitten in die Luft, Wände ließen sich nur über Rotationsraten an die
## richtige Kante bringen. Jetzt:
## • XZ: Zellen-Teile rasten in die Zellmitte, Wände auf die NÄCHSTE Zellkante
## (Drehung ergibt sich aus der Kante), Pfeiler auf die nächste Ecke,
## Treppen auf eine 4×2-m-Fläche über zwei Zellen.
## • Y: nie die Klickhöhe, sondern eine EBENE: Gelände (höchster Punkt unter
## der Grundfläche) oder Oberkante eines tragenden Bauteils darunter. Gewählt
## wird die höchste Ebene, die nicht mehr als TOL_M über dem Klickpunkt liegt.
## • Ein gleichartiges Teil auf demselben Platz wird ERSETZT (Tür in eine
## vorhandene Wand einsetzen); Klick auf die Oberkante stapelt stattdessen.
##
## Position eines Teils = Fußpunkt (Unterkante, Mitte der Grundfläche) — wie
## jedes Bibliotheks-Objekt (BauKatalog.mesh ist so normalisiert).
const TOL_M := 0.35 # so weit darf eine Ebene ÜBER dem Klickpunkt liegen
const SHRINK_M := 0.05 # Grundflächen-Schnitt: minimale Überlappung ignorieren
const SAME_M := 0.08 # Toleranz für "gleiche Stelle"
const UEBERSTAND_M := 0.01 # Wand-Außenseite steht 1 cm vor der Plattenkante: keine deckungsgleichen
# Flächen (Z-Fighting/Flackern an Boden↔Wand)
## Ergebnis-Wörterbuch:
## ok false = hier nicht setzbar (grund erklärt warum)
## pos Vector3 Fußpunkt
## rot Y-Drehung in Grad
## ersetze Index des Objekts, das ersetzt wird (-1 = neues Teil)
## ebene gewählte Höhe (= pos.y)
## grund Klartext (bei ok=false) bzw. Hinweis
##
## teile: Array von {"i": Index, "oid": String, "pos": Vector3, "rot": float}
## (nur Bauteile, siehe sammle_teile).
## gelaende: Callable(x: float, z: float) -> float
## dreh: Nutzerdrehung in Grad (wird auf 90° gerastet)
## kamera_dir: Blickrichtung des Klickstrahls (für Zell-Wahl an Wandkanten), optional
## ebene_fix: feste Geschosshöhe (Geschoss-Wahl im Bauen-Tab); NAN = automatisch.
## Dann hängt die Höhe NICHT mehr an Wänden/Böden darunter — Decken und Böden
## lassen sich auch mitten im großen Raum setzen.
static func berechne(oid: String, hit: Vector3, dreh: float, teile: Array,
gelaende: Callable, hit_ist_objekt := false, kamera_dir := Vector3.ZERO,
ebene_fix := NAN) -> Dictionary:
var art := BauKatalog.snap_art(oid)
var gr := BauKatalog.groesse(oid)
var q := int(roundf(dreh / 90.0))
var viertel := ((q % 4) + 4) % 4
var p := Vector2(hit.x, hit.z)
# Treffer auf einem Objekt: Wände liegen GANZ in einer Zelle — die Quer-Achse
# kommt dann von der Wand (Decke/Dach landet in der Zelle der Wand). Bei anderen
# Objekten die Zelle auf der Kameraseite wählen (Klick liegt auf einer Grenze).
var wand_getroffen := {}
if hit_ist_objekt:
wand_getroffen = _wand_unter_klick(teile, p, hit.y)
if hit_ist_objekt and art != "kante":
if not wand_getroffen.is_empty():
var wp: Vector3 = wand_getroffen["pos"]
if absf(sin(deg_to_rad(float(wand_getroffen["rot"])))) > 0.5:
p.x = wp.x
else:
p.y = wp.z
elif kamera_dir != Vector3.ZERO:
var d2 := Vector2(kamera_dir.x, kamera_dir.z)
if d2.length() > 0.01:
p -= d2.normalized() * 0.2
var mitte := Vector2.ZERO
var rot := 0.0
var r := BauKatalog.RASTER_M
match art:
"zelle":
# Teile über mehrere Zellen (große Dächer): gerade Zellzahl → Mitte auf
# einer Zellecke, ungerade → Mitte einer Zelle.
mitte = Vector2(_raster_mitte(p.x, gr.x), _raster_mitte(p.y, gr.z))
rot = viertel * 90.0
"ecke":
mitte = Vector2(roundf(p.x / r) * r, roundf(p.y / r) * r)
"treppe":
rot = viertel * 90.0
if viertel % 2 == 0: # Lauf entlang X: Mitte auf gerader X-, ungerader Z-Linie
mitte = Vector2(roundf(p.x / r) * r, floorf(p.y / r) * r + r * 0.5)
else:
mitte = Vector2(floorf(p.x / r) * r + r * 0.5, roundf(p.y / r) * r)
_: # "kante"
var vorhanden := wand_getroffen
if not vorhanden.is_empty():
# Klick auf eine vorhandene Wand (Seite = ersetzen, Oberkante = stapeln):
# GENAU ihre Lage übernehmen, nicht neu aus der Klickstelle raten.
var tp: Vector3 = vorhanden["pos"]
mitte = Vector2(tp.x, tp.z)
rot = 90.0 if absf(sin(deg_to_rad(float(vorhanden["rot"])))) > 0.5 else 0.0
else:
# Wand liegt GANZ IN der Zelle, in der der Mauszeiger ist: Außenseite
# bündig mit der Zellkante (Boden/Fundament), nicht halb in der Luft.
# Dadurch schließen sich auch Ecken ohne Lücke (Test-Befund 25.10.).
var laenge := gr.x
var halbe_dicke := gr.z * 0.5
var fx := p.x / r
var fz := p.y / r
var h_mitte := Vector2(_raster_mitte(p.x, laenge), roundf(fz) * r) # Kante entlang X
var v_mitte := Vector2(roundf(fx) * r, _raster_mitte(p.y, laenge)) # Kante entlang Z
if p.distance_squared_to(h_mitte) <= p.distance_squared_to(v_mitte):
mitte = h_mitte
mitte.y += (halbe_dicke - UEBERSTAND_M) * (1.0 if p.y >= h_mitte.y else -1.0)
rot = 0.0
else:
mitte = v_mitte
mitte.x += (halbe_dicke - UEBERSTAND_M) * (1.0 if p.x >= v_mitte.x else -1.0)
rot = 90.0
rot += 180.0 * float(viertel % 2) # R dreht die Wand um (Blickseite der Teile)
rot = fposmod(rot, 360.0)
var quer := absf(sin(deg_to_rad(rot))) > 0.5 # 90°/270°: X- und Z-Ausdehnung getauscht
var half := Vector2(gr.z, gr.x) * 0.5 if quer else Vector2(gr.x, gr.z) * 0.5
var rect := Rect2(mitte - half, half * 2.0)
# Ebenen sammeln: Gelände + Oberkanten tragender Teile unter der Grundfläche.
var gel_max := -INF
for sx in [0.0, 0.5, 1.0]:
for sz in [0.0, 0.5, 1.0]:
gel_max = maxf(gel_max, float(gelaende.call(rect.position.x + rect.size.x * sx,
rect.position.y + rect.size.y * sz)))
var ebenen: Array = [gel_max]
var kern := rect.grow(-SHRINK_M)
for t in teile:
var troid := str(t["oid"])
var tr := teil_rect(t)
if not tr.intersects(kern):
continue
var top: float = (t["pos"] as Vector3).y + BauKatalog.groesse(troid).y
if not BauKatalog.ist_traeger(troid):
continue
if (art == "kante" or art == "ecke") and BauKatalog.ist_platte(troid):
# Wände/Pfeiler zählen von der UNTERKANTE einer Platte (Boden/Decke/
# Fundament): jedes Geschoss liegt dadurch auf Vielfachen der Wandhöhe —
# mit oder ohne Decke darunter sitzen obere Wände gleich hoch
# (Test-Befund 25.10.). Die Platte selbst liegt auf der Wandoberkante.
ebenen.append((t["pos"] as Vector3).y)
else:
ebenen.append(top)
var ebene := ebene_fix if not is_nan(ebene_fix) else _waehle_ebene(ebenen, hit.y)
# Platz belegt? Gleiche Art + gleiche Stelle + gleiche Ebene → ersetzen/ablehnen.
var ersetze := -1
for t in teile:
var troid := str(t["oid"])
if BauKatalog.snap_art(troid) != art:
continue
var tp: Vector3 = t["pos"]
if absf(tp.y - ebene) > SAME_M:
continue
if Vector2(tp.x, tp.z).distance_to(mitte) > SAME_M:
continue
if art == "kante" and absf(fposmod(float(t["rot"]), 180.0) - fposmod(rot, 180.0)) > 1.0:
continue
if art == "treppe" and absf(fposmod(float(t["rot"]), 360.0) - rot) > 1.0:
continue
if troid == oid and absf(fposmod(float(t["rot"]) - rot, 360.0)) < 1.0:
return {"ok": false, "pos": Vector3(mitte.x, ebene, mitte.y), "rot": rot, "ersetze": -1,
"ebene": ebene, "grund": "Hier steht schon %s." % BauKatalog.label(oid)}
ersetze = int(t["i"])
break
return {"ok": true, "pos": Vector3(mitte.x, ebene, mitte.y), "rot": rot, "ersetze": ersetze,
"ebene": ebene, "grund": ""}
## Mitte eines Teils der Länge `laenge` entlang einer Achse: gerade Zellzahl →
## auf der nächsten Zellgrenze, ungerade → Mitte der Zelle.
static func _raster_mitte(wert: float, laenge: float) -> float:
var r := BauKatalog.RASTER_M
var zellen := maxi(1, int(roundf(laenge / r)))
if zellen % 2 == 0:
return roundf(wert / r) * r
return floorf(wert / r) * r + r * 0.5
## Vorhandene Wand, auf die der Klick trifft (Seite oder Oberkante): Rückgabe das
## Teil-Wörterbuch oder {}. `p` = Klickpunkt in XZ, `y` = Klickhöhe.
static func _wand_unter_klick(teile: Array, p: Vector2, y: float) -> Dictionary:
var bester := {}
var bester_d := INF
for t in teile:
var troid := str(t["oid"])
if BauKatalog.snap_art(troid) != "kante":
continue
var tr := teil_rect(t).grow(0.06)
if not tr.has_point(p):
continue
var unten: float = (t["pos"] as Vector3).y
var oben := unten + BauKatalog.groesse(troid).y
if y < unten - 0.1 or y > oben + 0.1:
continue
var d := p.distance_squared_to(tr.get_center())
if d < bester_d:
bester_d = d
bester = t
return bester
## Höchste Ebene ≤ hit_y + TOL; gibt es keine (Klick tief unter allen), die tiefste.
static func _waehle_ebene(ebenen: Array, hit_y: float) -> float:
var best := -INF
var tiefste := INF
for e in ebenen:
var ev := float(e)
tiefste = minf(tiefste, ev)
if ev <= hit_y + TOL_M and ev > best:
best = ev
return best if best > -INF else tiefste
## Grundfläche (XZ) eines gesetzten Bauteils in Weltkoordinaten.
static func teil_rect(t: Dictionary) -> Rect2:
var gr := BauKatalog.groesse(str(t["oid"]))
var rot := float(t["rot"])
var quer := absf(sin(deg_to_rad(rot))) > 0.5
var half := Vector2(gr.z, gr.x) * 0.5 if quer else Vector2(gr.x, gr.z) * 0.5
var pos: Vector3 = t["pos"]
return Rect2(Vector2(pos.x, pos.z) - half, half * 2.0)
## Alle Bauteile aus doc["objects"] (Index, oid, pos, rot) — nur `bau_*`.
static func sammle_teile(doc: Dictionary) -> Array:
var out: Array = []
var objects: Array = doc.get("objects", [])
for i in objects.size():
var o: Variant = objects[i]
if not (o is Dictionary):
continue
var oid := str((o as Dictionary).get("id", ""))
if not BauKatalog.ist_bau(oid):
continue
var pos: Array = (o as Dictionary).get("pos", [0.0, 0.0, 0.0])
if pos.size() != 3:
continue
out.append({
"i": i, "oid": oid,
"pos": Vector3(float(pos[0]), float(pos[1]), float(pos[2])),
"rot": float(SvmapIO.objekt_rotation(o as Dictionary).y),
})
return out
## Höhe des untersten Geschosses (= Fußpunkt der tiefsten Platte/Wand im Umkreis von
## `radius` um `p`), sonst das Gelände bei `p`. Geschoss k liegt bei Basis + k·WAND_H.
static func geschoss_basis(teile: Array, p: Vector2, gelaende: Callable, radius := 40.0) -> float:
var tiefste := INF
for t in teile:
var tp: Vector3 = t["pos"]
if Vector2(tp.x, tp.z).distance_to(p) > radius:
continue
var oid := str(t["oid"])
if BauKatalog.ist_platte(oid) or BauKatalog.snap_art(oid) == "kante":
tiefste = minf(tiefste, tp.y)
return tiefste if tiefste < INF else float(gelaende.call(p.x, p.y))