Compare commits

...

2 commits

Author SHA1 Message Date
slyphax
902181500a Drag&Drop aus der Bibliothek: Ablege-Fläche über der 3D-Ansicht (echter Maus-Drag getestet), Drag-Vorschau, Doku
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 22:50:37 +01:00
slyphax
bcbd416960 Bauen: Geschoss-Wahl (Decken mitten im Raum), 1 cm Wand-Überstand gegen Z-Fighting, Dach aus Einzelteilen, größeres Einstellungen-Fenster
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 22:47:15 +01:00
20 changed files with 476 additions and 30 deletions

View file

@ -28,6 +28,7 @@ Ein Bauteil ist ein **ganz normales Bibliotheks-Objekt** (`bau_*`, Kategorie
| Dach & Giebel (klein 2 m) | Giebelwand, Satteldach, Pyramidendach | | Dach & Giebel (klein 2 m) | Giebelwand, Satteldach, Pyramidendach |
| Dach & Giebel (mittel 4 m) | Giebelwand 4 m, Satteldach 4×4, Pyramidendach 4×4 (Spitze 2 m) | | Dach & Giebel (mittel 4 m) | Giebelwand 4 m, Satteldach 4×4, Pyramidendach 4×4 (Spitze 2 m) |
| Dach & Giebel (groß 6 m) | Giebelwand 6 m, Satteldach 6×6, Pyramidendach 6×6 (Spitze 3 m) | | Dach & Giebel (groß 6 m) | Giebelwand 6 m, Satteldach 6×6, Pyramidendach 6×6 (Spitze 3 m) |
| Dach aus Einzelteilen (45°) | Dachschräge 2×2 m (2 m hoch), Dachecke außen (Grat), Dachecke innen (Kehle), Dachplateau, Dachstirn — massiv, rundum geschlossen; tiefe Kante/Ecke zeigt Richtung Vorschau, R dreht |
| Treppe | Treppe 4 × 2 m, 3 m hoch (15 Stufen) | | Treppe | Treppe 4 × 2 m, 3 m hoch (15 Stufen) |
Fertige Pack-Objekte (Container, Hangar-Tore, Betonsperren) stehen NICHT im Bauen-Tab, Fertige Pack-Objekte (Container, Hangar-Tore, Betonsperren) stehen NICHT im Bauen-Tab,
@ -47,6 +48,11 @@ sondern wie alle Objekte in der Objekt-Bibliothek unten.
Pfeiler zählen von der Unterkante einer Decke/eines Fundaments**, die Decke selbst Pfeiler zählen von der Unterkante einer Decke/eines Fundaments**, die Decke selbst
liegt auf der Wandoberkante: jedes Geschoss liegt auf Vielfachen von 3 m, mit oder liegt auf der Wandoberkante: jedes Geschoss liegt auf Vielfachen von 3 m, mit oder
ohne Decke darunter stehen die oberen Wände gleich hoch. ohne Decke darunter stehen die oberen Wände gleich hoch.
* **Geschoss fest:** Bild↑/Bild↓ (oder Knöpfe im Tab) wählen ein festes Geschoss
(Basis = tiefstes Teil in der Nähe, je 3 m). Der Mausstrahl trifft dann die
Geschoss-Ebene statt Wand/Boden darunter — Decken und Böden lassen sich mitten im
großen Raum setzen. Ganz unten = Automatik.
* **Kein Z-Fighting:** Wand-Außenseite steht 1 cm vor der Plattenkante.
* **Ersetzen:** gleiche Art auf demselben Platz wird ersetzt (Tür in vorhandene * **Ersetzen:** gleiche Art auf demselben Platz wird ersetzt (Tür in vorhandene
Wand). Dasselbe Teil nochmal wird mit Erklärung abgelehnt. Wand). Dasselbe Teil nochmal wird mit Erklärung abgelehnt.

View file

@ -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]]}

View file

@ -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]]}

View file

@ -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]]}

View file

@ -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]]}

View file

@ -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]]}

View file

@ -3491,6 +3491,36 @@
"kategorie": "bauteile", "kategorie": "bauteile",
"name_de": "Brüstung" "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": { "bau_dach_giebel": {
"poly": 48, "poly": 48,
"collision": { "collision": {
@ -3536,6 +3566,36 @@
"kategorie": "bauteile", "kategorie": "bauteile",
"name_de": "Satteldach 6×6 m" "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": { "bau_dach_pyramide": {
"poly": 64, "poly": 64,
"collision": { "collision": {
@ -3581,6 +3641,21 @@
"kategorie": "bauteile", "kategorie": "bauteile",
"name_de": "Pyramidendach 6×6 m" "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": { "bau_fundament": {
"poly": 24, "poly": 24,
"collision": { "collision": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -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", "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_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_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"]}, {"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, "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), "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": { "bau_treppe": {
"label": "Treppe", "info": "4 m lang, 2 m breit, 3 m hoch — führt in ein Geschoss darüber", "label": "Treppe", "info": "4 m lang, 2 m breit, 3 m hoch — führt in ein Geschoss darüber",
"snap": "treppe", "traeger": false, "snap": "treppe", "traeger": false,
@ -190,7 +215,13 @@ static func mesh(oid: String) -> ArrayMesh:
var kind: Dictionary = KINDS.get(oid, {}) var kind: Dictionary = KINDS.get(oid, {})
if kind.is_empty(): if kind.is_empty():
return null 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) var fertig := _normalisieren(roh, kind["farbe"] as Color)
_mesh_cache[oid] = fertig _mesh_cache[oid] = fertig
return fertig return fertig
@ -204,6 +235,79 @@ static func _box_mesh(size: Vector3) -> ArrayMesh:
return out 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: static func _addon_mesh(kind: Dictionary) -> ArrayMesh:
var geo := Node3D.new() var geo := Node3D.new()
geo.set_script(load(GEO_SCRIPT)) geo.set_script(load(GEO_SCRIPT))

View file

@ -21,6 +21,8 @@ class_name BuildSnap
const TOL_M := 0.35 # so weit darf eine Ebene ÜBER dem Klickpunkt liegen 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 SHRINK_M := 0.05 # Grundflächen-Schnitt: minimale Überlappung ignorieren
const SAME_M := 0.08 # Toleranz für "gleiche Stelle" 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: ## Ergebnis-Wörterbuch:
@ -36,8 +38,12 @@ const SAME_M := 0.08 # Toleranz für "gleiche Stelle"
## gelaende: Callable(x: float, z: float) -> float ## gelaende: Callable(x: float, z: float) -> float
## dreh: Nutzerdrehung in Grad (wird auf 90° gerastet) ## dreh: Nutzerdrehung in Grad (wird auf 90° gerastet)
## kamera_dir: Blickrichtung des Klickstrahls (für Zell-Wahl an Wandkanten), optional ## 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, 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 art := BauKatalog.snap_art(oid)
var gr := BauKatalog.groesse(oid) var gr := BauKatalog.groesse(oid)
var q := int(roundf(dreh / 90.0)) 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 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): if p.distance_squared_to(h_mitte) <= p.distance_squared_to(v_mitte):
mitte = h_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 rot = 0.0
else: else:
mitte = v_mitte 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 = 90.0
rot += 180.0 * float(viertel % 2) # R dreht die Wand um (Blickseite der Teile) rot += 180.0 * float(viertel % 2) # R dreht die Wand um (Blickseite der Teile)
rot = fposmod(rot, 360.0) 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) ebenen.append((t["pos"] as Vector3).y)
else: else:
ebenen.append(top) 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. # Platz belegt? Gleiche Art + gleiche Stelle + gleiche Ebene → ersetzen/ablehnen.
var ersetze := -1 var ersetze := -1
@ -234,3 +240,17 @@ static func sammle_teile(doc: Dictionary) -> Array:
"rot": float(SvmapIO.objekt_rotation(o as Dictionary).y), "rot": float(SvmapIO.objekt_rotation(o as Dictionary).y),
}) })
return out 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))

View file

@ -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_last: Dictionary = {} # letztes BuildSnap-Ergebnis (pos/rot/ersetze/ok/grund)
var _build_hit: Dictionary = {} # letzter Treffer {punkt, obj, dir} var _build_hit: Dictionary = {} # letzter Treffer {punkt, obj, dir}
var _build_hint := "" # letzte Statuszeile des Ghosts (kein Spam) 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 # Terrain-Höhen-Cache (Karte t_4092e3a0): der Objekt-Cursor trifft das GELÄNDE
# (TerrainField.raycast, wie Terrain-/Bauen-/Spawn-Werkzeug), nicht mehr nur # (TerrainField.raycast, wie Terrain-/Bauen-/Spawn-Werkzeug), nicht mehr nur
@ -252,6 +254,7 @@ func cancel_placement() -> void:
_ghost_active = false _ghost_active = false
_build_last = {} _build_last = {}
_build_hit = {} _build_hit = {}
build_geschoss = -1
main.call("_highlight_library", "") main.call("_highlight_library", "")
main.call("_base_tool_status") 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 origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos) var dir := camera.project_ray_normal(viewport_pos)
var ground: Variant = _ground_point(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 best_t := INF
var out := {} var out := {}
if ground != null: if ground != null:
@ -476,6 +481,55 @@ func _build_ray_hit(viewport_pos: Vector2) -> Dictionary:
return out 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: func _bau_ghost_update(viewport_pos: Vector2) -> void:
_build_hit = _build_ray_hit(viewport_pos) _build_hit = _build_ray_hit(viewport_pos)
if _build_hit.is_empty(): if _build_hit.is_empty():
@ -490,13 +544,10 @@ func _bau_ghost_update(viewport_pos: Vector2) -> void:
func _bau_snap() -> Dictionary: func _bau_snap() -> Dictionary:
if _build_hit.is_empty(): if _build_hit.is_empty():
return {} 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, return BuildSnap.berechne(place_oid, _build_hit["punkt"] as Vector3, place_rot_deg,
BuildSnap.sammle_teile(main.doc), gelaende, BuildSnap.sammle_teile(main.doc), _bau_gelaende_fn(),
int(_build_hit.get("obj", -1)) >= 0, _build_hit.get("dir", Vector3.ZERO) as Vector3) 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: func _bau_ghost_neu() -> void:

View file

@ -78,6 +78,7 @@ var toolbar_mod # src/ui/panels/toolbar.gd
var status_mod # src/ui/panels/status_bar.gd var status_mod # src/ui/panels/status_bar.gd
var selection_mod # src/ui/panels/selection_panel.gd (nur Logik, kein Tab mehr) var selection_mod # src/ui/panels/selection_panel.gd (nur Logik, kein Tab mehr)
var settings_mod # src/ui/panels/settings_panel.gd (im Einstellungen-Fenster) var settings_mod # src/ui/panels/settings_panel.gd (im Einstellungen-Fenster)
var drop_overlay: Control # Ablege-Fläche über der 3D-Ansicht (nur während Drag aktiv)
var settings_dialog: AcceptDialog # Fenster „Einstellungen“ (Knopf in der Kopfleiste) var settings_dialog: AcceptDialog # Fenster „Einstellungen“ (Knopf in der Kopfleiste)
var sym_mod # src/ui/panels/symmetry_panel.gd (im Map-Tab) var sym_mod # src/ui/panels/symmetry_panel.gd (im Map-Tab)
var map_mod # src/ui/panels/map_panel.gd var map_mod # src/ui/panels/map_panel.gd
@ -485,7 +486,12 @@ func _open_settings() -> void:
if settings_dialog == null: if settings_dialog == null:
return return
settings_mod.call("refresh") 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: func _build_dialogs_and_overlays() -> void:
@ -695,6 +701,18 @@ func _build_center_viewport() -> SubViewportContainer:
container.add_child(viewport_toolbar) container.add_child(viewport_toolbar)
viewport_toolbar.setup(self) viewport_toolbar.setup(self)
# Ablege-Fläche für Bibliotheks-Kacheln: Godot ruft can_drop/drop NICHT auf einem
# SubViewportContainer auf (Test-Befund 25.10.: Ziehen startete, Loslassen setzte
# nichts — echter Maus-Drag, tests/probe_drag_echt.gd). Eine durchsichtige Fläche
# darüber nimmt den Drop an; sie ist nur WÄHREND eines Ziehens aktiv (sonst
# bleibt die Maus für Kamera/Auswahl frei).
drop_overlay = Control.new()
drop_overlay.name = "AblegeFlaeche"
drop_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
drop_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
drop_overlay.set_drag_forwarding(Callable(), _viewport_can_drop, _viewport_drop)
container.add_child(drop_overlay)
# Bedien-Hinweis im Viewport (Overlay) # Bedien-Hinweis im Viewport (Overlay)
help_label = Label.new() help_label = Label.new()
help_label.text = "Linksklick: auswählen · Rahmen: mehrere · R: drehen · Entf: löschen · H: Hilfe" help_label.text = "Linksklick: auswählen · Rahmen: mehrere · R: drehen · Entf: löschen · H: Hilfe"
@ -885,6 +903,11 @@ func _quit_editor() -> void:
func _notification(what: int) -> void: func _notification(what: int) -> void:
if drop_overlay != null and is_instance_valid(drop_overlay):
if what == NOTIFICATION_DRAG_BEGIN:
drop_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
elif what == NOTIFICATION_DRAG_END:
drop_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
if what == NOTIFICATION_WM_CLOSE_REQUEST: if what == NOTIFICATION_WM_CLOSE_REQUEST:
_quit_editor() _quit_editor()
@ -1516,6 +1539,11 @@ func _unhandled_key_input(event: InputEvent) -> void:
_test_start_apply(float(tp.get("x")), float(tp.get("z")), str(tp.get("hinweis"))) _test_start_apply(float(tp.get("x")), float(tp.get("z")), str(tp.get("hinweis")))
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return 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: match key:
KEY_T: KEY_T:
# Karte t_96e3c4ee: T schaltet das Test-Start-Werkzeug um (Editor). # Karte t_96e3c4ee: T schaltet das Test-Start-Werkzeug um (Editor).

View file

@ -636,10 +636,7 @@ func _tile_drag_data(_at_position: Vector2, oid: String, btn: Button) -> Variant
lbl.text = LibraryBrowser.entry_label(oid, (main.manifest.get("objects", {}) as Dictionary).get(oid, {})) lbl.text = LibraryBrowser.entry_label(oid, (main.manifest.get("objects", {}) as Dictionary).get(oid, {}))
box.add_child(lbl) box.add_child(lbl)
prev.modulate.a = 0.85 prev.modulate.a = 0.85
if btn.get_viewport().gui_is_dragging(): btn.set_drag_preview(prev) # gehört in den Drag-Start: gui_is_dragging() ist hier noch false
btn.set_drag_preview(prev)
else:
prev.free()
return {"svr_oid": oid} return {"svr_oid": oid}

View file

@ -8,6 +8,7 @@ extends "res://src/ui/panels/editor_panel.gd"
var bau_buttons: Dictionary = {} # oid → Button var bau_buttons: Dictionary = {} # oid → Button
var info_label: Label var info_label: Label
var geschoss_label: Label
var _group: ButtonGroup var _group: ButtonGroup
@ -22,11 +23,25 @@ func panel_title() -> String:
func build() -> void: func build() -> void:
_group = ButtonGroup.new() _group = ButtonGroup.new()
_group.allow_unpress = true _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: for gruppe in BauKatalog.GRUPPEN:
add_child(_field_label(str(gruppe["label"]))) add_child(_field_label(str(gruppe["label"])))
add_child(_knopf_raster(gruppe["oids"] as Array)) add_child(_knopf_raster(gruppe["oids"] as Array))
add_child(_separator()) 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")) add_child(_header_label("Gebäude"))
info_label = _muted_label("") info_label = _muted_label("")
add_child(info_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(): if info_label == null or main == null or main.doc.is_empty():
return return
var gebaeude := BauOps.gebaeude_gruppen(main.doc) 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 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")) var gid := int(main.interaction.get("build_group_id"))
for g in gebaeude: for g in gebaeude:

79
tests/probe_drag_echt.gd Normal file
View file

@ -0,0 +1,79 @@
extends SceneTree
## Echter Maus-Drag im sichtbaren Fenster (nicht headless): Kachel drücken, über die
## Szene ziehen, loslassen → genau ein Objekt gesetzt. Ausführen:
## godot4 --rendering-driver opengl3 --path . --script res://tests/probe_drag_echt.gd
func _mouse(pos: Vector2, pressed: bool, motion := false) -> void:
var ev: InputEvent
if motion:
var mm := InputEventMouseMotion.new()
mm.position = pos
mm.global_position = pos
mm.relative = Vector2(4, 4)
mm.button_mask = MOUSE_BUTTON_MASK_LEFT
ev = mm
else:
var mb := InputEventMouseButton.new()
mb.button_index = MOUSE_BUTTON_LEFT
mb.position = pos
mb.global_position = pos
mb.pressed = pressed
mb.button_mask = MOUSE_BUTTON_MASK_LEFT if pressed else 0
ev = mb
root.push_input(ev)
func _init() -> void:
root.size = Vector2i(1600, 900)
var main: Control = (load("res://scenes/main.tscn") as PackedScene).instantiate()
root.add_child(main)
for i in 10:
await process_frame
main.sym_mode = SymmetryCore.Mode.OFF
var vc0: Control = main.interaction.camera.get_viewport().get_parent()
var n0: int = (main.doc["objects"] as Array).size()
var oid := "barrel_001"
main.assets_mod._on_library_expand_all()
for i in 5:
await process_frame
var btn: Button = main.library_buttons.get(oid)
print("window=", root.size, " btn=", btn)
btn.get_parent().get_parent()
# Kachel in Sicht scrollen
var p := btn.get_parent()
while p != null and not (p is ScrollContainer):
p = p.get_parent()
if p != null:
(p as ScrollContainer).ensure_control_visible(btn)
for i in 5:
await process_frame
var r := btn.get_global_rect()
print("Kachel rect=", r)
var start := r.get_center()
var vc: Control = main.interaction.camera.get_viewport().get_parent()
var vr := vc.get_global_rect()
print("Szene rect=", vr)
var ziel := vr.get_center()
_mouse(start, false, true)
await process_frame
_mouse(start, true)
await process_frame
var steps := 25
for i in steps + 1:
var pos := start.lerp(ziel, float(i) / steps)
_mouse(pos, false, true)
await process_frame
for j in 6:
_mouse(ziel + Vector2(j * 3, j * 2), false, true)
await process_frame
var hov := root.gui_get_hovered_control()
print("Hover-Control: ", hov, " pfad=", hov.get_path() if hov != null else "-")
print("DragData=", root.gui_get_drag_data())
print("während Drag: dragging=", root.gui_is_dragging(), " placing=", main.interaction.placing)
_mouse(ziel, false)
for i in 4:
await process_frame
var n1: int = (main.doc["objects"] as Array).size()
print("Objekte: %d → %d placing=%s" % [n0, n1, main.interaction.placing])
print("ERGEBNIS: ", "OK" if n1 == n0 + 1 else "FEHLER")
quit(0 if n1 == n0 + 1 else 1)

View file

@ -45,6 +45,9 @@ func _run_tests() -> void:
_test_snap_cells_and_edges() _test_snap_cells_and_edges()
_test_snap_levels() _test_snap_levels()
_test_replace_and_stack() _test_replace_and_stack()
_test_dach_teile()
_test_geschoss_fix()
_test_wand_ueberstand()
_test_ops_group_undo() _test_ops_group_undo()
_test_duplicate_building() _test_duplicate_building()
_test_save_load() _test_save_load()
@ -55,7 +58,7 @@ func _run_tests() -> void:
func _test_catalog() -> void: func _test_catalog() -> void:
print("Katalog") print("Katalog")
_check(BauKatalog.KINDS.size() == 18, "18 Bauteile im Katalog") _check(BauKatalog.KINDS.size() == 23, "23 Bauteile im Katalog")
var in_gruppen := {} var in_gruppen := {}
for g in BauKatalog.GRUPPEN: for g in BauKatalog.GRUPPEN:
for oid in (g["oids"] as Array): for oid in (g["oids"] as Array):
@ -99,7 +102,7 @@ func _test_meshes() -> void:
print("Meshes / Fußpunkt-Normalisierung") print("Meshes / Fußpunkt-Normalisierung")
for oid in BauKatalog.oids(): for oid in BauKatalog.oids():
var m := BauKatalog.mesh(oid) 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() var aabb := m.get_aabb()
_check(absf(aabb.position.y) < 0.001, "%s: Unterkante y = 0" % oid) _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, _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)") _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)) 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)") _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)) s = BuildSnap.berechne("bau_wand", Vector3(1.0, 0.0, 0.09), 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)") _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.1, 0.0, 1.0), 0.0, kein, _flach(0.0)) s = BuildSnap.berechne("bau_wand", Vector3(0.09, 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") _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.1), 90.0, kein, _flach(0.0)) 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 s["pos"] == Vector3(1.0, 0.0, 0.1), "R dreht die Wand um (180°), bleibt an ihrer Stelle") _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.1, 0.0, 1.0), 90.0, kein, _flach(0.0)) 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°") _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)) 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") _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 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 w := BuildSnap.berechne("bau_wand", p, 0.0, kein, _flach(0.0))
var wp: Vector3 = w["pos"] 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 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.1) < 0.001 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 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 var oz := absf(fposmod(wp.z, 2.0) - 1.0) < 0.001
if not ((ex and oz) or (ez and ox)): 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 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"]}) 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(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)") "Ecke: beide Wände beginnen bündig an der Außenkante (0,0)")
# Große Teile: Zellzahl bestimmt die Rasterung # 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)) 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)) 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)") _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)) 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 # 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)) 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)") _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() var leer := _neue_doc()
leer["gebaeude"] = [] leer["gebaeude"] = []
_check(BauOps.migriere_gebaeude(leer) == 0 and not leer.has("gebaeude"), "leere Sektion wird still entfernt") _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])