diff --git a/library/kollisions/bau_dach_ecke_aussen.json b/library/kollisions/bau_dach_ecke_aussen.json new file mode 100644 index 0000000..29a04d4 --- /dev/null +++ b/library/kollisions/bau_dach_ecke_aussen.json @@ -0,0 +1 @@ +{"aabb_max":[1.0,2.0,1.0],"aabb_min":[-1.0,0.0,-1.0],"dreiecke":6,"oid":"bau_dach_ecke_aussen","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_dach_ecke_aussen.glb","vertices":[[-1.0,0.0,-1.0],[1.0,0.0,-1.0],[1.0,2.0,1.0],[-1.0,0.0,-1.0],[1.0,2.0,1.0],[-1.0,0.0,1.0],[1.0,0.0,-1.0],[1.0,0.0,1.0],[1.0,2.0,1.0],[-1.0,0.0,1.0],[1.0,2.0,1.0],[1.0,0.0,1.0],[-1.0,0.0,-1.0],[1.0,0.0,1.0],[1.0,0.0,-1.0],[-1.0,0.0,-1.0],[-1.0,0.0,1.0],[1.0,0.0,1.0]]} \ No newline at end of file diff --git a/library/kollisions/bau_dach_ecke_innen.json b/library/kollisions/bau_dach_ecke_innen.json new file mode 100644 index 0000000..0560d79 --- /dev/null +++ b/library/kollisions/bau_dach_ecke_innen.json @@ -0,0 +1 @@ +{"aabb_max":[1.0,2.0,1.0],"aabb_min":[-1.0,0.0,-1.0],"dreiecke":10,"oid":"bau_dach_ecke_innen","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_dach_ecke_innen.glb","vertices":[[-1.0,0.0,-1.0],[1.0,2.0,-1.0],[1.0,2.0,1.0],[-1.0,0.0,-1.0],[1.0,2.0,1.0],[-1.0,2.0,1.0],[-1.0,0.0,-1.0],[1.0,0.0,-1.0],[1.0,2.0,-1.0],[-1.0,0.0,-1.0],[-1.0,2.0,1.0],[-1.0,0.0,1.0],[1.0,0.0,-1.0],[1.0,0.0,1.0],[1.0,2.0,1.0],[1.0,0.0,-1.0],[1.0,2.0,1.0],[1.0,2.0,-1.0],[-1.0,0.0,1.0],[1.0,2.0,1.0],[1.0,0.0,1.0],[-1.0,0.0,1.0],[-1.0,2.0,1.0],[1.0,2.0,1.0],[-1.0,0.0,-1.0],[1.0,0.0,1.0],[1.0,0.0,-1.0],[-1.0,0.0,-1.0],[-1.0,0.0,1.0],[1.0,0.0,1.0]]} \ No newline at end of file diff --git a/library/kollisions/bau_dach_giebel_ende.json b/library/kollisions/bau_dach_giebel_ende.json new file mode 100644 index 0000000..40da381 --- /dev/null +++ b/library/kollisions/bau_dach_giebel_ende.json @@ -0,0 +1 @@ +{"aabb_max":[1.0,2.0,0.1],"aabb_min":[-1.0,0.0,-0.1],"dreiecke":8,"oid":"bau_dach_giebel_ende","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_dach_giebel_ende.glb","vertices":[[-1.0,0.0,-0.1],[1.0,0.0,-0.1],[-1.0,2.0,-0.1],[-1.0,0.0,0.1],[-1.0,2.0,0.1],[1.0,0.0,0.1],[-1.0,0.0,-0.1],[-1.0,0.0,0.1],[1.0,0.0,0.1],[-1.0,0.0,-0.1],[1.0,0.0,0.1],[1.0,0.0,-0.1],[-1.0,0.0,-0.1],[-1.0,2.0,-0.1],[-1.0,2.0,0.1],[-1.0,0.0,-0.1],[-1.0,2.0,0.1],[-1.0,0.0,0.1],[1.0,0.0,-0.1],[1.0,0.0,0.1],[-1.0,2.0,0.1],[1.0,0.0,-0.1],[-1.0,2.0,0.1],[-1.0,2.0,-0.1]]} \ No newline at end of file diff --git a/library/kollisions/bau_dach_plateau.json b/library/kollisions/bau_dach_plateau.json new file mode 100644 index 0000000..a37593b --- /dev/null +++ b/library/kollisions/bau_dach_plateau.json @@ -0,0 +1 @@ +{"aabb_max":[1.0,2.0,1.0],"aabb_min":[-1.0,0.0,-1.0],"dreiecke":12,"oid":"bau_dach_plateau","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_dach_plateau.glb","vertices":[[-1.0,2.0,1.0],[1.0,2.0,1.0],[-1.0,0.0,1.0],[1.0,2.0,1.0],[1.0,0.0,1.0],[-1.0,0.0,1.0],[1.0,2.0,-1.0],[-1.0,2.0,-1.0],[1.0,0.0,-1.0],[-1.0,2.0,-1.0],[-1.0,0.0,-1.0],[1.0,0.0,-1.0],[1.0,2.0,1.0],[1.0,2.0,-1.0],[1.0,0.0,1.0],[1.0,2.0,-1.0],[1.0,0.0,-1.0],[1.0,0.0,1.0],[-1.0,2.0,-1.0],[-1.0,2.0,1.0],[-1.0,0.0,-1.0],[-1.0,2.0,1.0],[-1.0,0.0,1.0],[-1.0,0.0,-1.0],[1.0,2.0,1.0],[-1.0,2.0,1.0],[1.0,2.0,-1.0],[-1.0,2.0,1.0],[-1.0,2.0,-1.0],[1.0,2.0,-1.0],[-1.0,0.0,1.0],[1.0,0.0,1.0],[-1.0,0.0,-1.0],[1.0,0.0,1.0],[1.0,0.0,-1.0],[-1.0,0.0,-1.0]]} \ No newline at end of file diff --git a/library/kollisions/bau_dach_schraege.json b/library/kollisions/bau_dach_schraege.json new file mode 100644 index 0000000..38bddcf --- /dev/null +++ b/library/kollisions/bau_dach_schraege.json @@ -0,0 +1 @@ +{"aabb_max":[1.0,2.0,1.0],"aabb_min":[-1.0,0.0,-1.0],"dreiecke":8,"oid":"bau_dach_schraege","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_dach_schraege.glb","vertices":[[-1.0,0.0,-1.0],[1.0,0.0,-1.0],[1.0,2.0,1.0],[-1.0,0.0,-1.0],[1.0,2.0,1.0],[-1.0,2.0,1.0],[-1.0,0.0,1.0],[1.0,2.0,1.0],[1.0,0.0,1.0],[-1.0,0.0,1.0],[-1.0,2.0,1.0],[1.0,2.0,1.0],[-1.0,0.0,-1.0],[-1.0,2.0,1.0],[-1.0,0.0,1.0],[1.0,0.0,-1.0],[1.0,0.0,1.0],[1.0,2.0,1.0],[-1.0,0.0,-1.0],[1.0,0.0,1.0],[1.0,0.0,-1.0],[-1.0,0.0,-1.0],[-1.0,0.0,1.0],[1.0,0.0,1.0]]} \ No newline at end of file diff --git a/library/objects.json b/library/objects.json index 1d7cd4f..883fd84 100644 --- a/library/objects.json +++ b/library/objects.json @@ -3491,6 +3491,36 @@ "kategorie": "bauteile", "name_de": "Brüstung" }, + "bau_dach_ecke_aussen": { + "poly": 6, + "collision": { + "quelle": "kollisions/bau_dach_ecke_aussen.json", + "shape": "trimesh", + "size": [ + 2.0, + 2.0, + 2.0 + ] + }, + "glb": "res://assets/objekte/military_desert/bauteile/bau_dach_ecke_aussen.glb", + "kategorie": "bauteile", + "name_de": "Dachecke außen" + }, + "bau_dach_ecke_innen": { + "poly": 10, + "collision": { + "quelle": "kollisions/bau_dach_ecke_innen.json", + "shape": "trimesh", + "size": [ + 2.0, + 2.0, + 2.0 + ] + }, + "glb": "res://assets/objekte/military_desert/bauteile/bau_dach_ecke_innen.glb", + "kategorie": "bauteile", + "name_de": "Dachecke innen" + }, "bau_dach_giebel": { "poly": 48, "collision": { @@ -3536,6 +3566,36 @@ "kategorie": "bauteile", "name_de": "Satteldach 6×6 m" }, + "bau_dach_giebel_ende": { + "poly": 8, + "collision": { + "quelle": "kollisions/bau_dach_giebel_ende.json", + "shape": "trimesh", + "size": [ + 2.0, + 2.0, + 0.2 + ] + }, + "glb": "res://assets/objekte/military_desert/bauteile/bau_dach_giebel_ende.glb", + "kategorie": "bauteile", + "name_de": "Dachstirn 45°" + }, + "bau_dach_plateau": { + "poly": 12, + "collision": { + "quelle": "kollisions/bau_dach_plateau.json", + "shape": "trimesh", + "size": [ + 2.0, + 2.0, + 2.0 + ] + }, + "glb": "res://assets/objekte/military_desert/bauteile/bau_dach_plateau.glb", + "kategorie": "bauteile", + "name_de": "Dachplateau" + }, "bau_dach_pyramide": { "poly": 64, "collision": { @@ -3581,6 +3641,21 @@ "kategorie": "bauteile", "name_de": "Pyramidendach 6×6 m" }, + "bau_dach_schraege": { + "poly": 8, + "collision": { + "quelle": "kollisions/bau_dach_schraege.json", + "shape": "trimesh", + "size": [ + 2.0, + 2.0, + 2.0 + ] + }, + "glb": "res://assets/objekte/military_desert/bauteile/bau_dach_schraege.glb", + "kategorie": "bauteile", + "name_de": "Dachschräge 45°" + }, "bau_fundament": { "poly": 24, "collision": { diff --git a/library/thumbs/bau_dach_ecke_aussen.png b/library/thumbs/bau_dach_ecke_aussen.png new file mode 100644 index 0000000..b9d320b Binary files /dev/null and b/library/thumbs/bau_dach_ecke_aussen.png differ diff --git a/library/thumbs/bau_dach_ecke_innen.png b/library/thumbs/bau_dach_ecke_innen.png new file mode 100644 index 0000000..cd24ae4 Binary files /dev/null and b/library/thumbs/bau_dach_ecke_innen.png differ diff --git a/library/thumbs/bau_dach_giebel_ende.png b/library/thumbs/bau_dach_giebel_ende.png new file mode 100644 index 0000000..43f9adf Binary files /dev/null and b/library/thumbs/bau_dach_giebel_ende.png differ diff --git a/library/thumbs/bau_dach_plateau.png b/library/thumbs/bau_dach_plateau.png new file mode 100644 index 0000000..997db21 Binary files /dev/null and b/library/thumbs/bau_dach_plateau.png differ diff --git a/library/thumbs/bau_dach_schraege.png b/library/thumbs/bau_dach_schraege.png new file mode 100644 index 0000000..8475dd5 Binary files /dev/null and b/library/thumbs/bau_dach_schraege.png differ diff --git a/src/editor/bau_katalog.gd b/src/editor/bau_katalog.gd index c960e1c..75a71fc 100644 --- a/src/editor/bau_katalog.gd +++ b/src/editor/bau_katalog.gd @@ -52,6 +52,8 @@ const GRUPPEN := [ {"key": "dach", "label": "Dach & Giebel (klein 2 m)", "oids": ["bau_giebelwand", "bau_dach_giebel", "bau_dach_pyramide"]}, {"key": "dach_mittel", "label": "Dach & Giebel (mittel 4 m)", "oids": ["bau_giebelwand_4", "bau_dach_giebel_4", "bau_dach_pyramide_4"]}, {"key": "dach_gross", "label": "Dach & Giebel (groß 6 m)", "oids": ["bau_giebelwand_6", "bau_dach_giebel_6", "bau_dach_pyramide_6"]}, + {"key": "dach_teile", "label": "Dach aus Einzelteilen (45°)", "oids": ["bau_dach_schraege", "bau_dach_ecke_aussen", + "bau_dach_ecke_innen", "bau_dach_plateau", "bau_dach_giebel_ende"]}, {"key": "treppe", "label": "Treppe", "oids": ["bau_treppe"]}, ] @@ -139,6 +141,29 @@ const KINDS := { "label": "Pyramidendach 6×6 m", "info": "6×6 m, Spitze in der Mitte", "snap": "zelle", "traeger": false, "typ": T_DACH_PYRAMIDE, "punkte": QUADRAT_6, "rise": 3.0, "farbe": Color(0.40, 0.25, 0.18), }, + # Dach aus Einzelteilen: alles 2×2-Zellen mit 45° (2 m Lauf = 2 m Höhe), massiv + # (rundum geschlossen, keine Lücke von innen). "profil" → _dach_mesh(). Tiefe + # Kante = -Z lokal (R dreht), Ecken: tiefer Punkt bei (-X,-Z). + "bau_dach_schraege": { + "label": "Dachschräge 45°", "info": "2×2 m, 2 m hoch — eine Seite des Dachs; die tiefe Kante zeigt in Richtung der Vorschau (R dreht)", + "snap": "zelle", "traeger": false, "profil": "schraege", "farbe": Color(0.40, 0.25, 0.18), + }, + "bau_dach_ecke_aussen": { + "label": "Dachecke außen", "info": "Walm-Ecke (Grat): zwei Schrägen treffen sich an einer Hausecke, die tiefe Ecke zeigt nach außen", + "snap": "zelle", "traeger": false, "profil": "ecke_aussen", "farbe": Color(0.40, 0.25, 0.18), + }, + "bau_dach_ecke_innen": { + "label": "Dachecke innen", "info": "Kehle: zwei Schrägen laufen in einer einspringenden Ecke (L-/T-Dach) zusammen", + "snap": "zelle", "traeger": false, "profil": "ecke_innen", "farbe": Color(0.40, 0.25, 0.18), + }, + "bau_dach_plateau": { + "label": "Dachplateau", "info": "2×2 m Block, 2 m hoch, oben flach — Mittelstück zwischen zwei Schrägen (breite Häuser), Schornstein-Sockel", + "snap": "zelle", "traeger": true, "profil": "plateau", "farbe": Color(0.40, 0.25, 0.18), + }, + "bau_dach_giebel_ende": { + "label": "Dachstirn 45°", "info": "Senkrechte Dreieck-Stirn (2 m breit, 2 m hoch) — Giebel-Abschluss unter einer Schräge, wenn das Dach an der Seite offen bleibt", + "snap": "kante", "traeger": false, "profil": "stirn", "farbe": Color(0.62, 0.53, 0.38), + }, "bau_treppe": { "label": "Treppe", "info": "4 m lang, 2 m breit, 3 m hoch — führt in ein Geschoss darüber", "snap": "treppe", "traeger": false, @@ -190,7 +215,13 @@ static func mesh(oid: String) -> ArrayMesh: var kind: Dictionary = KINDS.get(oid, {}) if kind.is_empty(): return null - var roh: ArrayMesh = _box_mesh(kind["box"]) if kind.has("box") else _addon_mesh(kind) + var roh: ArrayMesh + if kind.has("box"): + roh = _box_mesh(kind["box"]) + elif kind.has("profil"): + roh = _dach_mesh(str(kind["profil"])) + else: + roh = _addon_mesh(kind) var fertig := _normalisieren(roh, kind["farbe"] as Color) _mesh_cache[oid] = fertig return fertig @@ -204,6 +235,79 @@ static func _box_mesh(size: Vector3) -> ArrayMesh: return out +## Massive Dach-Teile aus Dreiecken (Zelle 2×2 m, lokal x,z ∈ [-1,1], 45°). +## Oberfläche h(x,z): Schräge = z+1, Ecke außen = min(x,z)+1, Ecke innen = max(x,z)+1, +## Plateau = 2. Dach-Stirn: dünnes Dreieck-Prisma. +static func _dach_mesh(profil: String) -> ArrayMesh: + var tris: Array = [] # je Dreieck [a, b, c] als Vector3 + var V := func(x: float, y: float, z: float) -> Vector3: return Vector3(x, y, z) + var quad := func(a: Vector3, b: Vector3, c: Vector3, d: Vector3) -> void: + tris.append([a, b, c]) + tris.append([a, c, d]) + var tri := func(a: Vector3, b: Vector3, c: Vector3) -> void: + tris.append([a, b, c]) + var mitte := Vector3(0.0, 0.7, 0.0) + match profil: + "schraege": + quad.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 2, 1), V.call(-1, 2, 1)) # Hang + quad.call(V.call(-1, 0, 1), V.call(1, 0, 1), V.call(1, 2, 1), V.call(-1, 2, 1)) # Rückwand + tri.call(V.call(-1, 0, -1), V.call(-1, 0, 1), V.call(-1, 2, 1)) # Stirn links + tri.call(V.call(1, 0, -1), V.call(1, 0, 1), V.call(1, 2, 1)) # Stirn rechts + quad.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 0, 1), V.call(-1, 0, 1)) # Boden + "ecke_aussen": + tri.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 2, 1)) + tri.call(V.call(-1, 0, -1), V.call(1, 2, 1), V.call(-1, 0, 1)) + tri.call(V.call(1, 0, -1), V.call(1, 0, 1), V.call(1, 2, 1)) + tri.call(V.call(-1, 0, 1), V.call(1, 0, 1), V.call(1, 2, 1)) + quad.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 0, 1), V.call(-1, 0, 1)) + "ecke_innen": + tri.call(V.call(-1, 0, -1), V.call(1, 2, -1), V.call(1, 2, 1)) + tri.call(V.call(-1, 0, -1), V.call(1, 2, 1), V.call(-1, 2, 1)) + tri.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 2, -1)) + tri.call(V.call(-1, 0, -1), V.call(-1, 2, 1), V.call(-1, 0, 1)) + quad.call(V.call(1, 0, -1), V.call(1, 0, 1), V.call(1, 2, 1), V.call(1, 2, -1)) + quad.call(V.call(-1, 0, 1), V.call(1, 0, 1), V.call(1, 2, 1), V.call(-1, 2, 1)) + quad.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 0, 1), V.call(-1, 0, 1)) + "plateau": + var bm := BoxMesh.new() + bm.size = Vector3(2.0, 2.0, 2.0) + var arr := bm.get_mesh_arrays() + var out := ArrayMesh.new() + out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arr) + return out + "stirn": + # Dünnes Dreieck-Prisma (Kante entlang X, 2 m breit, 2 m hoch, 0,2 m dick) + var d := 0.1 + tri.call(V.call(-1, 0, -d), V.call(1, 0, -d), V.call(-1, 2, -d)) + tri.call(V.call(-1, 0, d), V.call(-1, 2, d), V.call(1, 0, d)) + quad.call(V.call(-1, 0, -d), V.call(-1, 0, d), V.call(1, 0, d), V.call(1, 0, -d)) + quad.call(V.call(-1, 0, -d), V.call(-1, 2, -d), V.call(-1, 2, d), V.call(-1, 0, d)) + quad.call(V.call(1, 0, -d), V.call(1, 0, d), V.call(-1, 2, d), V.call(-1, 2, -d)) + mitte = Vector3(-0.33, 0.67, 0.0) + var pos := PackedVector3Array() + var nrm := PackedVector3Array() + for t in tris: + var a: Vector3 = t[0] + var b: Vector3 = t[1] + var c: Vector3 = t[2] + var n := (b - a).cross(c - a).normalized() + # nach außen drehen: Fläche muss vom Körper-Mittelpunkt wegzeigen + if n.dot((a + b + c) / 3.0 - mitte) < 0.0: + var tmp := b + b = c + c = tmp + n = -n + pos.append_array(PackedVector3Array([a, b, c])) + nrm.append_array(PackedVector3Array([n, n, n])) + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = pos + arrays[Mesh.ARRAY_NORMAL] = nrm + var m := ArrayMesh.new() + m.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + return m + + static func _addon_mesh(kind: Dictionary) -> ArrayMesh: var geo := Node3D.new() geo.set_script(load(GEO_SCRIPT)) diff --git a/src/editor/build_snap.gd b/src/editor/build_snap.gd index e9d802e..36997a3 100644 --- a/src/editor/build_snap.gd +++ b/src/editor/build_snap.gd @@ -21,6 +21,8 @@ class_name BuildSnap 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: @@ -36,8 +38,12 @@ const SAME_M := 0.08 # Toleranz für "gleiche Stelle" ## 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) -> Dictionary: + 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)) @@ -97,11 +103,11 @@ static func berechne(oid: String, hit: Vector3, dreh: float, teile: Array, 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 if p.y >= h_mitte.y else -halbe_dicke + 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 if p.x >= v_mitte.x else -halbe_dicke + 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) @@ -133,7 +139,7 @@ static func berechne(oid: String, hit: Vector3, dreh: float, teile: Array, ebenen.append((t["pos"] as Vector3).y) else: ebenen.append(top) - var ebene := _waehle_ebene(ebenen, hit.y) + 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 @@ -234,3 +240,17 @@ static func sammle_teile(doc: Dictionary) -> Array: "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)) diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 4f98c15..773f27e 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -52,6 +52,8 @@ var build_group_id := -1 # laufendes Gebäude; -1 = nächstes Teil be var _build_last: Dictionary = {} # letztes BuildSnap-Ergebnis (pos/rot/ersetze/ok/grund) var _build_hit: Dictionary = {} # letzter Treffer {punkt, obj, dir} var _build_hint := "" # letzte Statuszeile des Ghosts (kein Spam) +var build_geschoss := -1 # -1 = Höhe automatisch, sonst festes Geschoss (0 = Boden, 1 = erste Decke …) +const BAU_GESCHOSS_MAX := 8 # Terrain-Höhen-Cache (Karte t_4092e3a0): der Objekt-Cursor trifft das GELÄNDE # (TerrainField.raycast, wie Terrain-/Bauen-/Spawn-Werkzeug), nicht mehr nur @@ -252,6 +254,7 @@ func cancel_placement() -> void: _ghost_active = false _build_last = {} _build_hit = {} + build_geschoss = -1 main.call("_highlight_library", "") main.call("_base_tool_status") @@ -449,6 +452,8 @@ func _build_ray_hit(viewport_pos: Vector2) -> Dictionary: var origin := camera.project_ray_origin(viewport_pos) var dir := camera.project_ray_normal(viewport_pos) var ground: Variant = _ground_point(viewport_pos) + if build_geschoss >= 0: + return _build_ray_geschoss(origin, dir, ground) var best_t := INF var out := {} if ground != null: @@ -476,6 +481,55 @@ func _build_ray_hit(viewport_pos: Vector2) -> Dictionary: return out +## Feste Geschosshöhe: der Strahl trifft die waagerechte Ebene dieses Geschosses, +## egal ob darunter Wand, Boden oder nur Gelände ist (Decke mitten im Raum). +func _build_ray_geschoss(origin: Vector3, dir: Vector3, ground: Variant) -> Dictionary: + var xz := Vector2(origin.x, origin.z) + if ground != null: + xz = Vector2((ground as Vector3).x, (ground as Vector3).z) + var y := _bau_geschoss_hoehe(xz) + if absf(dir.y) < 0.001: + return {} + var t := (y - origin.y) / dir.y + if t <= 0.0: + return {} + return {"punkt": origin + dir * t, "obj": -1, "dir": dir, "ebene": y} + + +func _bau_gelaende_fn() -> Callable: + return func(x: float, z: float) -> float: + if _res >= 2 and _heights.size() >= _res * _res: + return TerrainField.height_at(_heights, _res, _sx, _sz, x, z) + return float(main.doc.get("terrain", {}).get("min_height_m", 0.0)) + + +func _bau_geschoss_hoehe(xz: Vector2) -> float: + var basis := BuildSnap.geschoss_basis(BuildSnap.sammle_teile(main.doc), xz, _bau_gelaende_fn()) + return basis + float(build_geschoss) * BauKatalog.WAND_H + + +## Bild↑/Bild↓ bzw. Knöpfe im Bauen-Tab: Geschoss wechseln (0 → zurück auf Automatik). +func bau_geschoss_aendern(delta: int) -> void: + var neu := build_geschoss + if delta > 0: + neu = 1 if build_geschoss < 0 else mini(build_geschoss + 1, BAU_GESCHOSS_MAX) + elif build_geschoss >= 0: + neu = build_geschoss - 1 # 0 → -1 (Automatik) + build_geschoss = neu + _build_hint = "" + if placing and not _build_hit.is_empty(): + _bau_ghost_neu() + main.call("_set_status", bau_geschoss_text()) + main.call("_refresh_bau_info") + + +func bau_geschoss_text() -> String: + if build_geschoss < 0: + return "Höhe: automatisch (Gelände bzw. Oberkante des Teils darunter). Bild↑ wählt ein festes Geschoss — dann sitzen Böden/Decken überall im Raum." + return "Höhe: festes Geschoss %d (%s). Bild↑/Bild↓ wechselt, bis „automatisch“ erreicht ist." % [ + build_geschoss, "Erdgeschoss-Boden" if build_geschoss == 0 else "%d × %d m über dem Boden" % [build_geschoss, int(BauKatalog.WAND_H)]] + + func _bau_ghost_update(viewport_pos: Vector2) -> void: _build_hit = _build_ray_hit(viewport_pos) if _build_hit.is_empty(): @@ -490,13 +544,10 @@ func _bau_ghost_update(viewport_pos: Vector2) -> void: func _bau_snap() -> Dictionary: if _build_hit.is_empty(): return {} - var gelaende := func(x: float, z: float) -> float: - if _res >= 2 and _heights.size() >= _res * _res: - return TerrainField.height_at(_heights, _res, _sx, _sz, x, z) - return float(main.doc.get("terrain", {}).get("min_height_m", 0.0)) return BuildSnap.berechne(place_oid, _build_hit["punkt"] as Vector3, place_rot_deg, - BuildSnap.sammle_teile(main.doc), gelaende, - int(_build_hit.get("obj", -1)) >= 0, _build_hit.get("dir", Vector3.ZERO) as Vector3) + BuildSnap.sammle_teile(main.doc), _bau_gelaende_fn(), + int(_build_hit.get("obj", -1)) >= 0, _build_hit.get("dir", Vector3.ZERO) as Vector3, + float(_build_hit.get("ebene", NAN))) func _bau_ghost_neu() -> void: diff --git a/src/ui/main.gd b/src/ui/main.gd index 6676497..34f03c8 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -485,7 +485,12 @@ func _open_settings() -> void: if settings_dialog == null: return settings_mod.call("refresh") - settings_dialog.popup_centered(UIScale.v2i(560, 520)) + # So groß, dass alles ohne Scrollen sichtbar ist (Inhalt + Titel/Knopf), begrenzt aufs Fenster. + var inhalt: Vector2 = settings_mod.get_combined_minimum_size() + var fenster := Vector2(get_window().size) + var breite := clampf(maxf(inhalt.x + UIScale.px(60), UIScale.px(640)), UIScale.px(640), fenster.x * 0.95) + var hoehe := clampf(inhalt.y + UIScale.px(110), UIScale.px(520), fenster.y * 0.95) + settings_dialog.popup_centered(Vector2i(int(breite), int(hoehe))) func _build_dialogs_and_overlays() -> void: @@ -1516,6 +1521,11 @@ func _unhandled_key_input(event: InputEvent) -> void: _test_start_apply(float(tp.get("x")), float(tp.get("z")), str(tp.get("hinweis"))) get_viewport().set_input_as_handled() return + if (key == KEY_PAGEUP or key == KEY_PAGEDOWN) and not ctrl and interaction.placing \ + and BauKatalog.ist_bau(interaction.place_oid): + interaction.bau_geschoss_aendern(1 if key == KEY_PAGEUP else -1) + get_viewport().set_input_as_handled() + return match key: KEY_T: # Karte t_96e3c4ee: T schaltet das Test-Start-Werkzeug um (Editor). diff --git a/src/ui/panels/bauen_panel.gd b/src/ui/panels/bauen_panel.gd index c485d1f..7b6e179 100644 --- a/src/ui/panels/bauen_panel.gd +++ b/src/ui/panels/bauen_panel.gd @@ -8,6 +8,7 @@ extends "res://src/ui/panels/editor_panel.gd" var bau_buttons: Dictionary = {} # oid → Button var info_label: Label +var geschoss_label: Label var _group: ButtonGroup @@ -22,11 +23,25 @@ func panel_title() -> String: func build() -> void: _group = ButtonGroup.new() _group.allow_unpress = true - add_child(_muted_label("Wähle ein Teil und klicke in die Karte. Wände rasten an die nächste Zellkante, Böden und Dächer in die Zellmitte (Raster 2 m). Klick auf die Oberkante einer Wand stapelt das nächste Geschoss, Klick auf die Seite ersetzt das Teil (z. B. Wand → Tür). R dreht, Esc beendet.")) + add_child(_muted_label("Wähle ein Teil und klicke in die Karte. Wände rasten an die nächste Zellkante, Böden und Dächer in die Zellmitte (Raster 2 m). Klick auf die Oberkante einer Wand stapelt das nächste Geschoss (oder Bild↑/Bild↓: festes Geschoss, dann sitzen Decken mitten im Raum), Klick auf die Seite ersetzt das Teil (z. B. Wand → Tür). R dreht, Esc beendet.")) for gruppe in BauKatalog.GRUPPEN: add_child(_field_label(str(gruppe["label"]))) add_child(_knopf_raster(gruppe["oids"] as Array)) add_child(_separator()) + add_child(_header_label("Höhe / Geschoss")) + geschoss_label = _muted_label(main.interaction.call("bau_geschoss_text")) + add_child(geschoss_label) + var hoehe := HBoxContainer.new() + hoehe.add_theme_constant_override("separation", UIScale.px(8)) + for spec in [["▲ Höher (Bild↑)", 1], ["▼ Tiefer (Bild↓)", -1]]: + var hb := _aktion(str(spec[0]), "Festes Geschoss wählen: Böden und Decken sitzen dann überall im Raum auf dieser Höhe (je 3 m). Ganz unten = automatisch.", + func() -> void: + main.interaction.call("bau_geschoss_aendern", int(spec[1])) + geschoss_label.text = main.interaction.call("bau_geschoss_text")) + hb.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hoehe.add_child(hb) + add_child(hoehe) + add_child(_separator()) add_child(_header_label("Gebäude")) info_label = _muted_label("") add_child(info_label) @@ -92,6 +107,8 @@ func refresh_info() -> void: if info_label == null or main == null or main.doc.is_empty(): return var gebaeude := BauOps.gebaeude_gruppen(main.doc) + if geschoss_label != null: + geschoss_label.text = main.interaction.call("bau_geschoss_text") var text := "%d Gebäude, %d Bauteile in der Map." % [gebaeude.size(), BauOps.teile_zahl(main.doc)] var gid := int(main.interaction.get("build_group_id")) for g in gebaeude: diff --git a/tests/test_bauen.gd b/tests/test_bauen.gd index 68fed9d..41e6c06 100644 --- a/tests/test_bauen.gd +++ b/tests/test_bauen.gd @@ -45,6 +45,9 @@ func _run_tests() -> void: _test_snap_cells_and_edges() _test_snap_levels() _test_replace_and_stack() + _test_dach_teile() + _test_geschoss_fix() + _test_wand_ueberstand() _test_ops_group_undo() _test_duplicate_building() _test_save_load() @@ -55,7 +58,7 @@ func _run_tests() -> void: func _test_catalog() -> void: print("Katalog") - _check(BauKatalog.KINDS.size() == 18, "18 Bauteile im Katalog") + _check(BauKatalog.KINDS.size() == 23, "23 Bauteile im Katalog") var in_gruppen := {} for g in BauKatalog.GRUPPEN: for oid in (g["oids"] as Array): @@ -99,7 +102,7 @@ func _test_meshes() -> void: print("Meshes / Fußpunkt-Normalisierung") for oid in BauKatalog.oids(): var m := BauKatalog.mesh(oid) - _check(m != null and m.get_faces().size() >= 36, "%s: Mesh vorhanden" % oid) + _check(m != null and m.get_faces().size() >= 18, "%s: Mesh vorhanden" % oid) var aabb := m.get_aabb() _check(absf(aabb.position.y) < 0.001, "%s: Unterkante y = 0" % oid) _check(absf(aabb.position.x + aabb.size.x * 0.5) < 0.001 and absf(aabb.position.z + aabb.size.z * 0.5) < 0.001, @@ -186,13 +189,13 @@ func _test_snap_cells_and_edges() -> void: _check(s["pos"] == Vector3(1.0, 0.0, 1.0), "Fundament: Zellmitte (1, 1)") s = BuildSnap.berechne("bau_fundament", Vector3(-0.3, 0.0, -2.9), 0.0, kein, _flach(0.0)) _check(s["pos"] == Vector3(-1.0, 0.0, -3.0), "Fundament: negative Zelle (−1, −3)") - s = BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.1), 0.0, kein, _flach(0.0)) - _check(s["pos"] == Vector3(1.0, 0.0, 0.1) and float(s["rot"]) == 0.0, "Wand: Kante entlang X, Drehung 0, 10 cm in die Zelle gerückt (bündig)") - s = BuildSnap.berechne("bau_wand", Vector3(0.1, 0.0, 1.0), 0.0, kein, _flach(0.0)) - _check(s["pos"] == Vector3(0.1, 0.0, 1.0) and float(s["rot"]) == 90.0, "Wand: Kante entlang Z, Drehung 90, 10 cm in die Zelle gerückt") - s = BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.1), 90.0, kein, _flach(0.0)) - _check(float(s["rot"]) == 180.0 and s["pos"] == Vector3(1.0, 0.0, 0.1), "R dreht die Wand um (180°), bleibt an ihrer Stelle") - s = BuildSnap.berechne("bau_wand", Vector3(0.1, 0.0, 1.0), 90.0, kein, _flach(0.0)) + s = BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.09), 0.0, kein, _flach(0.0)) + _check(_nah(s["pos"], Vector3(1.0, 0.0, 0.09)) and float(s["rot"]) == 0.0, "Wand: Kante entlang X, Drehung 0, 9 cm in die Zelle gerückt (1 cm Überstand)") + s = BuildSnap.berechne("bau_wand", Vector3(0.09, 0.0, 1.0), 0.0, kein, _flach(0.0)) + _check(_nah(s["pos"], Vector3(0.09, 0.0, 1.0)) and float(s["rot"]) == 90.0, "Wand: Kante entlang Z, Drehung 90, 9 cm in die Zelle gerückt (1 cm Überstand)") + s = BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.09), 90.0, kein, _flach(0.0)) + _check(float(s["rot"]) == 180.0 and _nah(s["pos"], Vector3(1.0, 0.0, 0.09)), "R dreht die Wand um (180°), bleibt an ihrer Stelle") + s = BuildSnap.berechne("bau_wand", Vector3(0.09, 0.0, 1.0), 90.0, kein, _flach(0.0)) _check(float(s["rot"]) == 270.0, "R: Z-Kante 90° → 270°") s = BuildSnap.berechne("bau_pfeiler", Vector3(1.8, 0.0, 2.3), 0.0, kein, _flach(0.0)) _check(s["pos"] == Vector3(2.0, 0.0, 2.0), "Pfeiler: nächste Zellecke") @@ -205,8 +208,8 @@ func _test_snap_cells_and_edges() -> void: var p := Vector3(-9.0 + 0.31 * k, 0.0, 7.0 - 0.27 * k) var w := BuildSnap.berechne("bau_wand", p, 0.0, kein, _flach(0.0)) var wp: Vector3 = w["pos"] - var ex := absf(absf(wp.x - roundf(wp.x / 2.0) * 2.0) - 0.1) < 0.001 # X nahe einer geraden Linie - var ez := absf(absf(wp.z - roundf(wp.z / 2.0) * 2.0) - 0.1) < 0.001 + var ex := absf(absf(wp.x - roundf(wp.x / 2.0) * 2.0) - 0.09) < 0.001 # X nahe einer geraden Linie + var ez := absf(absf(wp.z - roundf(wp.z / 2.0) * 2.0) - 0.09) < 0.001 var ox := absf(fposmod(wp.x, 2.0) - 1.0) < 0.001 # X auf Zellmitte var oz := absf(fposmod(wp.z, 2.0) - 1.0) < 0.001 if not ((ex and oz) or (ez and ox)): @@ -223,7 +226,7 @@ func _test_snap_cells_and_edges() -> void: var ra := BuildSnap.teil_rect({"oid": "bau_wand", "pos": wa["pos"], "rot": wa["rot"]}) var rb := BuildSnap.teil_rect({"oid": "bau_wand", "pos": wb["pos"], "rot": wb["rot"]}) _check(ra.has_point(Vector2(0.01, 0.01)) and rb.has_point(Vector2(0.01, 0.01)), "Ecke: äußere Ecke (0,0) ist von BEIDEN Wänden gedeckt") - _check(absf(ra.position.x) < 0.001 and absf(ra.position.y) < 0.001 and absf(rb.position.x) < 0.001 and absf(rb.position.y) < 0.001, + _check(absf(ra.position.x) < 0.0111 and absf(ra.position.y) < 0.0111 and absf(rb.position.x) < 0.0111 and absf(rb.position.y) < 0.0111, "Ecke: beide Wände beginnen bündig an der Außenkante (0,0)") # Große Teile: Zellzahl bestimmt die Rasterung s = BuildSnap.berechne("bau_dach_giebel_4", Vector3(3.2, 0.0, 3.2), 0.0, kein, _flach(0.0)) @@ -231,7 +234,7 @@ func _test_snap_cells_and_edges() -> void: s = BuildSnap.berechne("bau_dach_pyramide_6", Vector3(3.2, 0.0, 3.2), 0.0, kein, _flach(0.0)) _check(s["pos"] == Vector3(3.0, 0.0, 3.0), "Pyramidendach 6×6: Mitte einer Zelle (3, 3)") s = BuildSnap.berechne("bau_giebelwand_4", Vector3(3.0, 0.0, 0.5), 0.0, kein, _flach(0.0)) - _check(s["pos"] == Vector3(4.0, 0.0, 0.1), "Giebelwand 4 m: Mitte auf gerader Linie (4), in die Zelle gerückt") + _check(_nah(s["pos"], Vector3(4.0, 0.0, 0.09)), "Giebelwand 4 m: Mitte auf gerader Linie (4), in die Zelle gerückt") # Gelände: höchste Höhe unter der Grundfläche zählt s = BuildSnap.berechne("bau_fundament", Vector3(1.0, 5.0, 1.0), 0.0, kein, _flach(5.0)) _check(absf(float(s["ebene"]) - 5.0) < 0.001, "Gelände-Höhe wird die Ebene (5,0)") @@ -441,3 +444,64 @@ func _test_migration() -> void: var leer := _neue_doc() leer["gebaeude"] = [] _check(BauOps.migriere_gebaeude(leer) == 0 and not leer.has("gebaeude"), "leere Sektion wird still entfernt") + + +func _nah(a: Vector3, b: Vector3) -> bool: + return a.distance_to(b) < 0.0005 + + +## Dach-Einzelteile: massiv (geschlossen, Volumen > 0, Normalen nach außen), 45°. +func _test_dach_teile() -> void: + print("Dach aus Einzelteilen") + for oid in ["bau_dach_schraege", "bau_dach_ecke_aussen", "bau_dach_ecke_innen", "bau_dach_plateau", "bau_dach_giebel_ende"]: + var m := BauKatalog.mesh(oid) + var f := m.get_faces() + var vol := 0.0 + for i in range(0, f.size() - 2, 3): + vol += f[i].dot(f[i + 1].cross(f[i + 2])) / 6.0 + _check(vol < -0.05, "%s: geschlossener Körper, Volumen %.2f m³ (Godot: Vorderseite im Uhrzeigersinn → negativ = nach außen)" % [oid, vol]) + # jede Kante kommt in genau zwei Dreiecken vor (wasserdicht) + var kanten := {} + for i in range(0, f.size() - 2, 3): + for k in 3: + var a := f[i + k] + var b := f[i + (k + 1) % 3] + var key := "%s|%s" % [str(a.snapped(Vector3.ONE * 0.001)), str(b.snapped(Vector3.ONE * 0.001))] + kanten[key] = int(kanten.get(key, 0)) + 1 + var offen := 0 + for key in kanten: + var teile: PackedStringArray = (key as String).split("|") + if not kanten.has("%s|%s" % [teile[1], teile[0]]): + offen += 1 + _check(offen == 0, "%s: wasserdicht (keine offene Kante, %d)" % [oid, offen]) + var sch := BauKatalog.groesse("bau_dach_schraege") + _check(absf(sch.x - 2.0) < 0.01 and absf(sch.y - 2.0) < 0.01 and absf(sch.z - 2.0) < 0.01, "Schräge 2×2 m Grundfläche, 2 m hoch = 45°") + # Haus 4 m breit aus zwei Schrägen + Gegenschräge: Firsthöhe 2 m über Wandkrone + var kein: Array = [] + var s := BuildSnap.berechne("bau_dach_schraege", Vector3(1.2, 3.0, 1.1), 90.0, kein, _flach(0.0)) + _check(bool(s["ok"]) and float(s["rot"]) == 90.0, "Schräge lässt sich in 90°-Schritten drehen") + + +## Geschoss-Wahl: feste Höhe, unabhängig von Wänden darunter (Decke mitten im Raum). +func _test_geschoss_fix() -> void: + print("Geschoss fest (Decke mitten im Raum)") + var kein: Array = [] + # Raum 6×6: nur Außenwände, in der Mitte steht nichts. Klick auf dem Boden (y=0). + var s := BuildSnap.berechne("bau_boden", Vector3(3.0, 0.0, 3.0), 0.0, kein, _flach(0.0), false, Vector3.ZERO, 3.0) + _check(float(s["ebene"]) == 3.0 and _nah(s["pos"], Vector3(3.0, 3.0, 3.0)), "Decke auf 3 m mitten im Raum (kein Teil darunter)") + var ohne := BuildSnap.berechne("bau_boden", Vector3(3.0, 0.0, 3.0), 0.0, kein, _flach(0.0)) + _check(float(ohne["ebene"]) == 0.0, "Automatik bleibt wie bisher (Gelände)") + var teile := [{"i": 0, "oid": "bau_fundament", "pos": Vector3(1, 0.5, 1), "rot": 0.0}, {"i": 1, "oid": "bau_wand", "pos": Vector3(1, 0.5, 0.09), "rot": 0.0}] + _check(is_equal_approx(BuildSnap.geschoss_basis(teile, Vector2(5, 5), _flach(0.0)), 0.5), "Basis = tiefstes Teil in der Nähe (0,5 m)") + _check(is_equal_approx(BuildSnap.geschoss_basis([], Vector2(5, 5), _flach(2.0)), 2.0), "Basis ohne Teile = Gelände") + + +## Wand-Außenseite steht 1 cm vor der Plattenkante (kein Z-Fighting durch deckungsgleiche Flächen). +func _test_wand_ueberstand() -> void: + print("Wand ragt 1 cm über die Plattenkante") + var kein: Array = [] + var w := BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.3), 0.0, kein, _flach(0.0)) + var b := BuildSnap.berechne("bau_fundament", Vector3(1.0, 0.0, 0.3), 0.0, kein, _flach(0.0)) + var wr := BuildSnap.teil_rect({"oid": "bau_wand", "pos": w["pos"], "rot": w["rot"]}) + var br := BuildSnap.teil_rect({"oid": "bau_fundament", "pos": b["pos"], "rot": b["rot"]}) + _check(wr.position.y < br.position.y - 0.005, "Wand-Außenfläche liegt vor der Plattenkante (%.3f < %.3f)" % [wr.position.y, br.position.y])