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))