256 lines
11 KiB
GDScript
256 lines
11 KiB
GDScript
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))
|