Bauen-Modus neu: Bauteile als normale Objekte (bau_*), Snap an Kante/Zelle/Ebene, Gebäude = Gruppe

- BauKatalog: 12 Teile (Fundament, Boden, Wände mit Tür/Fenster/Scharte, Brüstung, Pfeiler, Giebel, Dächer, Treppe) auf Basis der CC0-Geometrie des Modular Builder 0.2.0, Wandhöhe 3 m
- BuildSnap: Wände auf Zellkanten, Böden in Zellmitte, Höhe aus Ebenen statt Klickhöhe, Ersetzen (Tür in Wand)
- Gebäude kopieren/auswählen/löschen im Bauen-Tab, Kopien bekommen eigene Gruppe
- Kollision serverkonsistent über Trimesh-Dateien (Türen/Fenster echte Löcher), tools/gen_bauteile.gd erzeugt GLB, Kollision, Registry-Fragment
- Alte gebaeude-Sektion wird beim Laden migriert, altes Bauen-Werkzeug entfernt, Resize behält Gruppennamen
- Tests: test_bauen (258 Checks), Playtest läuft Treppe/Tür

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-10-25 16:30:15 +01:00
parent fcc7f16f1c
commit a76c3ecae7
70 changed files with 2005 additions and 1841 deletions

View file

@ -3,3 +3,14 @@ Quelle: Godot Asset Store (https://store.godotengine.org/asset/michel-savadim/mo
Lizenz: CC0 1.0 Universal (siehe addons/modular_builder/LICENSE) — gilt für Code, Lizenz: CC0 1.0 Universal (siehe addons/modular_builder/LICENSE) — gilt für Code,
Icons und Dokumentation. Die Demo-Szene/Demo-Texturen des Originals sind NICHT Icons und Dokumentation. Die Demo-Szene/Demo-Texturen des Originals sind NICHT
CC0 und wurden beim Import ENTFERNT (addons/modular_builder/demo fehlt bewusst). CC0 und wurden beim Import ENTFERNT (addons/modular_builder/demo fehlt bewusst).
Abgleich 25.10.2026: Der Code der Runtime-Geometrie (modular_piece_geometry.gd,
modular_building_detail.gd, modular_piece.gd, modular_pillar_support.gd,
modular_roof_support.gd, modular_vertex_simplifier.gd, modular_mesh_baker.gd) ist
zeichengleich zu ModularBuilder-English-0.2.0-2.zip (Ordner addons/modular_builder_en,
dort CC0 1.0, Copyright (c) 2026 Michel Savadim). Der Bauen-Modus des Map-Editors
(src/editor/bau_katalog.gd) nutzt diese Geometrie nur zum Erzeugen der Bauteile
(Wandhöhe 3 m, 2-m-Raster); die Teile selbst sind normale Bibliotheks-Objekte
(docs/bauen.md). Nicht übernommen: Demo-Szene, Demo-Texturen (nicht CC0).
Hinweis der Vorlage: Teile des Codes wurden mit KI-Unterstützung (OpenAI) erstellt —
vom Autor geprüft; CC0 gilt unverändert.

62
docs/bauen.md Normal file
View file

@ -0,0 +1,62 @@
# Bauen — eigene Häuser aus Modulteilen
Stand 25.10.2026 (Umbau). Vorlage: **Modular Builder 0.2.0** von Michel Savadim,
Godot-Erweiterung, Lizenz **CC0 1.0** (Code, Icons, Doku — siehe
`addons/modular_builder/LICENSE`). Die Demo-Szene/Demo-Texturen der Vorlage sind
nicht CC0 und liegen bewusst nicht im Repo (`ADDON_LIZENZ.txt`).
## Prinzip
Ein Bauteil ist ein **ganz normales Bibliotheks-Objekt** (`bau_*`, Kategorie
„bauteile“). Gesetzte Teile stehen in `objects`, ein Gebäude ist eine **Gruppe**
(`group` + `group_names`). Dadurch gelten ohne Sonderfälle:
* Undo/Redo, Auswahl, Verschieben, Drehen, Duplizieren, Löschen, Symmetrie
* Speichern in der `.svmap` (kein eigener Block mehr, Server-Validierung V1–V17)
* **Kollision serverkonsistent**: jedes Teil hat eine Trimesh-Datei
(`library/kollisions/bau_*.json`) — Türen, Fenster, Schießscharten sind echte
Löcher, die Treppe ist eine Stufenfläche (Stufen 20 cm). Editor-Playtest, Client
und Server lesen dieselbe Datei.
* Poly-Budget zählt wie bei jedem Objekt (`poly` = Dreiecke des Teils).
## Teile (Katalog: `src/editor/bau_katalog.gd`)
| Gruppe | Teile |
| --- | --- |
| Fundament & Boden | Fundament, Boden/Decke (2 × 2 m, 20 cm) |
| Wände (2 m breit, 3 m hoch) | Wand, Wand mit Tür (1,2 × 2,2 m), Wand mit Fenster, Wand mit Schießscharte, Brüstung (1,1 m), Eckpfeiler |
| Dach & Giebel | Giebelwand, Satteldach, Pyramidendach |
| Treppe | Treppe 4 × 2 m, 3 m hoch (15 Stufen) |
Dazu im Tab „Pack-Module“: Container, Hangar-Tore, Betonsperren aus dem
Military-Desert-Pack (normale Objekte).
## Snap (`src/editor/build_snap.gd`)
* **XZ:** Wände auf die nächste Zellkante (Drehung ergibt sich aus der Kante, `R`
dreht die Wand um), Böden/Dächer/Fundamente in die Zellmitte, Pfeiler auf die
Zellecke, Treppen auf eine 4 × 2-m-Fläche. Zellen sind 2 × 2 m, global ab (0, 0).
* **Y:** nie die Klickhöhe, sondern eine **Ebene** — Gelände (höchster Punkt unter
der Grundfläche) oder Oberkante eines tragenden Teils darunter. Klick auf die
Wandoberkante stapelt das nächste Geschoss.
* **Ersetzen:** gleiche Art auf demselben Platz wird ersetzt (Tür in vorhandene
Wand). Dasselbe Teil nochmal wird mit Erklärung abgelehnt.
## Dateien erzeugen
Meshes, GLBs, Kollision und Registry-Fragment kommen aus dem Katalog:
```bash
godot --headless --path . --script res://tools/gen_bauteile.gd -- \
--client ../scopedvr-client --server ../scopedvr-match-server
(cd ../scopedvr-match-server && python3 tools/merge_object_library.py)
```
Danach `library/objects.json` in Client und Editor angleichen (`tools/sync_objects.sh`).
`tests/test_bauen.gd` prüft, dass die Registry zum Katalog passt.
## Alte Maps
Die frühere Sektion `gebaeude` (Bauen-Modus vom 20.10.) wird beim Öffnen in
`bau_*`-Objekte einer Gruppe übernommen (`BauOps.migriere_gebaeude`). Wandhöhe ist
jetzt 3 m statt 2 m — alte Häuser bitte kurz prüfen und neu speichern.

View file

@ -56,7 +56,7 @@ alle, Strg+G gruppieren / Strg+Umschalt+G lösen.
| Eingabe | Wirkung | | Eingabe | Wirkung |
| --- | --- | | --- | --- |
| **R** | Auswahl 90° drehen (Vierteldrehung; Umschalt+R = −90°). Szene-Objekte drehen um den Auswahl-Mittelpunkt, Bauen-Bauteile um ihren Fußpunkt. Ein Objekt aus einer Gruppe dreht die GANZE Gruppe. Dauerhaft (kein Vorschau-Zustand) | | **R** | Auswahl 90° drehen (Vierteldrehung; Umschalt+R = −90°). Objekte (auch Bauteile) drehen um den Auswahl-Mittelpunkt. Ein Objekt aus einer Gruppe dreht die GANZE Gruppe. Dauerhaft (kein Vorschau-Zustand) |
| **Strg+R / Strg+Umschalt+R** | Auswahl um X **kippen** (+15° / −15°) — auch im Platzier-Modus (Ghost kippt mit). Karte t_e166e656 | | **Strg+R / Strg+Umschalt+R** | Auswahl um X **kippen** (+15° / −15°) — auch im Platzier-Modus (Ghost kippt mit). Karte t_e166e656 |
| **Alt+R / Alt+Umschalt+R** | Auswahl um Z **kippen** (+15° / −15°) — auch im Platzier-Modus. Karte t_e166e656 | | **Alt+R / Alt+Umschalt+R** | Auswahl um Z **kippen** (+15° / −15°) — auch im Platzier-Modus. Karte t_e166e656 |
| Mausrad (mit Auswahl) | Feindrehen im 15°-Raster (bleibt wie bisher) | | Mausrad (mit Auswahl) | Feindrehen im 15°-Raster (bleibt wie bisher) |
@ -177,9 +177,9 @@ keinen fokussierten Button auslöst.
| --- | --- | | --- | --- |
| **F1** | Werkzeug: Terrain formen | | **F1** | Werkzeug: Terrain formen |
| **F2** | Werkzeug: Objekte platzieren/auswählen (Standard) | | **F2** | Werkzeug: Objekte platzieren/auswählen (Standard) |
| **F3** | Werkzeug: Bauen (Häuser) | | **F3** | Tab „Bauen“ öffnen (Bauteile wählen, dann in die Karte klicken) |
| **F4** | Werkzeug: Spawn-Zonen malen | | **F4** | Werkzeug: Spawn-Zonen malen |
| Esc (im Terrain-/Spawn-/Bauen-Werkzeug) | Werkzeug beenden — zurück zum Objekt-Werkzeug | | Esc (im Terrain-/Spawn-Werkzeug) | Werkzeug beenden — zurück zum Objekt-Werkzeug |
| Esc (beim Greifen) | Greifen abbrechen, alte Positionen zurück | | Esc (beim Greifen) | Greifen abbrechen, alte Positionen zurück |
| Esc (beim Platzieren) | Ghost-Preview abbrechen | | Esc (beim Platzieren) | Ghost-Preview abbrechen |
| Esc (Kurzhilfe offen) | Kurzhilfe schließen | | Esc (Kurzhilfe offen) | Kurzhilfe schließen |

View file

@ -0,0 +1 @@
{"aabb_max":[1.0,0.2,1.0],"aabb_min":[-1.0,0.0,-1.0],"dreiecke":24,"oid":"bau_boden","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_boden.glb","vertices":[[-1.0,0.2,-1.0],[1.0,0.2,-1.0],[1.0,0.2,1.0],[1.0,0.2,-1.0],[-1.0,0.2,-1.0],[1.0,0.2,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.2,-1.0],[1.0,0.2,1.0],[-1.0,0.2,1.0],[1.0,0.2,1.0],[-1.0,0.2,-1.0],[-1.0,0.2,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],[1.0,0.2,-1.0],[1.0,0.0,-1.0],[1.0,0.2,-1.0],[-1.0,0.0,-1.0],[1.0,0.0,-1.0],[-1.0,0.0,-1.0],[-1.0,0.2,-1.0],[1.0,0.2,-1.0],[-1.0,0.2,-1.0],[-1.0,0.0,-1.0],[1.0,0.2,-1.0],[1.0,0.0,-1.0],[1.0,0.2,1.0],[1.0,0.0,1.0],[1.0,0.2,1.0],[1.0,0.0,-1.0],[1.0,0.0,1.0],[1.0,0.0,-1.0],[1.0,0.2,-1.0],[1.0,0.2,1.0],[1.0,0.2,-1.0],[1.0,0.0,-1.0],[1.0,0.2,1.0],[1.0,0.0,1.0],[-1.0,0.2,1.0],[-1.0,0.0,1.0],[-1.0,0.2,1.0],[1.0,0.0,1.0],[-1.0,0.0,1.0],[1.0,0.0,1.0],[1.0,0.2,1.0],[-1.0,0.2,1.0],[1.0,0.2,1.0],[1.0,0.0,1.0],[-1.0,0.2,1.0],[-1.0,0.0,1.0],[-1.0,0.2,-1.0],[-1.0,0.0,-1.0],[-1.0,0.2,-1.0],[-1.0,0.0,1.0],[-1.0,0.0,-1.0],[-1.0,0.0,1.0],[-1.0,0.2,1.0],[-1.0,0.2,-1.0],[-1.0,0.2,1.0],[-1.0,0.0,1.0],[-1.0,0.2,-1.0]]}

View file

@ -0,0 +1 @@
{"aabb_max":[1.0,1.1,0.1],"aabb_min":[-1.0,0.0,-0.1],"dreiecke":24,"oid":"bau_bruestung","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_bruestung.glb","vertices":[[-1.0,1.1,0.1],[1.0,0.0,0.1],[-1.0,0.0,0.1],[1.0,0.0,0.1],[-1.0,1.1,0.1],[-1.0,0.0,0.1],[1.0,0.0,-0.1],[-1.0,1.1,-0.1],[-1.0,0.0,-0.1],[-1.0,1.1,-0.1],[1.0,0.0,-0.1],[-1.0,0.0,-0.1],[1.0,0.0,0.1],[-1.0,1.1,0.1],[1.0,1.1,0.1],[-1.0,1.1,0.1],[1.0,0.0,0.1],[1.0,1.1,0.1],[-1.0,1.1,-0.1],[1.0,0.0,-0.1],[1.0,1.1,-0.1],[1.0,0.0,-0.1],[-1.0,1.1,-0.1],[1.0,1.1,-0.1],[-1.0,0.0,-0.1],[-1.0,1.1,0.1],[-1.0,0.0,0.1],[-1.0,1.1,0.1],[-1.0,0.0,-0.1],[-1.0,0.0,0.1],[-1.0,0.0,-0.1],[-1.0,1.1,-0.1],[-1.0,1.1,0.1],[-1.0,1.1,-0.1],[-1.0,0.0,-0.1],[-1.0,1.1,0.1],[1.0,0.0,0.1],[1.0,1.1,-0.1],[1.0,0.0,-0.1],[1.0,1.1,-0.1],[1.0,0.0,0.1],[1.0,0.0,-0.1],[1.0,0.0,0.1],[1.0,1.1,0.1],[1.0,1.1,-0.1],[1.0,1.1,0.1],[1.0,0.0,0.1],[1.0,1.1,-0.1],[-1.0,1.1,0.1],[1.0,1.1,-0.1],[1.0,1.1,0.1],[1.0,1.1,-0.1],[-1.0,1.1,0.1],[1.0,1.1,0.1],[-1.0,1.1,0.1],[-1.0,1.1,-0.1],[1.0,1.1,-0.1],[-1.0,1.1,-0.1],[-1.0,1.1,0.1],[1.0,1.1,-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,0.0,0.1],[-1.0,0.0,0.1],[-1.0,0.0,-0.1],[1.0,0.0,0.1]]}

View file

@ -0,0 +1 @@
{"aabb_max":[1.071,1.141,1.0],"aabb_min":[-1.071,0.0,-1.0],"dreiecke":48,"oid":"bau_dach_giebel","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_dach_giebel.glb","vertices":[[-1.0707,0.1414,-1.0],[-0.0707,1.1414,1.0],[-1.0707,0.1414,1.0],[-0.0707,1.1414,1.0],[-1.0707,0.1414,-1.0],[-1.0707,0.1414,1.0],[0.0707,1.0,1.0],[-0.9293,0.0,-1.0],[-0.9293,0.0,1.0],[-0.9293,0.0,-1.0],[0.0707,1.0,1.0],[-0.9293,0.0,1.0],[-1.0707,0.1414,-1.0],[-0.0707,1.1414,-1.0],[-0.0707,1.1414,1.0],[-0.0707,1.1414,-1.0],[-1.0707,0.1414,-1.0],[-0.0707,1.1414,1.0],[0.0707,1.0,-1.0],[-0.9293,0.0,-1.0],[0.0707,1.0,1.0],[-0.9293,0.0,-1.0],[0.0707,1.0,-1.0],[0.0707,1.0,1.0],[-1.0707,0.1414,-1.0],[-0.9293,0.0,1.0],[-0.9293,0.0,-1.0],[-0.9293,0.0,1.0],[-1.0707,0.1414,-1.0],[-0.9293,0.0,-1.0],[-1.0707,0.1414,-1.0],[-1.0707,0.1414,1.0],[-0.9293,0.0,1.0],[-1.0707,0.1414,1.0],[-1.0707,0.1414,-1.0],[-0.9293,0.0,1.0],[-1.0707,0.1414,1.0],[0.0707,1.0,1.0],[-0.9293,0.0,1.0],[0.0707,1.0,1.0],[-1.0707,0.1414,1.0],[-0.9293,0.0,1.0],[-1.0707,0.1414,1.0],[-0.0707,1.1414,1.0],[0.0707,1.0,1.0],[-0.0707,1.1414,1.0],[-1.0707,0.1414,1.0],[0.0707,1.0,1.0],[-0.0707,1.1414,1.0],[0.0707,1.0,-1.0],[0.0707,1.0,1.0],[0.0707,1.0,-1.0],[-0.0707,1.1414,1.0],[0.0707,1.0,1.0],[-0.0707,1.1414,1.0],[-0.0707,1.1414,-1.0],[0.0707,1.0,-1.0],[-0.0707,1.1414,-1.0],[-0.0707,1.1414,1.0],[0.0707,1.0,-1.0],[-0.0707,1.1414,-1.0],[-0.9293,0.0,-1.0],[0.0707,1.0,-1.0],[-0.9293,0.0,-1.0],[-0.0707,1.1414,-1.0],[0.0707,1.0,-1.0],[-0.0707,1.1414,-1.0],[-1.0707,0.1414,-1.0],[-0.9293,0.0,-1.0],[-1.0707,0.1414,-1.0],[-0.0707,1.1414,-1.0],[-0.9293,0.0,-1.0],[0.0707,1.1414,-1.0],[1.0707,0.1414,1.0],[0.0707,1.1414,1.0],[1.0707,0.1414,1.0],[0.0707,1.1414,-1.0],[0.0707,1.1414,1.0],[0.9293,0.0,1.0],[-0.0707,1.0,-1.0],[-0.0707,1.0,1.0],[-0.0707,1.0,-1.0],[0.9293,0.0,1.0],[-0.0707,1.0,1.0],[0.0707,1.1414,-1.0],[1.0707,0.1414,-1.0],[1.0707,0.1414,1.0],[1.0707,0.1414,-1.0],[0.0707,1.1414,-1.0],[1.0707,0.1414,1.0],[0.9293,0.0,-1.0],[-0.0707,1.0,-1.0],[0.9293,0.0,1.0],[-0.0707,1.0,-1.0],[0.9293,0.0,-1.0],[0.9293,0.0,1.0],[0.0707,1.1414,-1.0],[-0.0707,1.0,1.0],[-0.0707,1.0,-1.0],[-0.0707,1.0,1.0],[0.0707,1.1414,-1.0],[-0.0707,1.0,-1.0],[0.0707,1.1414,-1.0],[0.0707,1.1414,1.0],[-0.0707,1.0,1.0],[0.0707,1.1414,1.0],[0.0707,1.1414,-1.0],[-0.0707,1.0,1.0],[0.0707,1.1414,1.0],[0.9293,0.0,1.0],[-0.0707,1.0,1.0],[0.9293,0.0,1.0],[0.0707,1.1414,1.0],[-0.0707,1.0,1.0],[0.0707,1.1414,1.0],[1.0707,0.1414,1.0],[0.9293,0.0,1.0],[1.0707,0.1414,1.0],[0.0707,1.1414,1.0],[0.9293,0.0,1.0],[1.0707,0.1414,1.0],[0.9293,0.0,-1.0],[0.9293,0.0,1.0],[0.9293,0.0,-1.0],[1.0707,0.1414,1.0],[0.9293,0.0,1.0],[1.0707,0.1414,1.0],[1.0707,0.1414,-1.0],[0.9293,0.0,-1.0],[1.0707,0.1414,-1.0],[1.0707,0.1414,1.0],[0.9293,0.0,-1.0],[1.0707,0.1414,-1.0],[-0.0707,1.0,-1.0],[0.9293,0.0,-1.0],[-0.0707,1.0,-1.0],[1.0707,0.1414,-1.0],[0.9293,0.0,-1.0],[1.0707,0.1414,-1.0],[0.0707,1.1414,-1.0],[-0.0707,1.0,-1.0],[0.0707,1.1414,-1.0],[1.0707,0.1414,-1.0],[-0.0707,1.0,-1.0]]}

View file

@ -0,0 +1 @@
{"aabb_max":[1.071,1.141,1.071],"aabb_min":[-1.071,0.0,-1.071],"dreiecke":64,"oid":"bau_dach_pyramide","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_dach_pyramide.glb","vertices":[[-1.0,0.0,-0.9293],[0.0,1.0,0.0707],[1.0,0.0,-0.9293],[0.0,1.0,0.0707],[-1.0,0.0,-0.9293],[1.0,0.0,-0.9293],[0.0,1.1414,-0.0707],[-1.0,0.1414,-1.0707],[1.0,0.1414,-1.0707],[-1.0,0.1414,-1.0707],[0.0,1.1414,-0.0707],[1.0,0.1414,-1.0707],[-1.0,0.0,-0.9293],[1.0,0.1414,-1.0707],[-1.0,0.1414,-1.0707],[1.0,0.1414,-1.0707],[-1.0,0.0,-0.9293],[-1.0,0.1414,-1.0707],[-1.0,0.0,-0.9293],[1.0,0.0,-0.9293],[1.0,0.1414,-1.0707],[1.0,0.0,-0.9293],[-1.0,0.0,-0.9293],[1.0,0.1414,-1.0707],[1.0,0.0,-0.9293],[0.0,1.1414,-0.0707],[1.0,0.1414,-1.0707],[0.0,1.1414,-0.0707],[1.0,0.0,-0.9293],[1.0,0.1414,-1.0707],[1.0,0.0,-0.9293],[0.0,1.0,0.0707],[0.0,1.1414,-0.0707],[0.0,1.0,0.0707],[1.0,0.0,-0.9293],[0.0,1.1414,-0.0707],[0.0,1.0,0.0707],[-1.0,0.1414,-1.0707],[0.0,1.1414,-0.0707],[-1.0,0.1414,-1.0707],[0.0,1.0,0.0707],[0.0,1.1414,-0.0707],[0.0,1.0,0.0707],[-1.0,0.0,-0.9293],[-1.0,0.1414,-1.0707],[-1.0,0.0,-0.9293],[0.0,1.0,0.0707],[-1.0,0.1414,-1.0707],[0.9293,0.0,-1.0],[-0.0707,1.0,0.0],[0.9293,0.0,1.0],[-0.0707,1.0,0.0],[0.9293,0.0,-1.0],[0.9293,0.0,1.0],[0.0707,1.1414,0.0],[1.0707,0.1414,-1.0],[1.0707,0.1414,1.0],[1.0707,0.1414,-1.0],[0.0707,1.1414,0.0],[1.0707,0.1414,1.0],[0.9293,0.0,-1.0],[1.0707,0.1414,1.0],[1.0707,0.1414,-1.0],[1.0707,0.1414,1.0],[0.9293,0.0,-1.0],[1.0707,0.1414,-1.0],[0.9293,0.0,-1.0],[0.9293,0.0,1.0],[1.0707,0.1414,1.0],[0.9293,0.0,1.0],[0.9293,0.0,-1.0],[1.0707,0.1414,1.0],[0.9293,0.0,1.0],[0.0707,1.1414,0.0],[1.0707,0.1414,1.0],[0.0707,1.1414,0.0],[0.9293,0.0,1.0],[1.0707,0.1414,1.0],[0.9293,0.0,1.0],[-0.0707,1.0,0.0],[0.0707,1.1414,0.0],[-0.0707,1.0,0.0],[0.9293,0.0,1.0],[0.0707,1.1414,0.0],[-0.0707,1.0,0.0],[1.0707,0.1414,-1.0],[0.0707,1.1414,0.0],[1.0707,0.1414,-1.0],[-0.0707,1.0,0.0],[0.0707,1.1414,0.0],[-0.0707,1.0,0.0],[0.9293,0.0,-1.0],[1.0707,0.1414,-1.0],[0.9293,0.0,-1.0],[-0.0707,1.0,0.0],[1.0707,0.1414,-1.0],[1.0,0.0,0.9293],[0.0,1.0,-0.0707],[-1.0,0.0,0.9293],[0.0,1.0,-0.0707],[1.0,0.0,0.9293],[-1.0,0.0,0.9293],[0.0,1.1414,0.0707],[1.0,0.1414,1.0707],[-1.0,0.1414,1.0707],[1.0,0.1414,1.0707],[0.0,1.1414,0.0707],[-1.0,0.1414,1.0707],[1.0,0.0,0.9293],[-1.0,0.1414,1.0707],[1.0,0.1414,1.0707],[-1.0,0.1414,1.0707],[1.0,0.0,0.9293],[1.0,0.1414,1.0707],[1.0,0.0,0.9293],[-1.0,0.0,0.9293],[-1.0,0.1414,1.0707],[-1.0,0.0,0.9293],[1.0,0.0,0.9293],[-1.0,0.1414,1.0707],[-1.0,0.0,0.9293],[0.0,1.1414,0.0707],[-1.0,0.1414,1.0707],[0.0,1.1414,0.0707],[-1.0,0.0,0.9293],[-1.0,0.1414,1.0707],[-1.0,0.0,0.9293],[0.0,1.0,-0.0707],[0.0,1.1414,0.0707],[0.0,1.0,-0.0707],[-1.0,0.0,0.9293],[0.0,1.1414,0.0707],[0.0,1.0,-0.0707],[1.0,0.1414,1.0707],[0.0,1.1414,0.0707],[1.0,0.1414,1.0707],[0.0,1.0,-0.0707],[0.0,1.1414,0.0707],[0.0,1.0,-0.0707],[1.0,0.0,0.9293],[1.0,0.1414,1.0707],[1.0,0.0,0.9293],[0.0,1.0,-0.0707],[1.0,0.1414,1.0707],[-0.9293,0.0,1.0],[0.0707,1.0,0.0],[-0.9293,0.0,-1.0],[0.0707,1.0,0.0],[-0.9293,0.0,1.0],[-0.9293,0.0,-1.0],[-0.0707,1.1414,0.0],[-1.0707,0.1414,1.0],[-1.0707,0.1414,-1.0],[-1.0707,0.1414,1.0],[-0.0707,1.1414,0.0],[-1.0707,0.1414,-1.0],[-0.9293,0.0,1.0],[-1.0707,0.1414,-1.0],[-1.0707,0.1414,1.0],[-1.0707,0.1414,-1.0],[-0.9293,0.0,1.0],[-1.0707,0.1414,1.0],[-0.9293,0.0,1.0],[-0.9293,0.0,-1.0],[-1.0707,0.1414,-1.0],[-0.9293,0.0,-1.0],[-0.9293,0.0,1.0],[-1.0707,0.1414,-1.0],[-0.9293,0.0,-1.0],[-0.0707,1.1414,0.0],[-1.0707,0.1414,-1.0],[-0.0707,1.1414,0.0],[-0.9293,0.0,-1.0],[-1.0707,0.1414,-1.0],[-0.9293,0.0,-1.0],[0.0707,1.0,0.0],[-0.0707,1.1414,0.0],[0.0707,1.0,0.0],[-0.9293,0.0,-1.0],[-0.0707,1.1414,0.0],[0.0707,1.0,0.0],[-1.0707,0.1414,1.0],[-0.0707,1.1414,0.0],[-1.0707,0.1414,1.0],[0.0707,1.0,0.0],[-0.0707,1.1414,0.0],[0.0707,1.0,0.0],[-0.9293,0.0,1.0],[-1.0707,0.1414,1.0],[-0.9293,0.0,1.0],[0.0707,1.0,0.0],[-1.0707,0.1414,1.0]]}

View file

@ -0,0 +1 @@
{"aabb_max":[1.0,0.2,1.0],"aabb_min":[-1.0,0.0,-1.0],"dreiecke":24,"oid":"bau_fundament","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_fundament.glb","vertices":[[-1.0,0.2,-1.0],[1.0,0.2,-1.0],[1.0,0.2,1.0],[1.0,0.2,-1.0],[-1.0,0.2,-1.0],[1.0,0.2,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.2,-1.0],[1.0,0.2,1.0],[-1.0,0.2,1.0],[1.0,0.2,1.0],[-1.0,0.2,-1.0],[-1.0,0.2,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],[1.0,0.2,-1.0],[1.0,0.0,-1.0],[1.0,0.2,-1.0],[-1.0,0.0,-1.0],[1.0,0.0,-1.0],[-1.0,0.0,-1.0],[-1.0,0.2,-1.0],[1.0,0.2,-1.0],[-1.0,0.2,-1.0],[-1.0,0.0,-1.0],[1.0,0.2,-1.0],[1.0,0.0,-1.0],[1.0,0.2,1.0],[1.0,0.0,1.0],[1.0,0.2,1.0],[1.0,0.0,-1.0],[1.0,0.0,1.0],[1.0,0.0,-1.0],[1.0,0.2,-1.0],[1.0,0.2,1.0],[1.0,0.2,-1.0],[1.0,0.0,-1.0],[1.0,0.2,1.0],[1.0,0.0,1.0],[-1.0,0.2,1.0],[-1.0,0.0,1.0],[-1.0,0.2,1.0],[1.0,0.0,1.0],[-1.0,0.0,1.0],[1.0,0.0,1.0],[1.0,0.2,1.0],[-1.0,0.2,1.0],[1.0,0.2,1.0],[1.0,0.0,1.0],[-1.0,0.2,1.0],[-1.0,0.0,1.0],[-1.0,0.2,-1.0],[-1.0,0.0,-1.0],[-1.0,0.2,-1.0],[-1.0,0.0,1.0],[-1.0,0.0,-1.0],[-1.0,0.0,1.0],[-1.0,0.2,1.0],[-1.0,0.2,-1.0],[-1.0,0.2,1.0],[-1.0,0.0,1.0],[-1.0,0.2,-1.0]]}

View file

@ -0,0 +1 @@
{"aabb_max":[1.0,1.0,0.1],"aabb_min":[-1.0,0.0,-0.1],"dreiecke":16,"oid":"bau_giebelwand","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_giebelwand.glb","vertices":[[-1.0,0.0,0.1],[0.0,1.0,0.1],[1.0,0.0,0.1],[0.0,1.0,0.1],[-1.0,0.0,0.1],[1.0,0.0,0.1],[1.0,0.0,-0.1],[0.0,1.0,-0.1],[-1.0,0.0,-0.1],[0.0,1.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,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],[0.0,1.0,-0.1],[1.0,0.0,-0.1],[0.0,1.0,-0.1],[1.0,0.0,0.1],[1.0,0.0,-0.1],[1.0,0.0,0.1],[0.0,1.0,0.1],[0.0,1.0,-0.1],[0.0,1.0,0.1],[1.0,0.0,0.1],[0.0,1.0,-0.1],[0.0,1.0,0.1],[-1.0,0.0,-0.1],[0.0,1.0,-0.1],[-1.0,0.0,-0.1],[0.0,1.0,0.1],[0.0,1.0,-0.1],[0.0,1.0,0.1],[-1.0,0.0,0.1],[-1.0,0.0,-0.1],[-1.0,0.0,0.1],[0.0,1.0,0.1],[-1.0,0.0,-0.1]]}

View file

@ -0,0 +1 @@
{"aabb_max":[0.15,3.0,0.15],"aabb_min":[-0.15,0.0,-0.15],"dreiecke":12,"oid":"bau_pfeiler","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_pfeiler.glb","vertices":[[-0.15,3.0,0.15],[0.15,3.0,0.15],[-0.15,0.0,0.15],[0.15,3.0,0.15],[0.15,0.0,0.15],[-0.15,0.0,0.15],[0.15,3.0,-0.15],[-0.15,3.0,-0.15],[0.15,0.0,-0.15],[-0.15,3.0,-0.15],[-0.15,0.0,-0.15],[0.15,0.0,-0.15],[0.15,3.0,0.15],[0.15,3.0,-0.15],[0.15,0.0,0.15],[0.15,3.0,-0.15],[0.15,0.0,-0.15],[0.15,0.0,0.15],[-0.15,3.0,-0.15],[-0.15,3.0,0.15],[-0.15,0.0,-0.15],[-0.15,3.0,0.15],[-0.15,0.0,0.15],[-0.15,0.0,-0.15],[0.15,3.0,0.15],[-0.15,3.0,0.15],[0.15,3.0,-0.15],[-0.15,3.0,0.15],[-0.15,3.0,-0.15],[0.15,3.0,-0.15],[-0.15,0.0,0.15],[0.15,0.0,0.15],[-0.15,0.0,-0.15],[0.15,0.0,0.15],[0.15,0.0,-0.15],[-0.15,0.0,-0.15]]}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"aabb_max":[1.0,3.0,0.1],"aabb_min":[-1.0,0.0,-0.1],"dreiecke":24,"oid":"bau_wand","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_wand.glb","vertices":[[-1.0,3.0,0.1],[1.0,0.0,0.1],[-1.0,0.0,0.1],[1.0,0.0,0.1],[-1.0,3.0,0.1],[-1.0,0.0,0.1],[1.0,0.0,-0.1],[-1.0,3.0,-0.1],[-1.0,0.0,-0.1],[-1.0,3.0,-0.1],[1.0,0.0,-0.1],[-1.0,0.0,-0.1],[1.0,0.0,0.1],[-1.0,3.0,0.1],[1.0,3.0,0.1],[-1.0,3.0,0.1],[1.0,0.0,0.1],[1.0,3.0,0.1],[-1.0,3.0,-0.1],[1.0,0.0,-0.1],[1.0,3.0,-0.1],[1.0,0.0,-0.1],[-1.0,3.0,-0.1],[1.0,3.0,-0.1],[-1.0,0.0,-0.1],[-1.0,3.0,0.1],[-1.0,0.0,0.1],[-1.0,3.0,0.1],[-1.0,0.0,-0.1],[-1.0,0.0,0.1],[-1.0,0.0,-0.1],[-1.0,3.0,-0.1],[-1.0,3.0,0.1],[-1.0,3.0,-0.1],[-1.0,0.0,-0.1],[-1.0,3.0,0.1],[1.0,0.0,0.1],[1.0,3.0,-0.1],[1.0,0.0,-0.1],[1.0,3.0,-0.1],[1.0,0.0,0.1],[1.0,0.0,-0.1],[1.0,0.0,0.1],[1.0,3.0,0.1],[1.0,3.0,-0.1],[1.0,3.0,0.1],[1.0,0.0,0.1],[1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.0,-0.1],[1.0,3.0,0.1],[1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.0,0.1],[-1.0,3.0,0.1],[-1.0,3.0,-0.1],[1.0,3.0,-0.1],[-1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.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,0.0,0.1],[-1.0,0.0,0.1],[-1.0,0.0,-0.1],[1.0,0.0,0.1]]}

View file

@ -0,0 +1 @@
{"aabb_max":[1.0,3.0,0.1],"aabb_min":[-1.0,0.0,-0.1],"dreiecke":96,"oid":"bau_wand_fenster","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_wand_fenster.glb","vertices":[[-0.6,0.0,0.1],[-1.0,1.1,0.1],[-0.6,1.1,0.1],[-1.0,1.1,0.1],[-0.6,0.0,0.1],[-0.6,1.1,0.1],[-1.0,1.1,-0.1],[-0.6,0.0,-0.1],[-0.6,1.1,-0.1],[-0.6,0.0,-0.1],[-1.0,1.1,-0.1],[-0.6,1.1,-0.1],[-1.0,1.1,0.1],[-0.6,0.0,0.1],[-1.0,0.0,0.1],[-0.6,0.0,0.1],[-1.0,1.1,0.1],[-1.0,0.0,0.1],[-0.6,0.0,-0.1],[-1.0,1.1,-0.1],[-1.0,0.0,-0.1],[-1.0,1.1,-0.1],[-0.6,0.0,-0.1],[-1.0,0.0,-0.1],[-0.6,1.1,0.1],[-1.0,2.3,0.1],[-0.6,2.3,0.1],[-1.0,2.3,0.1],[-0.6,1.1,0.1],[-0.6,2.3,0.1],[-1.0,2.3,-0.1],[-0.6,1.1,-0.1],[-0.6,2.3,-0.1],[-0.6,1.1,-0.1],[-1.0,2.3,-0.1],[-0.6,2.3,-0.1],[-1.0,2.3,0.1],[-0.6,1.1,0.1],[-1.0,1.1,0.1],[-0.6,1.1,0.1],[-1.0,2.3,0.1],[-1.0,1.1,0.1],[-0.6,1.1,-0.1],[-1.0,2.3,-0.1],[-1.0,1.1,-0.1],[-1.0,2.3,-0.1],[-0.6,1.1,-0.1],[-1.0,1.1,-0.1],[-0.6,2.3,0.1],[-1.0,3.0,0.1],[-0.6,3.0,0.1],[-1.0,3.0,0.1],[-0.6,2.3,0.1],[-0.6,3.0,0.1],[-1.0,3.0,-0.1],[-0.6,2.3,-0.1],[-0.6,3.0,-0.1],[-0.6,2.3,-0.1],[-1.0,3.0,-0.1],[-0.6,3.0,-0.1],[-1.0,3.0,0.1],[-0.6,2.3,0.1],[-1.0,2.3,0.1],[-0.6,2.3,0.1],[-1.0,3.0,0.1],[-1.0,2.3,0.1],[-0.6,2.3,-0.1],[-1.0,3.0,-0.1],[-1.0,2.3,-0.1],[-1.0,3.0,-0.1],[-0.6,2.3,-0.1],[-1.0,2.3,-0.1],[0.6,0.0,0.1],[-0.6,1.1,0.1],[0.6,1.1,0.1],[-0.6,1.1,0.1],[0.6,0.0,0.1],[0.6,1.1,0.1],[-0.6,1.1,-0.1],[0.6,0.0,-0.1],[0.6,1.1,-0.1],[0.6,0.0,-0.1],[-0.6,1.1,-0.1],[0.6,1.1,-0.1],[-0.6,1.1,0.1],[0.6,0.0,0.1],[-0.6,0.0,0.1],[0.6,0.0,0.1],[-0.6,1.1,0.1],[-0.6,0.0,0.1],[0.6,0.0,-0.1],[-0.6,1.1,-0.1],[-0.6,0.0,-0.1],[-0.6,1.1,-0.1],[0.6,0.0,-0.1],[-0.6,0.0,-0.1],[0.6,2.3,0.1],[-0.6,3.0,0.1],[0.6,3.0,0.1],[-0.6,3.0,0.1],[0.6,2.3,0.1],[0.6,3.0,0.1],[-0.6,3.0,-0.1],[0.6,2.3,-0.1],[0.6,3.0,-0.1],[0.6,2.3,-0.1],[-0.6,3.0,-0.1],[0.6,3.0,-0.1],[-0.6,3.0,0.1],[0.6,2.3,0.1],[-0.6,2.3,0.1],[0.6,2.3,0.1],[-0.6,3.0,0.1],[-0.6,2.3,0.1],[0.6,2.3,-0.1],[-0.6,3.0,-0.1],[-0.6,2.3,-0.1],[-0.6,3.0,-0.1],[0.6,2.3,-0.1],[-0.6,2.3,-0.1],[1.0,0.0,0.1],[0.6,1.1,0.1],[1.0,1.1,0.1],[0.6,1.1,0.1],[1.0,0.0,0.1],[1.0,1.1,0.1],[0.6,1.1,-0.1],[1.0,0.0,-0.1],[1.0,1.1,-0.1],[1.0,0.0,-0.1],[0.6,1.1,-0.1],[1.0,1.1,-0.1],[0.6,1.1,0.1],[1.0,0.0,0.1],[0.6,0.0,0.1],[1.0,0.0,0.1],[0.6,1.1,0.1],[0.6,0.0,0.1],[1.0,0.0,-0.1],[0.6,1.1,-0.1],[0.6,0.0,-0.1],[0.6,1.1,-0.1],[1.0,0.0,-0.1],[0.6,0.0,-0.1],[1.0,1.1,0.1],[0.6,2.3,0.1],[1.0,2.3,0.1],[0.6,2.3,0.1],[1.0,1.1,0.1],[1.0,2.3,0.1],[0.6,2.3,-0.1],[1.0,1.1,-0.1],[1.0,2.3,-0.1],[1.0,1.1,-0.1],[0.6,2.3,-0.1],[1.0,2.3,-0.1],[0.6,2.3,0.1],[1.0,1.1,0.1],[0.6,1.1,0.1],[1.0,1.1,0.1],[0.6,2.3,0.1],[0.6,1.1,0.1],[1.0,1.1,-0.1],[0.6,2.3,-0.1],[0.6,1.1,-0.1],[0.6,2.3,-0.1],[1.0,1.1,-0.1],[0.6,1.1,-0.1],[1.0,2.3,0.1],[0.6,3.0,0.1],[1.0,3.0,0.1],[0.6,3.0,0.1],[1.0,2.3,0.1],[1.0,3.0,0.1],[0.6,3.0,-0.1],[1.0,2.3,-0.1],[1.0,3.0,-0.1],[1.0,2.3,-0.1],[0.6,3.0,-0.1],[1.0,3.0,-0.1],[0.6,3.0,0.1],[1.0,2.3,0.1],[0.6,2.3,0.1],[1.0,2.3,0.1],[0.6,3.0,0.1],[0.6,2.3,0.1],[1.0,2.3,-0.1],[0.6,3.0,-0.1],[0.6,2.3,-0.1],[0.6,3.0,-0.1],[1.0,2.3,-0.1],[0.6,2.3,-0.1],[-1.0,0.0,-0.1],[-1.0,3.0,0.1],[-1.0,0.0,0.1],[-1.0,3.0,0.1],[-1.0,0.0,-0.1],[-1.0,0.0,0.1],[-1.0,0.0,-0.1],[-1.0,3.0,-0.1],[-1.0,3.0,0.1],[-1.0,3.0,-0.1],[-1.0,0.0,-0.1],[-1.0,3.0,0.1],[1.0,0.0,0.1],[1.0,3.0,-0.1],[1.0,0.0,-0.1],[1.0,3.0,-0.1],[1.0,0.0,0.1],[1.0,0.0,-0.1],[1.0,0.0,0.1],[1.0,3.0,0.1],[1.0,3.0,-0.1],[1.0,3.0,0.1],[1.0,0.0,0.1],[1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.0,-0.1],[1.0,3.0,0.1],[1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.0,0.1],[-1.0,3.0,0.1],[-1.0,3.0,-0.1],[1.0,3.0,-0.1],[-1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.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,0.0,0.1],[-1.0,0.0,0.1],[-1.0,0.0,-0.1],[1.0,0.0,0.1],[0.6,2.3,0.1],[-0.6,2.3,-0.1],[0.6,2.3,-0.1],[-0.6,2.3,-0.1],[0.6,2.3,0.1],[0.6,2.3,-0.1],[0.6,2.3,0.1],[-0.6,2.3,0.1],[-0.6,2.3,-0.1],[-0.6,2.3,0.1],[0.6,2.3,0.1],[-0.6,2.3,-0.1],[-0.6,2.3,0.1],[-0.6,1.1,-0.1],[-0.6,2.3,-0.1],[-0.6,1.1,-0.1],[-0.6,2.3,0.1],[-0.6,2.3,-0.1],[-0.6,2.3,0.1],[-0.6,1.1,0.1],[-0.6,1.1,-0.1],[-0.6,1.1,0.1],[-0.6,2.3,0.1],[-0.6,1.1,-0.1],[-0.6,1.1,0.1],[0.6,1.1,-0.1],[-0.6,1.1,-0.1],[0.6,1.1,-0.1],[-0.6,1.1,0.1],[-0.6,1.1,-0.1],[-0.6,1.1,0.1],[0.6,1.1,0.1],[0.6,1.1,-0.1],[0.6,1.1,0.1],[-0.6,1.1,0.1],[0.6,1.1,-0.1],[0.6,1.1,0.1],[0.6,2.3,-0.1],[0.6,1.1,-0.1],[0.6,2.3,-0.1],[0.6,1.1,0.1],[0.6,1.1,-0.1],[0.6,1.1,0.1],[0.6,2.3,0.1],[0.6,2.3,-0.1],[0.6,2.3,0.1],[0.6,1.1,0.1],[0.6,2.3,-0.1]]}

View file

@ -0,0 +1 @@
{"aabb_max":[1.0,3.0,0.1],"aabb_min":[-1.0,0.0,-0.1],"dreiecke":96,"oid":"bau_wand_scharte","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_wand_scharte.glb","vertices":[[-0.5,0.0,0.1],[-1.0,1.3,0.1],[-0.5,1.3,0.1],[-1.0,1.3,0.1],[-0.5,0.0,0.1],[-0.5,1.3,0.1],[-1.0,1.3,-0.1],[-0.5,0.0,-0.1],[-0.5,1.3,-0.1],[-0.5,0.0,-0.1],[-1.0,1.3,-0.1],[-0.5,1.3,-0.1],[-1.0,1.3,0.1],[-0.5,0.0,0.1],[-1.0,0.0,0.1],[-0.5,0.0,0.1],[-1.0,1.3,0.1],[-1.0,0.0,0.1],[-0.5,0.0,-0.1],[-1.0,1.3,-0.1],[-1.0,0.0,-0.1],[-1.0,1.3,-0.1],[-0.5,0.0,-0.1],[-1.0,0.0,-0.1],[-0.5,1.3,0.1],[-1.0,1.7,0.1],[-0.5,1.7,0.1],[-1.0,1.7,0.1],[-0.5,1.3,0.1],[-0.5,1.7,0.1],[-1.0,1.7,-0.1],[-0.5,1.3,-0.1],[-0.5,1.7,-0.1],[-0.5,1.3,-0.1],[-1.0,1.7,-0.1],[-0.5,1.7,-0.1],[-1.0,1.7,0.1],[-0.5,1.3,0.1],[-1.0,1.3,0.1],[-0.5,1.3,0.1],[-1.0,1.7,0.1],[-1.0,1.3,0.1],[-0.5,1.3,-0.1],[-1.0,1.7,-0.1],[-1.0,1.3,-0.1],[-1.0,1.7,-0.1],[-0.5,1.3,-0.1],[-1.0,1.3,-0.1],[-0.5,1.7,0.1],[-1.0,3.0,0.1],[-0.5,3.0,0.1],[-1.0,3.0,0.1],[-0.5,1.7,0.1],[-0.5,3.0,0.1],[-1.0,3.0,-0.1],[-0.5,1.7,-0.1],[-0.5,3.0,-0.1],[-0.5,1.7,-0.1],[-1.0,3.0,-0.1],[-0.5,3.0,-0.1],[-1.0,3.0,0.1],[-0.5,1.7,0.1],[-1.0,1.7,0.1],[-0.5,1.7,0.1],[-1.0,3.0,0.1],[-1.0,1.7,0.1],[-0.5,1.7,-0.1],[-1.0,3.0,-0.1],[-1.0,1.7,-0.1],[-1.0,3.0,-0.1],[-0.5,1.7,-0.1],[-1.0,1.7,-0.1],[0.5,0.0,0.1],[-0.5,1.3,0.1],[0.5,1.3,0.1],[-0.5,1.3,0.1],[0.5,0.0,0.1],[0.5,1.3,0.1],[-0.5,1.3,-0.1],[0.5,0.0,-0.1],[0.5,1.3,-0.1],[0.5,0.0,-0.1],[-0.5,1.3,-0.1],[0.5,1.3,-0.1],[-0.5,1.3,0.1],[0.5,0.0,0.1],[-0.5,0.0,0.1],[0.5,0.0,0.1],[-0.5,1.3,0.1],[-0.5,0.0,0.1],[0.5,0.0,-0.1],[-0.5,1.3,-0.1],[-0.5,0.0,-0.1],[-0.5,1.3,-0.1],[0.5,0.0,-0.1],[-0.5,0.0,-0.1],[0.5,1.7,0.1],[-0.5,3.0,0.1],[0.5,3.0,0.1],[-0.5,3.0,0.1],[0.5,1.7,0.1],[0.5,3.0,0.1],[-0.5,3.0,-0.1],[0.5,1.7,-0.1],[0.5,3.0,-0.1],[0.5,1.7,-0.1],[-0.5,3.0,-0.1],[0.5,3.0,-0.1],[-0.5,3.0,0.1],[0.5,1.7,0.1],[-0.5,1.7,0.1],[0.5,1.7,0.1],[-0.5,3.0,0.1],[-0.5,1.7,0.1],[0.5,1.7,-0.1],[-0.5,3.0,-0.1],[-0.5,1.7,-0.1],[-0.5,3.0,-0.1],[0.5,1.7,-0.1],[-0.5,1.7,-0.1],[1.0,0.0,0.1],[0.5,1.3,0.1],[1.0,1.3,0.1],[0.5,1.3,0.1],[1.0,0.0,0.1],[1.0,1.3,0.1],[0.5,1.3,-0.1],[1.0,0.0,-0.1],[1.0,1.3,-0.1],[1.0,0.0,-0.1],[0.5,1.3,-0.1],[1.0,1.3,-0.1],[0.5,1.3,0.1],[1.0,0.0,0.1],[0.5,0.0,0.1],[1.0,0.0,0.1],[0.5,1.3,0.1],[0.5,0.0,0.1],[1.0,0.0,-0.1],[0.5,1.3,-0.1],[0.5,0.0,-0.1],[0.5,1.3,-0.1],[1.0,0.0,-0.1],[0.5,0.0,-0.1],[1.0,1.3,0.1],[0.5,1.7,0.1],[1.0,1.7,0.1],[0.5,1.7,0.1],[1.0,1.3,0.1],[1.0,1.7,0.1],[0.5,1.7,-0.1],[1.0,1.3,-0.1],[1.0,1.7,-0.1],[1.0,1.3,-0.1],[0.5,1.7,-0.1],[1.0,1.7,-0.1],[0.5,1.7,0.1],[1.0,1.3,0.1],[0.5,1.3,0.1],[1.0,1.3,0.1],[0.5,1.7,0.1],[0.5,1.3,0.1],[1.0,1.3,-0.1],[0.5,1.7,-0.1],[0.5,1.3,-0.1],[0.5,1.7,-0.1],[1.0,1.3,-0.1],[0.5,1.3,-0.1],[1.0,1.7,0.1],[0.5,3.0,0.1],[1.0,3.0,0.1],[0.5,3.0,0.1],[1.0,1.7,0.1],[1.0,3.0,0.1],[0.5,3.0,-0.1],[1.0,1.7,-0.1],[1.0,3.0,-0.1],[1.0,1.7,-0.1],[0.5,3.0,-0.1],[1.0,3.0,-0.1],[0.5,3.0,0.1],[1.0,1.7,0.1],[0.5,1.7,0.1],[1.0,1.7,0.1],[0.5,3.0,0.1],[0.5,1.7,0.1],[1.0,1.7,-0.1],[0.5,3.0,-0.1],[0.5,1.7,-0.1],[0.5,3.0,-0.1],[1.0,1.7,-0.1],[0.5,1.7,-0.1],[-1.0,0.0,-0.1],[-1.0,3.0,0.1],[-1.0,0.0,0.1],[-1.0,3.0,0.1],[-1.0,0.0,-0.1],[-1.0,0.0,0.1],[-1.0,0.0,-0.1],[-1.0,3.0,-0.1],[-1.0,3.0,0.1],[-1.0,3.0,-0.1],[-1.0,0.0,-0.1],[-1.0,3.0,0.1],[1.0,0.0,0.1],[1.0,3.0,-0.1],[1.0,0.0,-0.1],[1.0,3.0,-0.1],[1.0,0.0,0.1],[1.0,0.0,-0.1],[1.0,0.0,0.1],[1.0,3.0,0.1],[1.0,3.0,-0.1],[1.0,3.0,0.1],[1.0,0.0,0.1],[1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.0,-0.1],[1.0,3.0,0.1],[1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.0,0.1],[-1.0,3.0,0.1],[-1.0,3.0,-0.1],[1.0,3.0,-0.1],[-1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.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,0.0,0.1],[-1.0,0.0,0.1],[-1.0,0.0,-0.1],[1.0,0.0,0.1],[0.5,1.7,0.1],[-0.5,1.7,-0.1],[0.5,1.7,-0.1],[-0.5,1.7,-0.1],[0.5,1.7,0.1],[0.5,1.7,-0.1],[0.5,1.7,0.1],[-0.5,1.7,0.1],[-0.5,1.7,-0.1],[-0.5,1.7,0.1],[0.5,1.7,0.1],[-0.5,1.7,-0.1],[-0.5,1.7,0.1],[-0.5,1.3,-0.1],[-0.5,1.7,-0.1],[-0.5,1.3,-0.1],[-0.5,1.7,0.1],[-0.5,1.7,-0.1],[-0.5,1.7,0.1],[-0.5,1.3,0.1],[-0.5,1.3,-0.1],[-0.5,1.3,0.1],[-0.5,1.7,0.1],[-0.5,1.3,-0.1],[-0.5,1.3,0.1],[0.5,1.3,-0.1],[-0.5,1.3,-0.1],[0.5,1.3,-0.1],[-0.5,1.3,0.1],[-0.5,1.3,-0.1],[-0.5,1.3,0.1],[0.5,1.3,0.1],[0.5,1.3,-0.1],[0.5,1.3,0.1],[-0.5,1.3,0.1],[0.5,1.3,-0.1],[0.5,1.3,0.1],[0.5,1.7,-0.1],[0.5,1.3,-0.1],[0.5,1.7,-0.1],[0.5,1.3,0.1],[0.5,1.3,-0.1],[0.5,1.3,0.1],[0.5,1.7,0.1],[0.5,1.7,-0.1],[0.5,1.7,0.1],[0.5,1.3,0.1],[0.5,1.7,-0.1]]}

View file

@ -0,0 +1 @@
{"aabb_max":[1.0,3.0,0.1],"aabb_min":[-1.0,0.0,-0.1],"dreiecke":72,"oid":"bau_wand_tuer","quelle_glb":"res://assets/objekte/military_desert/bauteile/bau_wand_tuer.glb","vertices":[[-0.6,0.0,0.1],[-1.0,2.2,0.1],[-0.6,2.2,0.1],[-1.0,2.2,0.1],[-0.6,0.0,0.1],[-0.6,2.2,0.1],[-1.0,2.2,-0.1],[-0.6,0.0,-0.1],[-0.6,2.2,-0.1],[-0.6,0.0,-0.1],[-1.0,2.2,-0.1],[-0.6,2.2,-0.1],[-1.0,2.2,0.1],[-0.6,0.0,0.1],[-1.0,0.0,0.1],[-0.6,0.0,0.1],[-1.0,2.2,0.1],[-1.0,0.0,0.1],[-0.6,0.0,-0.1],[-1.0,2.2,-0.1],[-1.0,0.0,-0.1],[-1.0,2.2,-0.1],[-0.6,0.0,-0.1],[-1.0,0.0,-0.1],[-0.6,2.2,0.1],[-1.0,3.0,0.1],[-0.6,3.0,0.1],[-1.0,3.0,0.1],[-0.6,2.2,0.1],[-0.6,3.0,0.1],[-1.0,3.0,-0.1],[-0.6,2.2,-0.1],[-0.6,3.0,-0.1],[-0.6,2.2,-0.1],[-1.0,3.0,-0.1],[-0.6,3.0,-0.1],[-1.0,3.0,0.1],[-0.6,2.2,0.1],[-1.0,2.2,0.1],[-0.6,2.2,0.1],[-1.0,3.0,0.1],[-1.0,2.2,0.1],[-0.6,2.2,-0.1],[-1.0,3.0,-0.1],[-1.0,2.2,-0.1],[-1.0,3.0,-0.1],[-0.6,2.2,-0.1],[-1.0,2.2,-0.1],[0.6,2.2,0.1],[-0.6,3.0,0.1],[0.6,3.0,0.1],[-0.6,3.0,0.1],[0.6,2.2,0.1],[0.6,3.0,0.1],[-0.6,3.0,-0.1],[0.6,2.2,-0.1],[0.6,3.0,-0.1],[0.6,2.2,-0.1],[-0.6,3.0,-0.1],[0.6,3.0,-0.1],[-0.6,3.0,0.1],[0.6,2.2,0.1],[-0.6,2.2,0.1],[0.6,2.2,0.1],[-0.6,3.0,0.1],[-0.6,2.2,0.1],[0.6,2.2,-0.1],[-0.6,3.0,-0.1],[-0.6,2.2,-0.1],[-0.6,3.0,-0.1],[0.6,2.2,-0.1],[-0.6,2.2,-0.1],[1.0,0.0,0.1],[0.6,2.2,0.1],[1.0,2.2,0.1],[0.6,2.2,0.1],[1.0,0.0,0.1],[1.0,2.2,0.1],[0.6,2.2,-0.1],[1.0,0.0,-0.1],[1.0,2.2,-0.1],[1.0,0.0,-0.1],[0.6,2.2,-0.1],[1.0,2.2,-0.1],[0.6,2.2,0.1],[1.0,0.0,0.1],[0.6,0.0,0.1],[1.0,0.0,0.1],[0.6,2.2,0.1],[0.6,0.0,0.1],[1.0,0.0,-0.1],[0.6,2.2,-0.1],[0.6,0.0,-0.1],[0.6,2.2,-0.1],[1.0,0.0,-0.1],[0.6,0.0,-0.1],[1.0,2.2,0.1],[0.6,3.0,0.1],[1.0,3.0,0.1],[0.6,3.0,0.1],[1.0,2.2,0.1],[1.0,3.0,0.1],[0.6,3.0,-0.1],[1.0,2.2,-0.1],[1.0,3.0,-0.1],[1.0,2.2,-0.1],[0.6,3.0,-0.1],[1.0,3.0,-0.1],[0.6,3.0,0.1],[1.0,2.2,0.1],[0.6,2.2,0.1],[1.0,2.2,0.1],[0.6,3.0,0.1],[0.6,2.2,0.1],[1.0,2.2,-0.1],[0.6,3.0,-0.1],[0.6,2.2,-0.1],[0.6,3.0,-0.1],[1.0,2.2,-0.1],[0.6,2.2,-0.1],[-1.0,0.0,-0.1],[-1.0,3.0,0.1],[-1.0,0.0,0.1],[-1.0,3.0,0.1],[-1.0,0.0,-0.1],[-1.0,0.0,0.1],[-1.0,0.0,-0.1],[-1.0,3.0,-0.1],[-1.0,3.0,0.1],[-1.0,3.0,-0.1],[-1.0,0.0,-0.1],[-1.0,3.0,0.1],[1.0,0.0,0.1],[1.0,3.0,-0.1],[1.0,0.0,-0.1],[1.0,3.0,-0.1],[1.0,0.0,0.1],[1.0,0.0,-0.1],[1.0,0.0,0.1],[1.0,3.0,0.1],[1.0,3.0,-0.1],[1.0,3.0,0.1],[1.0,0.0,0.1],[1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.0,-0.1],[1.0,3.0,0.1],[1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.0,0.1],[-1.0,3.0,0.1],[-1.0,3.0,-0.1],[1.0,3.0,-0.1],[-1.0,3.0,-0.1],[-1.0,3.0,0.1],[1.0,3.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,0.0,0.1],[-1.0,0.0,0.1],[-1.0,0.0,-0.1],[1.0,0.0,0.1],[0.6,2.2,0.1],[-0.6,2.2,-0.1],[0.6,2.2,-0.1],[-0.6,2.2,-0.1],[0.6,2.2,0.1],[0.6,2.2,-0.1],[0.6,2.2,0.1],[-0.6,2.2,0.1],[-0.6,2.2,-0.1],[-0.6,2.2,0.1],[0.6,2.2,0.1],[-0.6,2.2,-0.1],[-0.6,2.2,0.1],[-0.6,0.0,-0.1],[-0.6,2.2,-0.1],[-0.6,0.0,-0.1],[-0.6,2.2,0.1],[-0.6,2.2,-0.1],[-0.6,2.2,0.1],[-0.6,0.0,0.1],[-0.6,0.0,-0.1],[-0.6,0.0,0.1],[-0.6,2.2,0.1],[-0.6,0.0,-0.1],[-0.6,0.0,0.1],[0.6,0.0,-0.1],[-0.6,0.0,-0.1],[0.6,0.0,-0.1],[-0.6,0.0,0.1],[-0.6,0.0,-0.1],[-0.6,0.0,0.1],[0.6,0.0,0.1],[0.6,0.0,-0.1],[0.6,0.0,0.1],[-0.6,0.0,0.1],[0.6,0.0,-0.1],[0.6,0.0,0.1],[0.6,2.2,-0.1],[0.6,0.0,-0.1],[0.6,2.2,-0.1],[0.6,0.0,0.1],[0.6,0.0,-0.1],[0.6,0.0,0.1],[0.6,2.2,0.1],[0.6,2.2,-0.1],[0.6,2.2,0.1],[0.6,0.0,0.1],[0.6,2.2,-0.1]]}

View file

@ -628,6 +628,186 @@
] ]
} }
}, },
"bau_boden": {
"poly": 24,
"collision": {
"quelle": "kollisions/bau_boden.json",
"shape": "trimesh",
"size": [
2.0,
0.2,
2.0
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_boden.glb",
"kategorie": "bauteile",
"name_de": "Boden / Decke"
},
"bau_bruestung": {
"poly": 24,
"collision": {
"quelle": "kollisions/bau_bruestung.json",
"shape": "trimesh",
"size": [
2.0,
1.1,
0.2
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_bruestung.glb",
"kategorie": "bauteile",
"name_de": "Brüstung"
},
"bau_dach_giebel": {
"poly": 48,
"collision": {
"quelle": "kollisions/bau_dach_giebel.json",
"shape": "trimesh",
"size": [
2.141,
1.141,
2.0
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_dach_giebel.glb",
"kategorie": "bauteile",
"name_de": "Satteldach"
},
"bau_dach_pyramide": {
"poly": 64,
"collision": {
"quelle": "kollisions/bau_dach_pyramide.json",
"shape": "trimesh",
"size": [
2.141,
1.141,
2.141
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_dach_pyramide.glb",
"kategorie": "bauteile",
"name_de": "Pyramidendach"
},
"bau_fundament": {
"poly": 24,
"collision": {
"quelle": "kollisions/bau_fundament.json",
"shape": "trimesh",
"size": [
2.0,
0.2,
2.0
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_fundament.glb",
"kategorie": "bauteile",
"name_de": "Fundament"
},
"bau_giebelwand": {
"poly": 16,
"collision": {
"quelle": "kollisions/bau_giebelwand.json",
"shape": "trimesh",
"size": [
2.0,
1.0,
0.2
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_giebelwand.glb",
"kategorie": "bauteile",
"name_de": "Giebelwand"
},
"bau_pfeiler": {
"poly": 12,
"collision": {
"quelle": "kollisions/bau_pfeiler.json",
"shape": "trimesh",
"size": [
0.3,
3.0,
0.3
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_pfeiler.glb",
"kategorie": "bauteile",
"name_de": "Eckpfeiler"
},
"bau_treppe": {
"poly": 248,
"collision": {
"quelle": "kollisions/bau_treppe.json",
"shape": "trimesh",
"size": [
4.0,
3.0,
2.0
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_treppe.glb",
"kategorie": "bauteile",
"name_de": "Treppe"
},
"bau_wand": {
"poly": 24,
"collision": {
"quelle": "kollisions/bau_wand.json",
"shape": "trimesh",
"size": [
2.0,
3.0,
0.2
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_wand.glb",
"kategorie": "bauteile",
"name_de": "Wand"
},
"bau_wand_fenster": {
"poly": 96,
"collision": {
"quelle": "kollisions/bau_wand_fenster.json",
"shape": "trimesh",
"size": [
2.0,
3.0,
0.2
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_wand_fenster.glb",
"kategorie": "bauteile",
"name_de": "Wand mit Fenster"
},
"bau_wand_scharte": {
"poly": 96,
"collision": {
"quelle": "kollisions/bau_wand_scharte.json",
"shape": "trimesh",
"size": [
2.0,
3.0,
0.2
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_wand_scharte.glb",
"kategorie": "bauteile",
"name_de": "Wand mit Schießscharte"
},
"bau_wand_tuer": {
"poly": 72,
"collision": {
"quelle": "kollisions/bau_wand_tuer.json",
"shape": "trimesh",
"size": [
2.0,
3.0,
0.2
]
},
"glb": "res://assets/objekte/military_desert/bauteile/bau_wand_tuer.glb",
"kategorie": "bauteile",
"name_de": "Wand mit Tür"
},
"baum_kiefer_1": { "baum_kiefer_1": {
"poly": 728, "poly": 728,
"collision": { "collision": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
library/thumbs/bau_wand.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

307
src/editor/bau_katalog.gd Normal file
View file

@ -0,0 +1,307 @@
extends RefCounted
class_name BauKatalog
## Bauteil-Katalog des Bauen-Modus (Karte "Bauen sauber integrieren", 25.10.).
##
## Ein Bauteil ist ein GANZ NORMALES Bibliotheks-Objekt (`bau_*`, Kategorie
## "bauteile"): gesetzte Teile stehen in doc["objects"], tragen ein
## Gruppen-Feld (= ein Gebäude) und laufen damit durch dieselbe Kette wie jedes
## Objekt — Undo, Kopieren, Löschen, Symmetrie, Speichern, Server-Registry,
## Client-Loader, Trimesh-Kollision. Es gibt keinen Sonderblock "gebaeude" mehr.
##
## Geometrie: die CC0-Runtime des Modular Builder (addons/modular_builder,
## Michel Savadim, v0.2.0 — Lizenz siehe ADDON_LIZENZ.txt). Hier wird sie nur
## PARAMETRIERT (Wandhöhe 3 m, 2-m-Raster) und auf die Fußpunkt-Konvention der
## Bibliothek normalisiert: Unterkante y = 0, XZ um die Mitte zentriert
## (docs/objekt_fragmente.md). Die Farbe ist die Material-Grundfarbe (glTF
## baseColorFactor) — keine Texturen, keine Lizenzfragen, 1 Draw-Call je Teil;
## Editor, Client und Thumbnails laden sie dadurch identisch.
##
## Meshes entstehen aus diesem Katalog (tools/gen_bauteile.gd schreibt GLB +
## Trimesh-JSON + Registry-Fragment), der Editor zeigt die GLBs wie jedes andere
## Objekt. mesh() ist die Quelle für Generator UND Tests.
const GEO_SCRIPT := "res://addons/modular_builder/modular_piece_geometry.gd"
const DETAIL_SCRIPT := "res://addons/modular_builder/modular_building_detail.gd"
const KATEGORIE := "bauteile"
const PREFIX := "bau_"
const RASTER_M := 2.0 # Zellgröße (XZ)
const WAND_H := 3.0 # Wandhöhe eines Geschosses
const DICKE_M := 0.2 # Wand-/Plattendicke
# Geometrie-Typen der Addon-Runtime (PieceType-Enum, Kopie der Werte)
const T_FUNDAMENT := 0
const T_BODEN := 2
const T_WAND := 3
const T_GIEBEL := 5
const T_DACH_GIEBEL := 6
const T_DACH_PYRAMIDE := 9
const T_TREPPE := 10
const D_FENSTER := 0
const D_TUER := 1
## Snap-Arten (build_snap.gd):
## "zelle" Mitte einer 2×2-Zelle (Fundament, Boden, Dächer)
## "kante" Mitte einer Zellkante (Wände) — Drehung ergibt sich aus der Kante
## "ecke" Zell-Ecke (Pfeiler)
## "treppe" 4×2-m-Fläche über zwei Zellen
const GRUPPEN := [
{"key": "grund", "label": "Fundament & Boden", "oids": ["bau_fundament", "bau_boden"]},
{"key": "waende", "label": "Wände", "oids": ["bau_wand", "bau_wand_tuer", "bau_wand_fenster",
"bau_wand_scharte", "bau_bruestung", "bau_pfeiler"]},
{"key": "dach", "label": "Dach & Giebel", "oids": ["bau_giebelwand", "bau_dach_giebel", "bau_dach_pyramide"]},
{"key": "treppe", "label": "Treppe", "oids": ["bau_treppe"]},
]
const QUADRAT := [[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [-1.0, 0.0, 1.0]]
## Reihenfolge = Anzeige-Reihenfolge (GRUPPEN nutzt dieselben IDs).
## "traeger": darauf darf gestapelt werden (Oberkante zählt als Ebene).
const KINDS := {
"bau_fundament": {
"label": "Fundament", "info": "2×2 m, 20 cm dick — Basis für Wände", "snap": "zelle", "traeger": true,
"typ": T_FUNDAMENT, "punkte": QUADRAT, "farbe": Color(0.42, 0.42, 0.40),
},
"bau_boden": {
"label": "Boden / Decke", "info": "2×2 m Platte — Fußboden oder Decke fürs nächste Geschoss",
"snap": "zelle", "traeger": true,
"typ": T_BODEN, "punkte": QUADRAT, "farbe": Color(0.50, 0.46, 0.40),
},
"bau_wand": {
"label": "Wand", "info": "2 m breit, 3 m hoch", "snap": "kante", "traeger": true,
"typ": T_WAND, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "farbe": Color(0.62, 0.53, 0.38),
},
"bau_wand_tuer": {
"label": "Wand mit Tür", "info": "Türöffnung 1,2 × 2,2 m — man läuft hindurch", "snap": "kante", "traeger": true,
"typ": T_WAND, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "farbe": Color(0.62, 0.53, 0.38),
"details": [{"typ": D_TUER, "pos": [1.0, 1.1, 0.0], "w": 1.2, "h": 2.2}],
},
"bau_wand_fenster": {
"label": "Wand mit Fenster", "info": "Fensteröffnung 1,2 × 1,2 m, Brüstung 1,1 m", "snap": "kante", "traeger": true,
"typ": T_WAND, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "farbe": Color(0.62, 0.53, 0.38),
"details": [{"typ": D_FENSTER, "pos": [1.0, 1.7, 0.0], "w": 1.2, "h": 1.2}],
},
"bau_wand_scharte": {
"label": "Wand mit Schießscharte", "info": "Schlitz 1,0 × 0,4 m auf Schulterhöhe", "snap": "kante", "traeger": true,
"typ": T_WAND, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "farbe": Color(0.62, 0.53, 0.38),
"details": [{"typ": D_FENSTER, "pos": [1.0, 1.5, 0.0], "w": 1.0, "h": 0.4}],
},
"bau_bruestung": {
"label": "Brüstung", "info": "2 m breit, 1,1 m hoch — Deckung, Dachrand", "snap": "kante", "traeger": true,
"typ": T_WAND, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "farbe": Color(0.56, 0.48, 0.35), "hoehe": 1.1,
},
"bau_pfeiler": {
"label": "Eckpfeiler", "info": "Schließt die Ecke, wo zwei Wände zusammenstoßen", "snap": "ecke", "traeger": true,
"box": Vector3(0.3, 3.0, 0.3), "farbe": Color(0.42, 0.42, 0.40),
},
"bau_giebelwand": {
"label": "Giebelwand", "info": "Dreieck-Abschluss unter dem Satteldach (2 m breit, 1 m Spitze)",
"snap": "kante", "traeger": false,
"typ": T_GIEBEL, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "rise": 1.0, "farbe": Color(0.62, 0.53, 0.38),
},
"bau_dach_giebel": {
"label": "Satteldach", "info": "2×2 m, First in Richtung der Vorschau (R dreht)", "snap": "zelle", "traeger": false,
"typ": T_DACH_GIEBEL, "punkte": QUADRAT, "rise": 1.0, "farbe": Color(0.40, 0.25, 0.18),
},
"bau_dach_pyramide": {
"label": "Pyramidendach", "info": "2×2 m, Spitze in der Mitte", "snap": "zelle", "traeger": false,
"typ": T_DACH_PYRAMIDE, "punkte": QUADRAT, "rise": 1.0, "farbe": Color(0.40, 0.25, 0.18),
},
"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,
"typ": T_TREPPE, "punkte": [[0.0, 0.0, 0.0], [4.0, 3.0, 0.0]], "stufen": 15, "breite": 2.0,
"farbe": Color(0.46, 0.45, 0.42),
},
}
static var _mesh_cache: Dictionary = {}
static func ist_bau(oid: String) -> bool:
return KINDS.has(oid)
static func oids() -> Array:
return KINDS.keys()
static func label(oid: String) -> String:
return str((KINDS.get(oid, {}) as Dictionary).get("label", oid))
static func snap_art(oid: String) -> String:
return str((KINDS.get(oid, {}) as Dictionary).get("snap", "zelle"))
static func ist_traeger(oid: String) -> bool:
return bool((KINDS.get(oid, {}) as Dictionary).get("traeger", false))
## Ausmaße (m) des normalisierten Meshes: x = Länge/Breite, y = Höhe, z = Tiefe.
static func groesse(oid: String) -> Vector3:
var m := mesh(oid)
return m.get_aabb().size if m != null else Vector3.ZERO
# ------------------------------------------------------------------- Mesh ---
## Normalisiertes Mesh (Fußpunkt unten-Mitte, Vertex-Farben) oder null.
static func mesh(oid: String) -> ArrayMesh:
if _mesh_cache.has(oid):
return _mesh_cache[oid]
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 fertig := _normalisieren(roh, kind["farbe"] as Color)
_mesh_cache[oid] = fertig
return fertig
static func _box_mesh(size: Vector3) -> ArrayMesh:
var bm := BoxMesh.new()
bm.size = size
var out := ArrayMesh.new()
out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, bm.get_mesh_arrays())
return out
static func _addon_mesh(kind: Dictionary) -> ArrayMesh:
var geo := Node3D.new()
geo.set_script(load(GEO_SCRIPT))
geo.set("piece_type", int(kind["typ"]))
geo.set("unit_size", float(kind.get("hoehe", WAND_H)))
geo.set("thickness", DICKE_M)
if kind.has("rise"):
geo.set("rise", float(kind["rise"]))
if kind.has("stufen"):
geo.set("stair_count", int(kind["stufen"]))
geo.set("stair_width", float(kind["breite"]))
var pts := PackedVector3Array()
for p in (kind["punkte"] as Array):
pts.append(Vector3(float(p[0]), float(p[1]), float(p[2])))
geo.set("custom_points", pts)
for d in (kind.get("details", []) as Array):
var detail := Node3D.new()
detail.set_script(load(DETAIL_SCRIPT))
detail.set("detail_type", int(d["typ"]))
detail.set("width", float(d["w"]))
detail.set("height", float(d["h"]))
detail.position = Vector3(float(d["pos"][0]), float(d["pos"][1]), float(d["pos"][2]))
geo.add_child(detail)
var m: ArrayMesh = geo.call("_make_mesh")
geo.free()
return m
## Alle Flächen zu EINER Fläche verschmelzen, Fußpunkt-normalisieren, Material-
## Farbe setzen.
static func _normalisieren(roh: ArrayMesh, farbe: Color) -> ArrayMesh:
var aabb := roh.get_aabb()
var versatz := Vector3(aabb.position.x + aabb.size.x * 0.5, aabb.position.y,
aabb.position.z + aabb.size.z * 0.5)
var pos := PackedVector3Array()
var nrm := PackedVector3Array()
for s in roh.get_surface_count():
var arr := roh.surface_get_arrays(s)
var v: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
var n: Variant = arr[Mesh.ARRAY_NORMAL]
var idx: Variant = arr[Mesh.ARRAY_INDEX]
var order: Array = []
if idx is PackedInt32Array and not (idx as PackedInt32Array).is_empty():
for i in (idx as PackedInt32Array):
order.append(i)
else:
for i in v.size():
order.append(i)
for i in order:
pos.append(v[i] - versatz)
nrm.append((n as PackedVector3Array)[i] if n is PackedVector3Array else Vector3.UP)
# Die Addon-Geometrie hat Dreiecke gegen die Normalenrichtung gewickelt (sie
# rendert ohne Backface-Culling). Godot culled Rückseiten → Wicklung an die
# Normale anpassen, sonst sieht man Innenseiten und Dächer werden schwarz.
for t in range(0, pos.size() - 2, 3):
var gn := (pos[t + 2] - pos[t]).cross(pos[t + 1] - pos[t])
if gn.dot(nrm[t]) < 0.0:
var tmp := pos[t + 1]
pos[t + 1] = pos[t + 2]
pos[t + 2] = tmp
var tn := nrm[t + 1]
nrm[t + 1] = nrm[t + 2]
nrm[t + 2] = tn
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = pos
arrays[Mesh.ARRAY_NORMAL] = nrm
var out := ArrayMesh.new()
out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
var mat := StandardMaterial3D.new()
mat.albedo_color = farbe
mat.roughness = 0.9
out.surface_set_material(0, mat)
return out
static func dreiecke(oid: String) -> int:
var m := mesh(oid)
return int(m.get_faces().size() / 3) if m != null else 0
# ------------------------------------------------- Kollision / Registry ------
## Trimesh-Datei (library/kollisions/<oid>.json) — gleiche Felder wie die
## Pack-Dateien: Dreiecks-Suppe in Fußpunkt-Koordinaten. Türen, Fenster und
## Scharten sind echte Löcher (die Öffnung hat keine Dreiecke).
static func kollisions_json(oid: String) -> Dictionary:
var m := mesh(oid)
if m == null:
return {}
var verts: Array = []
for v in m.get_faces():
verts.append([snappedf(v.x, 0.0001), snappedf(v.y, 0.0001), snappedf(v.z, 0.0001)])
var aabb := m.get_aabb()
return {
"oid": oid,
"quelle_glb": "res://assets/objekte/military_desert/%s/%s.glb" % [KATEGORIE, oid],
"dreiecke": int(verts.size() / 3),
"aabb_min": [snappedf(aabb.position.x, 0.001), snappedf(aabb.position.y, 0.001), snappedf(aabb.position.z, 0.001)],
"aabb_max": [snappedf(aabb.end.x, 0.001), snappedf(aabb.end.y, 0.001), snappedf(aabb.end.z, 0.001)],
"vertices": verts,
}
## Registry-Eintrag (Fragment library/objects.d/bauteile.json).
static func registry_eintrag(oid: String) -> Dictionary:
var g := groesse(oid)
return {
"poly": dreiecke(oid),
"collision": {
"quelle": "kollisions/%s.json" % oid,
"shape": "trimesh",
"size": [snappedf(g.x, 0.001), snappedf(g.y, 0.001), snappedf(g.z, 0.001)],
},
"glb": "res://assets/objekte/military_desert/%s/%s.glb" % [KATEGORIE, oid],
"kategorie": KATEGORIE,
"name_de": label(oid),
}
## GLB schreiben (eigenes Mesh mit Vertex-Farben). Rückgabe: Godot-Fehlercode.
static func glb_schreiben(oid: String, pfad: String) -> int:
var m := mesh(oid)
if m == null:
return ERR_DOES_NOT_EXIST
var root := Node3D.new()
root.name = oid
var mi := MeshInstance3D.new()
mi.name = oid
mi.mesh = m
root.add_child(mi)
var gdoc := GLTFDocument.new()
var gstate := GLTFState.new()
var err := gdoc.append_from_scene(root, gstate)
if err == OK:
err = gdoc.write_to_filesystem(gstate, pfad)
root.free()
return err

View file

@ -0,0 +1 @@
uid://defuinyvy5j8i

204
src/editor/bau_ops.gd Normal file
View file

@ -0,0 +1,204 @@
extends RefCounted
class_name BauOps
## Dokument-Operationen des Bauen-Modus (pure Funktionen, headless testbar).
##
## Ein Gebäude = eine Gruppe (GroupOps, Feld "group" + group_names) aus `bau_*`-
## Objekten. Alles Weitere — Verschieben, Drehen, Kopieren, Löschen, Symmetrie,
## Speichern — läuft über die normalen Objekt-Funktionen. Dieser Teil kennt nur,
## was speziell am Bauen ist: Teil setzen/ersetzen, Gebäude-Gruppen, Migration
## der alten "gebaeude"-Sektion (Karte t_97ea59c5 → Umbau 25.10.).
static func teile_zahl(doc: Dictionary) -> int:
var n := 0
for o in (doc.get("objects", []) as Array):
if o is Dictionary and BauKatalog.ist_bau(str((o as Dictionary).get("id", ""))):
n += 1
return n
## Dreiecke aller Bauteile (grobe Poly-Übersicht für die Statuszeile).
static func dreiecke_gesamt(doc: Dictionary) -> int:
var n := 0
for o in (doc.get("objects", []) as Array):
if o is Dictionary:
n += BauKatalog.dreiecke(str((o as Dictionary).get("id", "")))
return n
## Neue Gebäude-Gruppe anlegen und benennen. Rückgabe: Gruppen-ID.
static func gruppe_neu(doc: Dictionary) -> int:
var gid := GroupOps.next_group_id(doc)
GroupState.set_group_name(doc, gid, "Gebäude %d" % (gebaeude_zahl(doc) + 1))
return gid
## Anzahl Gebäude-Gruppen (Gruppen, deren erstes Mitglied ein Bauteil ist).
static func gebaeude_zahl(doc: Dictionary) -> int:
return gebaeude_gruppen(doc).size()
## [{gid, name, indizes, dreiecke}] für alle Gebäude-Gruppen.
static func gebaeude_gruppen(doc: Dictionary) -> Array:
var out: Array = []
var objects: Array = doc.get("objects", [])
for gid in GroupOps.active_group_ids(doc):
var members := GroupOps.members_of(doc, int(gid))
if members.is_empty():
continue
var bau := false
var tris := 0
for i in members:
var oid := str((objects[i] as Dictionary).get("id", ""))
if BauKatalog.ist_bau(oid):
bau = true
tris += BauKatalog.dreiecke(oid)
if bau:
out.append({"gid": int(gid), "name": GroupState.display_name(doc, int(gid)),
"indizes": members, "dreiecke": tris})
return out
## Teil setzen oder ersetzen (snap = BuildSnap.berechne-Ergebnis, ok=true).
## Mutiert doc["objects"]; der Aufrufer macht snapshot/commit.
## gid: Zielgruppe (Gebäude); bei Symmetrie bekommt der Zwilling ein eigenes
## Spiegel-Gebäude (spiegel_gid, vom Aufrufer gemerkt: 1 Original-Gebäude ↔ 1 Spiegel).
## Rückgabe: {index, twin, ersetzt}.
static func setze_teil(doc: Dictionary, oid: String, snap: Dictionary, gid: int,
sym_mode: int, spiegel_gid := -1) -> Dictionary:
var objects: Array = doc["objects"]
var pos: Vector3 = snap["pos"]
var rot := float(snap["rot"])
var ersetze := int(snap.get("ersetze", -1))
if ersetze >= 0 and ersetze < objects.size():
var o: Dictionary = objects[ersetze]
o["id"] = oid
o["pos"] = [pos.x, pos.y, pos.z]
SvmapIO.rot_setzen(o, Vector3(0.0, rot, 0.0))
# Symmetrie: der Zwilling bekommt dasselbe Teil an seiner (gespiegelten) Stelle.
var key := SymmetryCore.sym_of(o)
if sym_mode != SymmetryCore.Mode.OFF and not key.is_empty():
var partner := SymmetryCore.find_partner_index(objects, key, ersetze)
if partner >= 0 and objects[partner] is Dictionary:
(objects[partner] as Dictionary)["id"] = oid
return {"index": ersetze, "twin": -1, "ersetzt": true}
var neu := {"id": oid, "pos": [pos.x, pos.y, pos.z], "scale": 1.0, "rot_y_deg": rot, "group": gid}
objects.append(neu)
var index := objects.size() - 1
var twin := -1
if sym_mode != SymmetryCore.Mode.OFF:
var res: Dictionary = ObjectOps.symmetry_place_twin(doc, index, sym_mode)
twin = int(res.get("twin", -1))
if twin >= 0 and spiegel_gid >= 0:
(objects[twin] as Dictionary)["group"] = spiegel_gid
return {"index": index, "twin": twin, "ersetzt": false}
## Eine ganze Gebäude-Gruppe als Indizes (für „Ganzes Gebäude wählen“).
static func gebaeude_von(doc: Dictionary, index: int) -> Array:
var objects: Array = doc.get("objects", [])
if index < 0 or index >= objects.size():
return []
var gid := GroupOps.group_id(objects[index])
if gid == GroupOps.UNGROUPED:
return [index]
return GroupOps.members_of(doc, gid)
# ----------------------------------------------------------- Demo / Test ----
## Kleines Haus über die echte Klick-Logik setzen (Fundament, Wände, Tür, Fenster,
## Giebel, Dach, Treppe) — für Demo und Tests. Jeder Schritt = BuildSnap-Treffer
## wie im Editor. Rückgabe: Gruppen-ID. history kann null sein.
static func haus_demo(doc: Dictionary, history: Variant, mitte: Vector3) -> int:
var gid := gruppe_neu(doc)
var terrain := func(_x: float, _z: float) -> float: return mitte.y
var before: Dictionary = history.snapshot(["objects", "group_names"]) if history != null else {}
# 2×2 Zellen (4×4 m): Zellmitten bei (±1, ±1) relativ zur Mitte.
var ox := floorf(mitte.x / 2.0) * 2.0
var oz := floorf(mitte.z / 2.0) * 2.0
var schritte := [
["bau_fundament", Vector3(ox - 1, mitte.y, oz - 1), 0.0],
["bau_fundament", Vector3(ox + 1, mitte.y, oz - 1), 0.0],
["bau_fundament", Vector3(ox - 1, mitte.y, oz + 1), 0.0],
["bau_fundament", Vector3(ox + 1, mitte.y, oz + 1), 0.0],
["bau_wand", Vector3(ox - 1, mitte.y, oz - 2), 0.0],
["bau_wand_fenster", Vector3(ox + 1, mitte.y, oz - 2), 0.0],
["bau_wand_tuer", Vector3(ox - 1, mitte.y, oz + 2), 0.0],
["bau_wand", Vector3(ox + 1, mitte.y, oz + 2), 0.0],
["bau_wand", Vector3(ox - 2, mitte.y, oz - 1), 90.0],
["bau_wand_scharte", Vector3(ox - 2, mitte.y, oz + 1), 90.0],
["bau_wand", Vector3(ox + 2, mitte.y, oz - 1), 90.0],
["bau_wand", Vector3(ox + 2, mitte.y, oz + 1), 90.0],
]
for st in schritte:
_demo_setzen(doc, str(st[0]), st[1] as Vector3, float(st[2]), gid, terrain)
# Decke + Dach auf die Wandoberkante (Klick auf die Oberkante = Ebene Wand oben)
var oben := mitte.y + 0.2 + BauKatalog.WAND_H
for cx in [-1.0, 1.0]:
for cz in [-1.0, 1.0]:
_demo_setzen(doc, "bau_dach_giebel", Vector3(ox + cx, oben, oz + cz), 0.0, gid, terrain)
if history != null:
history.commit("Haus gebaut (Demo)", before, history.snapshot(["objects", "group_names"]))
return gid
static func _demo_setzen(doc: Dictionary, oid: String, hit: Vector3, dreh: float, gid: int,
gelaende: Callable) -> void:
var teile := BuildSnap.sammle_teile(doc)
var snap := BuildSnap.berechne(oid, hit, dreh, teile, gelaende)
if bool(snap["ok"]):
setze_teil(doc, oid, snap, gid, SymmetryCore.Mode.OFF)
# --------------------------------------------------------------- Migration --
## Alte Sektion doc["gebaeude"] (Karte t_97ea59c5, 10 prozedurale Teile ohne
## Registry) → bau_*-Objekte in EINER Gruppe, dann Sektion entfernen. Beste
## Näherung: Teil-Ursprünge der alten Teile lagen an Wandanfang/Zellmitte, die
## neuen Bauteile an der Mitte der Grundfläche; Höhen (2 m → 3 m) ändern sich.
## Rückgabe: Anzahl übernommener Teile.
const _ALT := {
"fundament": "bau_fundament", "boden": "bau_boden", "wand": "bau_wand",
"tuer": "bau_wand_tuer", "fenster": "bau_wand_fenster",
"giebelwand": "bau_giebelwand", "giebelwand_rechts": "bau_giebelwand",
"dach_giebel": "bau_dach_giebel", "dach_pyramide": "bau_dach_pyramide",
"treppe": "bau_treppe",
}
static func migriere_gebaeude(doc: Dictionary) -> int:
if not doc.has("gebaeude"):
return 0
var alt: Variant = doc["gebaeude"]
doc.erase("gebaeude")
if not (alt is Array) or (alt as Array).is_empty():
return 0
if not doc.has("objects") or not (doc["objects"] is Array):
doc["objects"] = []
var objects: Array = doc["objects"]
var gid := gruppe_neu(doc)
var n := 0
for teil in alt:
if not (teil is Dictionary):
continue
var oid: String = _ALT.get(str((teil as Dictionary).get("id", "")), "")
if oid.is_empty():
continue
var p: Array = (teil as Dictionary).get("pos", [0.0, 0.0, 0.0])
var rot := float((teil as Dictionary).get("rot", 0.0))
var pos := Vector3(float(p[0]), float(p[1]), float(p[2]))
var local := Vector3.ZERO
match str((teil as Dictionary).get("id", "")):
"wand", "tuer", "fenster", "giebelwand", "giebelwand_rechts":
local = Vector3(1.0, 0.0, 0.0) # alt: Ursprung am Wandanfang
"treppe":
local = Vector3(2.0, 0.0, 0.0) # alt: 2 m lang ab Ursprung
"boden":
pos.y -= BauKatalog.DICKE_M # alt: Oberkante am Ursprung
pos += Basis(Vector3.UP, deg_to_rad(rot)) * local
objects.append({"id": oid, "pos": [pos.x, pos.y, pos.z], "scale": 1.0,
"rot_y_deg": fposmod(rot, 360.0), "group": gid})
n += 1
if n == 0:
GroupState.set_group_name(doc, gid, "")
return n

View file

@ -0,0 +1 @@
uid://bh4sybddgu3mt

View file

@ -1,454 +0,0 @@
extends RefCounted
class_name BuildOps
## Bauen-Modus (Karte t_97ea59c5): Modular-Builder-Pieces im App-Skelett.
##
## Architektur-Entscheidung (dokumentiert, Karte t_97ea59c5):
## 1. WRAPPER statt Plugin-UI: Das Godot-Editor-Plugin (addons/modular_builder,
## aktivierbar) bleibt unangetastet für den Godot-Editor. Die App bekommt
## einen EIGENEN „Bauen“-Modus, der dieselbe Runtime-Geometrie benutzt
## (modular_piece_geometry.gd + modular_building_detail.gd — CC0, reines
## Node3D/ArrayMesh, keine Editor-Abhängigkeit). Keine Godot-Editor-API
## leakt ins App-UI.
## 2. UNDO: EIN ActionHistory-Eintrag PRO BAUTEIL (Snapshot des Blocks
## "gebaeude" — wie Objekte platzieren). Der Block ist klein (Positionen +
## Preset-Punkte), tiefe Kopien sind billig. Kein Terrain-Stroke-Muster
## nötig, weil ein Bauteil eine atomare Klick-Aktion ist (kein Drag).
## 3. SPEICHERN: Die BAUTEILE sind die Quelle der Wahrheit und wandern als
## optionale Sektion "gebaeude" ins .svmap ( checksum-neutral: der
## Server-Loader prüft Pflichtblöcke und die Prüfsumme ÜBER alles —
## unbekannte Zusatzblöcke stören nicht). Das gebackene Mesh wird NICHT
## in der Datei abgelegt: Baken ist deterministisch aus den Teilen
## (Editor-Ansicht backt beim Laden, Match-Export in Karte 2 backt beim
## Export). Weiterbauen nach Laden bleibt damit möglich — Empfehlung
## „beim Speichern backen“ wäre nur Doppeldaten.
## 4. RASTER: 2-m-Modulraster. "ecke"-Teile (Wand/Treppe/Tür/Fenster/Giebel)
## rasten auf gerade Meter (Zellen-Ecken), "mitte"-Teile (Fundament,
## Boden, Dach) auf ungerade Meter (Zellen-Mitte) — so greifen Fundamente
## und Wände bündig ineinander. Drehen in 90°-Schritten bleibt rastergetreu.
##
## Teile-Format (im doc["gebaeude"]-Array):
## {"id": "wand", "pos": [x, y, z], "rot": 90.0,
## "punkte": [[x,y,z], ...], # lokale custom_points des Pieces
## "details": [{"typ": "tuer", "pos": [x,y,z], "w": 1.0, "h": 2.0}]}
## "punkte"/"details" kommen aus dem Preset (make_piece) — sie bleiben in der
## Datei, damit Laden → Weiterbauen → Baken identisch funktioniert.
const GEO_SCRIPT := "res://addons/modular_builder/modular_piece_geometry.gd"
const DETAIL_SCRIPT := "res://addons/modular_builder/modular_building_detail.gd"
# PieceType (addons/modular_builder/modular_piece_geometry.gd, Kopie der Werte —
# die Typen-Anmerkung Array[ModularBuilderPieceGeometry] im Plugin-Code braucht
# den globalen Klassen-Cache, der hier bewusst NICHT angefasst wird).
const T_FOUNDATION_SQUARE := 0
const T_FOUNDATION_TRIANGLE := 1
const T_FLOOR := 2
const T_WALL := 3
const T_GABLE_WALL := 5
const T_ROOF_GABLE := 6
const T_ROOF_SHED := 7
const T_GABLE_WALL_RIGHT := 8
const T_ROOF_PYRAMID := 9
const T_STAIRS := 10
const DETAIL_WINDOW := 0
const DETAIL_DOOR := 1
const GRID_M := 2.0 # Modulraster (unit_size des Plugins)
const WALL_H := 2.0 # Wandhöhe = unit_size
const SNAP_Y_M := 0.05 # Y-Snap: verhindert Float-Staub beim Stapeln
## Katalog: id → Preset. Reihenfolge = Anzeige-Reihenfolge im Bauen-Panel.
const KINDS := {
"fundament": {
"label": "Fundament", "info": "2×2 m, Basis", "typ": T_FOUNDATION_SQUARE, "raster": "mitte",
"farbe": Color(0.62, 0.62, 0.60),
"punkte": [[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [-1.0, 0.0, 1.0]],
},
"boden": {
"label": "Boden", "info": "2×2 m Platte", "typ": T_FLOOR, "raster": "mitte",
"farbe": Color(0.72, 0.60, 0.42),
"punkte": [[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [-1.0, 0.0, 1.0]],
},
"wand": {
"label": "Wand", "info": "2 m lang, 2 m hoch", "typ": T_WALL, "raster": "ecke",
"farbe": Color(0.88, 0.86, 0.80),
"punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
},
"tuer": {
"label": "Wand mit Tür", "info": "2 m, Öffnung 1×2 m", "typ": T_WALL, "raster": "ecke",
"farbe": Color(0.88, 0.86, 0.80),
"punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
"details": [{"typ": DETAIL_DOOR, "pos": [1.0, 1.0, 0.0], "w": 1.0, "h": 2.0}],
},
"fenster": {
"label": "Wand mit Fenster", "info": "2 m, Fenster 1×1 m", "typ": T_WALL, "raster": "ecke",
"farbe": Color(0.88, 0.86, 0.80),
"punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
"details": [{"typ": DETAIL_WINDOW, "pos": [1.0, 1.0, 0.0], "w": 1.0, "h": 1.0}],
},
"giebelwand": {
"label": "Giebelwand", "info": "Spitze in der Mitte", "typ": T_GABLE_WALL, "raster": "ecke",
"farbe": Color(0.88, 0.86, 0.80),
"punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "rise": 1.0,
},
"giebelwand_rechts": {
"label": "Giebel rechts", "info": "Spitze rechts", "typ": T_GABLE_WALL_RIGHT, "raster": "ecke",
"farbe": Color(0.88, 0.86, 0.80),
"punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "rise": 1.0,
},
"dach_giebel": {
"label": "Dach (Giebel)", "info": "2×2 m, Satteldach", "typ": T_ROOF_GABLE, "raster": "mitte",
"farbe": Color(0.66, 0.28, 0.20),
"punkte": [[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [-1.0, 0.0, 1.0]],
"rise": 1.0,
},
"dach_pyramide": {
"label": "Dach (Pyramide)", "info": "2×2 m, Spitze mitte", "typ": T_ROOF_PYRAMID, "raster": "mitte",
"farbe": Color(0.66, 0.28, 0.20),
"punkte": [[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [-1.0, 0.0, 1.0]],
"rise": 1.0,
},
"treppe": {
"label": "Treppe", "info": "2 m lang, 1 m hoch", "typ": T_STAIRS, "raster": "ecke",
"farbe": Color(0.55, 0.42, 0.30),
"punkte": [[0.0, 0.0, 0.0], [2.0, 1.0, 0.0]], "stufen": 10, "breite": 1.2,
},
}
# ------------------------------------------------------------------ Blöcke ---
## "gebaeude"-Block garantieren (alte Maps haben ihn nicht — Snapshot/Undo
## braucht den Block NUR, wenn er existiert oder beim ersten Teil entsteht).
static func ensure_block(doc: Dictionary) -> Array:
if not doc.has("gebaeude") or not (doc["gebaeude"] is Array):
doc["gebaeude"] = []
return doc["gebaeude"]
static func pieces(doc: Dictionary) -> Array:
var block = doc.get("gebaeude", [])
return block if block is Array else []
static func piece_count(doc: Dictionary) -> int:
return pieces(doc).size()
static func kind_label(kind_id: String) -> String:
var kind: Dictionary = KINDS.get(kind_id, {})
return str(kind.get("label", kind_id))
## Gesamt-Dreiecke aller Teile (grob, für Statusleiste/Budget-Blick).
static func tri_count(doc: Dictionary) -> int:
var total := 0
for teil in pieces(doc):
if not (teil is Dictionary):
continue
var mesh := piece_mesh(teil)
if mesh != null:
total += int(mesh.get_faces().size() / 3)
return total
# ------------------------------------------------------------------- Teile ---
## Ein Bauteil-Dict aus dem Preset bauen (Punkte/Details als Kopien —
## sonst aliasen sie mit dem Katalog-Konstanten).
static func make_piece(kind_id: String, pos: Vector3, rot_deg: float) -> Dictionary:
var kind: Dictionary = KINDS.get(kind_id, {})
if kind.is_empty():
return {}
var punkte: Array = []
for p in (kind.get("punkte", []) as Array):
punkte.append([float(p[0]), float(p[1]), float(p[2])])
var teil := {
"id": kind_id,
"pos": [pos.x, pos.y, pos.z],
"rot": rot_deg,
"punkte": punkte,
}
if kind.has("details"):
var details: Array = []
for d in (kind["details"] as Array):
details.append({
"typ": int(d["typ"]),
"pos": [float(d["pos"][0]), float(d["pos"][1]), float(d["pos"][2])],
"w": float(d["w"]),
"h": float(d["h"]),
})
teil["details"] = details
return teil
## 2-m-Raster-Snap je Teil-Typ ("ecke" = gerade Meter, "mitte" = ungerade Meter).
static func snap_xz(kind_id: String, x: float, z: float) -> Vector2:
var kind: Dictionary = KINDS.get(kind_id, {})
var half := GRID_M * 0.5
if str(kind.get("raster", "ecke")) == "mitte":
return Vector2(floorf(x / GRID_M) * GRID_M + half, floorf(z / GRID_M) * GRID_M + half)
return Vector2(roundf(x / GRID_M) * GRID_M, roundf(z / GRID_M) * GRID_M)
## Y-Snap: 5-cm-Raster gegen Float-Staub beim Stapeln.
static func snap_y(y: float) -> float:
return roundf(y / SNAP_Y_M) * SNAP_Y_M
## Komplett gerasterte Teil-Position aus einem Trefferpunkt (Boden oder
## Bauteil-Oberfläche): XZ aufs Modulraster, Y aufs 5-cm-Raster.
static func snapped_pos(kind_id: String, hit: Vector3) -> Vector3:
var xz := snap_xz(kind_id, hit.x, hit.z)
return Vector3(xz.x, snap_y(hit.y), xz.y)
## Mesh EINES Teils (lokal, Ursprung = Teil-Position). Nutzt die Plugin-
## Runtime-Geometrie — identische Meshes wie im Godot-Editor-Plugin.
static func piece_mesh(teil: Dictionary) -> ArrayMesh:
var kind_id := str(teil.get("id", ""))
var kind: Dictionary = KINDS.get(kind_id, {})
if kind.is_empty():
return null
var geo := Node3D.new()
geo.set_script(load(GEO_SCRIPT))
geo.set("piece_type", int(kind["typ"]))
geo.set("unit_size", WALL_H)
geo.set("thickness", 0.2)
if kind.has("rise"):
geo.set("rise", float(kind["rise"]))
if kind.has("stufen"):
geo.set("stair_count", int(kind["stufen"]))
geo.set("stair_width", float(kind["breite"]))
# custom_points: vom Preset (Datei) oder (Rückfall) aus dem Katalog.
var pts_src: Array = (teil.get("punkte", kind.get("punkte", [])) as Array)
if pts_src.size() >= 2:
var pts := PackedVector3Array()
for p in pts_src:
pts.append(Vector3(float(p[0]), float(p[1]), float(p[2])))
geo.set("custom_points", pts)
# Detail-Öffnungen (Tür/Fenster) als Kind-Knoten — die Wand-Geometrie
# schneidet sie aus (genau wie im Plugin).
for d in (teil.get("details", []) as Array):
var detail := Node3D.new()
detail.set_script(load(DETAIL_SCRIPT))
detail.set("detail_type", int(d["typ"]))
detail.set("width", float(d["w"]))
detail.set("height", float(d["h"]))
detail.position = Vector3(float(d["pos"][0]), float(d["pos"][1]), float(d["pos"][2]))
geo.add_child(detail)
var mesh: ArrayMesh = geo.call("_make_mesh")
geo.free()
return mesh
## Material je Teil-Typ (flache Farben, eigene CC0-freie Fallbacks statt der
## nicht-CC0-Demo-Texturen — Sly-Look kommt später als echtes Texturenpaket).
static func piece_material(kind_id: String, cache: Dictionary) -> StandardMaterial3D:
if cache.has(kind_id):
return cache[kind_id]
var mat := StandardMaterial3D.new()
mat.albedo_color = KINDS.get(kind_id, {}).get("farbe", Color(0.8, 0.8, 0.8))
mat.roughness = 0.9
cache[kind_id] = mat
return mat
# ------------------------------------------------------------------- Baken ---
## ALLE Teile in WELT-Koordinaten backen (für Kollisions-Raycast + späterer
## Match-Export in Karte 2). Rückgabe: faces (Dreiecke, Welt-Koordinaten) +
## offsets[i] = Index des ERSTEN Dreiecks von Teil i im faces-Array.
static func bake_faces(doc: Dictionary) -> Dictionary:
var faces := PackedVector3Array()
var offsets: Array = []
for teil in pieces(doc):
if not (teil is Dictionary):
offsets.append(-1)
continue
offsets.append(faces.size() / 3)
var mesh := piece_mesh(teil)
if mesh == null:
continue
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var xf := Transform3D(
Basis(Vector3.UP, deg_to_rad(float(teil.get("rot", 0.0)))),
Vector3(float(pos[0]), float(pos[1]), float(pos[2])))
for v in mesh.get_faces():
faces.append(xf * v)
return {"faces": faces, "offsets": offsets}
## Aßerhalb-Test-loser AABB aller Teile (für Auswahl-Rahmen im Viewport).
static func piece_aabb(teil: Dictionary) -> AABB:
var mesh := piece_mesh(teil)
if mesh == null:
return AABB()
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var xf := Transform3D(
Basis(Vector3.UP, deg_to_rad(float(teil.get("rot", 0.0)))),
Vector3(float(pos[0]), float(pos[1]), float(pos[2])))
var aabb := AABB()
var first := true
for v in mesh.get_faces():
var wp := xf * v
if first:
aabb = AABB(wp, Vector3.ZERO)
first = false
else:
aabb = aabb.expand(wp)
return aabb
# ---------------------------------------------------------------- Ray-Pick ---
## Analytischer Ray-Treffer auf den gebackenen Flächen (Möller–Trumbore) —
## kein Physik-Space nötig (SubViewport ohne Physik-Tick, headless-testbar).
## Rückgabe: {"piece": Teil-Index, "punkt": Vector3} oder {}.
## max_dist ist nur pro Aufruf begrenzbar (Default: unbegrenzt) — ein Cap von
## 500 m war hier früher die Ursache dafür, dass Umschalt+Klick im Default-
## Framing (~614 m Kamera-Distanz) schlicht nichts traf. Performance-unkritisch:
## der Tri-Loop läuft über alle gebackenen Faces, egal wie der Cap steht.
static func ray_pick(baked: Dictionary, origin: Vector3, dir: Vector3, max_dist: float = INF) -> Dictionary:
var faces: PackedVector3Array = baked.get("faces", PackedVector3Array())
var offsets: Array = baked.get("offsets", [])
var tri_count := int(faces.size() / 3)
var best_t := max_dist
var best_tri := -1
for i in tri_count:
var a := faces[i * 3]
var b := faces[i * 3 + 1]
var c := faces[i * 3 + 2]
var t := _ray_tri(origin, dir, a, b, c)
if t >= 0.0 and t < best_t:
best_t = t
best_tri = i
if best_tri < 0:
return {}
var piece := -1
for j in offsets.size():
var off := int(offsets[j])
if off >= 0 and best_tri >= off:
piece = j # offsets aufsteigend → letzter Treffer gewinnt
return {"piece": piece, "punkt": origin + dir * best_t}
static func _ray_tri(origin: Vector3, dir: Vector3, a: Vector3, b: Vector3, c: Vector3) -> float:
var e1 := b - a
var e2 := c - a
var p := dir.cross(e2)
var det := e1.dot(p)
if absf(det) < 0.000001:
return -1.0
var inv := 1.0 / det
var tv := origin - a
var u := tv.dot(p) * inv
if u < 0.0 or u > 1.0:
return -1.0
var q := tv.cross(e1)
var v := dir.dot(q) * inv
if v < 0.0 or u + v > 1.0:
return -1.0
return e2.dot(q) * inv
# ------------------------------------------------------ Symmetrie (M4) ------
## Karte t_ffa207b1 (Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md): Im
## Symmetrie-Modus bekommt jedes gesetzte Bau-Teil automatisch einen Zwilling
## auf der anderen Kartenseite — gleiche Snap-Regeln (Position wird 1:1
## gespiegelt, Parität „ecke"/„mitte" bleibt bei zentrierter Karte erhalten),
## gleiche kind_id, gleiche Punkte/Details. Gekoppelt über das `sym`-Feld aus
## M1 (SymmetryCore). ALLE Funktionen sind pure Statics → headless testbar.
## Aufrufer ist build_tool.gd (dort leben die Klick-Mutationen); die Historie
## snapshotet den Block „gebaeude" um den Aufruf herum → EIN Undo-Schritt
## nimmt Original UND Zwilling zurück.
## V9-Grenzen (M4): dieselbe Regel wie der Loader-Kern (svmap_io.gd,
## _inside_bounds mit ±1 m Toleranz). true = gespiegeltes Teil darf gespeichert
## werden; false → Aufrufer meldet V9 im Status statt still zu speichern.
static func inside_match_bounds(pos: Vector3, doc: Dictionary) -> bool:
var bounds: Dictionary = doc.get("bounds", {})
if bounds.is_empty():
return true
return SvmapIO._inside_bounds([pos.x, pos.y, pos.z], bounds)
## Zwilling eines frisch gesetzten Bauteils anlegen (Symmetrie an). `teil` muss
## bereits im doc hängen (der sym-Link wird auf beide Einträge gesetzt).
## Rückgabe: Klartext-Zusatz für die Statuszeile ("" = still gespiegelt).
static func place_mirrored(doc: Dictionary, teil: Dictionary, mode: int) -> String:
if mode == SymmetryCore.Mode.OFF:
return ""
var label := kind_label(str(teil.get("id", "")))
var pos := _pos_of(teil)
var center := SymmetryCore.map_center(doc)
if SymmetryCore.maps_to_self(mode, pos, center):
return "%s liegt auf der Symmetrie-Mitte — nicht gedoppelt." % label
var twin := teil.duplicate(true) # tief: punkte/details dürfen nicht aliasen
var mirror_pos := SymmetryCore.mirror_point(mode, pos, center)
twin["pos"] = [mirror_pos.x, mirror_pos.y, mirror_pos.z]
twin["rot"] = SymmetryCore.mirror_rot_y(mode, float(teil.get("rot", 0.0)))
if not inside_match_bounds(mirror_pos, doc):
return "❌ V9: Gespiegeltes %s läge außerhalb der Match-Grenzen — nur das Original wurde gesetzt." % label
var key := SymmetryCore.next_sym_key(doc)
SymmetryCore.set_sym(teil, key)
SymmetryCore.set_sym(twin, key)
ensure_block(doc).append(twin)
return ""
## Zwilling nach Drehen/Anheben/Verschieben/Auf-Boden neu ausrichten: er bekommt
## immer die gespiegelte Form der NEUEN Transform des Originals (Drehung über
## mirror_rot_y, Position über mirror_point, y bleibt Skalar). Teile ohne
## sym-Partner (z. B. bei ausgeschalteter Symmetrie gebaut) bleiben unberührt.
## Läge der gespiegelte Zielpunkt außerhalb der V9-Grenzen, bleibt der Zwilling
## UNVERÄNDERT und die Meldung geht in den Status. Rückgabe: Klartext ("" = ok).
static func update_mirrored(doc: Dictionary, index: int, mode: int) -> String:
if mode == SymmetryCore.Mode.OFF:
return ""
var list := pieces(doc)
if index < 0 or index >= list.size() or not (list[index] is Dictionary):
return ""
var teil := list[index] as Dictionary
var key := SymmetryCore.sym_of(teil)
if key.is_empty():
return ""
var partner_idx := SymmetryCore.find_partner_index(list, key, index)
if partner_idx < 0 or not (list[partner_idx] is Dictionary):
return ""
var mirror_pos := SymmetryCore.mirror_point(mode, _pos_of(teil), SymmetryCore.map_center(doc))
if not inside_match_bounds(mirror_pos, doc):
return "❌ V9: Gespiegeltes Bauteil läge außerhalb der Match-Grenzen — Zwilling nicht verschoben."
var twin := list[partner_idx] as Dictionary
twin["pos"] = [mirror_pos.x, mirror_pos.y, mirror_pos.z]
twin["rot"] = SymmetryCore.mirror_rot_y(mode, float(teil.get("rot", 0.0)))
return ""
## Bauteil löschen — bei eingeschalteter Symmetrie den sym-Partner mit
## (entfernen → beide weg). Index-Verschiebung sicher: der HÖHERE Index wird
## zuerst entfernt. Modus AUS = exakt das alte Verhalten (nur ein Teil weg).
## Rückgabe: Klartext-Status ("" = nur das eine Teil entfernt).
static func remove_mirrored(doc: Dictionary, index: int, mode: int) -> String:
var list := pieces(doc)
if index < 0 or index >= list.size() or not (list[index] is Dictionary):
return ""
var teil := list[index] as Dictionary
var partner_idx := -1
if mode != SymmetryCore.Mode.OFF:
partner_idx = SymmetryCore.find_partner_index(list, SymmetryCore.sym_of(teil), index)
if partner_idx < 0:
list.remove_at(index)
return ""
if partner_idx > index:
list.remove_at(partner_idx)
list.remove_at(index)
else:
list.remove_at(index)
list.remove_at(partner_idx)
return "Der gespiegelte Zwilling wurde mit entfernt."
static func _pos_of(teil: Dictionary) -> Vector3:
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
return Vector3(float(pos[0]), float(pos[1]), float(pos[2]))

View file

@ -1 +0,0 @@
uid://buoucxvl5d35c

167
src/editor/build_snap.gd Normal file
View file

@ -0,0 +1,167 @@
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"
## 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
static func berechne(oid: String, hit: Vector3, dreh: float, teile: Array,
gelaende: Callable, hit_ist_objekt := false, kamera_dir := Vector3.ZERO) -> 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: Zelle auf der Kameraseite wählen (Klick auf die
# Wandoberkante liegt genau auf der Zellgrenze).
if hit_ist_objekt and art != "kante" and 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":
mitte = Vector2(floorf(p.x / r) * r + r * 0.5, floorf(p.y / r) * r + r * 0.5)
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 fx := p.x / r
var fz := p.y / r
var h_mitte := Vector2((floorf(fx) + 0.5) * r, roundf(fz) * r) # Kante entlang X
var v_mitte := Vector2(roundf(fx) * r, (floorf(fz) + 0.5) * r) # Kante entlang Z
if p.distance_squared_to(h_mitte) <= p.distance_squared_to(v_mitte):
mitte = h_mitte
rot = 0.0
else:
mitte = v_mitte
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 BauKatalog.ist_traeger(troid):
ebenen.append(top)
var ebene := _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": ""}
## 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

View file

@ -0,0 +1 @@
uid://irnbwhk0eijq

View file

@ -1,406 +0,0 @@
extends Node3D
## Bauen-Werkzeug (Karte t_97ea59c5): Modular-Builder-Pieces in der App setzen —
## Fundament/Wand/Dach/Treppe/Tür/Fenster auf einem 2-m-Modulraster.
##
## Bedienung (Chef-Grundsatz Sly: easy, deutsch):
## Bauteil rechts anklicken → Vorschau folgt dem Cursor (rastet aufs Raster)
## Linksklick → Bauteil setzen (auch AUF andere Teile — zum
## Stapeln: Wand oben drauf, Dach auf die Wände)
## Umschalt+Klick → gesetztes Bauteil AUSWÄHLEN (orange Rahmen)
## R → Vorschau drehen (90°) / Auswahl drehen
## Bild↑/Bild↓ → Auswahl anheben/absenken (0,2 m — Wände aufs
## Fundament setzen)
## Entf → Auswahl löschen
## ESC → Auswahl weg, nochmal = Werkzeug beenden
## (Rechte Maustaste = immer Flug, t_2470de89)
##
## UNDO: EIN Rückgängig-Schritt PRO Bauteil (ActionHistory, Block "gebaeude").
const MODE_HELP := "Linksklick: Bauteil setzen · Umschalt+Klick: Bauteil auswählen · R: drehen (90°) ·\nBild↑/↓: anheben/absenken · Entf: löschen · Mittlere Maustaste: Kamera · Strg+Z: zurück"
var main # Hauptfenster (script-erweitert, Variant)
var camera: Camera3D
var view_builder # Welt-Bauer (script-erweitert, Variant)
var interaction # Objekt-Interaktion (für Auswahl-Entkoppelung)
var active := false
var kind_id := "fundament"
var ghost_rot := 0.0 # 90°-Schritte
var selected_index := -1
var _ghost: MeshInstance3D
var _cursor_valid := false
var _cursor := Vector3.ZERO
# Terrain-Cache für den Boden-Raycast (lesen)
var _heights := PackedFloat32Array()
var _res := 0
var _sx := 0.0
var _sz := 0.0
func setup(p_main, p_camera: Camera3D, p_builder, p_interaction) -> void:
main = p_main
camera = p_camera
view_builder = p_builder
interaction = p_interaction
_ensure_ghost()
func _ensure_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost):
return
_ghost = MeshInstance3D.new()
_ghost.visible = false
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.75, 0.2, 0.45)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.emission_enabled = true
mat.emission = Color(1.0, 0.6, 0.1) * 0.3
_ghost.material_override = mat
add_child(_ghost)
# ------------------------------------------------------------ Aktivierung ---
func activate() -> bool:
active = true
selected_index = -1
ghost_rot = 0.0
_cursor_valid = false
_refresh_from_doc()
_rebuild_ghost_mesh()
if view_builder != null:
view_builder.call("set_gebaeude_selected", -1)
return true
func deactivate() -> void:
active = false
selected_index = -1
if _ghost != null and is_instance_valid(_ghost):
_ghost.visible = false
if view_builder != null:
view_builder.call("set_gebaeude_selected", -1)
## Nach Undo/Redo/Laden/Neu: Cache frisch, Auswahl nur halten, wenn das Teil
## noch existiert.
func refresh_from_doc() -> void:
_refresh_from_doc()
_rebuild_ghost_mesh()
if selected_index >= 0 and selected_index >= BuildOps.piece_count(main.doc):
selected_index = -1
if active:
view_builder.call("set_gebaeude_selected", selected_index)
func _refresh_from_doc() -> void:
var terrain: Dictionary = main.doc.get("terrain", {})
_res = int(terrain.get("resolution", 0))
_sx = float(terrain.get("size_x_m", 0.0))
_sz = float(terrain.get("size_z_m", 0.0))
if _res >= 2 and _sx > 0.0 and _sz > 0.0:
_heights = SvmapIO.decode_heights(terrain)
else:
_heights = PackedFloat32Array()
# ------------------------------------------------------------- Werkzeug-UI --
func set_kind(next_kind: String) -> void:
if not BuildOps.KINDS.has(next_kind):
return
kind_id = next_kind
selected_index = -1
if view_builder != null:
view_builder.call("set_gebaeude_selected", -1)
_rebuild_ghost_mesh()
if active:
main.call("_set_status", "%s ausgewählt — Linksklick setzt es aufs Raster (auch auf andere Bauteile zum Stapeln). Umschalt+Klick wählt gesetzte Teile aus." % BuildOps.kind_label(kind_id))
func _rebuild_ghost_mesh() -> void:
if _ghost == null or not is_instance_valid(_ghost):
return
var probe := BuildOps.make_piece(kind_id, Vector3.ZERO, 0.0)
_ghost.mesh = BuildOps.piece_mesh(probe)
# --------------------------------------------------------------- Eingaben ---
func _unhandled_input(event: InputEvent) -> void:
if not active:
return
if event is InputEventMouseMotion:
_update_cursor((event as InputEventMouseMotion).position)
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed:
if mb.shift_pressed:
_select_at(mb.position)
else:
_place_piece()
get_viewport().set_input_as_handled()
# Rechte Maustaste: absichtlich NICHT behandelt (Karte t_2470de89) —
# sie gehört mit absolutem Vorrang dem Freelook-Flug. Deselect/Werkzeug
# beenden läuft über ESC (main.gd), die Auswahl bleibt im Flug erhalten.
func _update_cursor(viewport_pos: Vector2) -> void:
var hit: Variant = _ray_hit(viewport_pos)
if hit == null:
_cursor_valid = false
if _ghost != null and is_instance_valid(_ghost):
_ghost.visible = false
return
_cursor_valid = true
_cursor = hit as Vector3
if _ghost != null and is_instance_valid(_ghost):
_ghost.position = BuildOps.snapped_pos(kind_id, _cursor)
_ghost.rotation.y = deg_to_rad(ghost_rot)
_ghost.visible = true
## Strahl → Trefferpunkt: ZUERST auf die gebackenen Bauteile (Stapeln!),
## sonst auf das Terrain. Entfernung entscheidet.
func _ray_hit(viewport_pos: Vector2) -> Variant:
var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos)
var best: Variant = null
var best_t := INF
var baked: Dictionary = view_builder.call("gebaeude_baked")
if not baked.get("faces", PackedVector3Array()).is_empty():
var pick := BuildOps.ray_pick(baked, origin, dir)
if not pick.is_empty():
best_t = (origin - (pick["punkt"] as Vector3)).length()
best = pick["punkt"]
var terrain: Dictionary = main.doc.get("terrain", {})
if _res >= 2 and _heights.size() >= _res * _res:
var ground: Variant = TerrainField.raycast(_heights, _res, _sx, _sz, origin, dir)
if ground != null:
var t := (origin - (ground as Vector3)).length()
if t < best_t:
best = ground
return best
# ------------------------------------------------------------ Platzieren ----
func _place_piece() -> void:
if not _cursor_valid:
main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
return
var before: Dictionary = main.history.snapshot(["gebaeude"])
var pos: Vector3 = BuildOps.snapped_pos(kind_id, _cursor)
var teil := BuildOps.make_piece(kind_id, pos, ghost_rot)
if teil.is_empty():
return
BuildOps.ensure_block(main.doc).append(teil)
var label := BuildOps.kind_label(kind_id)
# Symmetrie (M4, t_ffa207b1): Zwilling anlegen — innerhalb desselben
# Undo-Schritts (Snapshot läuft um beide Mutationen herum).
var mirror_msg: String = BuildOps.place_mirrored(main.doc, teil, _sym_mode())
main.history.commit("%s gesetzt" % label, before, main.history.snapshot(["gebaeude"]))
_after_build_change()
main.call("_set_status", "%s gesetzt (%d Bauteile).%s Weiter klicken zum Bauen — R dreht, Strg+Z nimmt das letzte Teil zurück." % [
label, BuildOps.piece_count(main.doc),
"" if mirror_msg.is_empty() else " " + mirror_msg])
func _select_at(viewport_pos: Vector2) -> void:
var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos)
var baked: Dictionary = view_builder.call("gebaeude_baked")
var pick := BuildOps.ray_pick(baked, origin, dir)
if pick.is_empty():
selected_index = -1
view_builder.call("set_gebaeude_selected", -1)
main.call("_set_status", "Kein Bauteil getroffen — Umschalt+Klick direkt AUF ein Bauteil (Wand, Dach …).")
return
selected_index = int(pick["piece"])
view_builder.call("set_gebaeude_selected", selected_index)
var label := _piece_label(selected_index)
main.call("_set_status", "%s ausgewählt — R drehen · Bild↑/↓ anheben · Entf löschen · Esc: Auswahl weg." % label)
func _piece_label(idx: int) -> String:
var list := BuildOps.pieces(main.doc)
if idx < 0 or idx >= list.size():
return "Bauteil"
return "%s (Teil %d von %d)" % [BuildOps.kind_label(str((list[idx] as Dictionary).get("id", "?"))), idx + 1, list.size()]
func rotate_selected() -> void:
if selected_index < 0:
return
var list := BuildOps.pieces(main.doc)
if selected_index >= list.size():
return
var teil: Dictionary = list[selected_index]
var before: Dictionary = main.history.snapshot(["gebaeude"])
teil["rot"] = wrapf(float(teil.get("rot", 0.0)) + 90.0, 0.0, 360.0)
# Symmetrie (M4): Zwilling bekommt die gespiegelte neue Drehung.
BuildOps.update_mirrored(main.doc, selected_index, _sym_mode())
main.history.commit("%s gedreht (90°)" % _piece_label(selected_index),
before, main.history.snapshot(["gebaeude"]), "bauen-rot:%d" % selected_index)
_after_build_change()
view_builder.call("set_gebaeude_selected", selected_index)
## Auswahl anheben/absenken (0,2 m — passend zur Fundament-Dicke), merge-gekoppelt.
func lift_selected(delta_m: float) -> void:
if selected_index < 0:
return
var list := BuildOps.pieces(main.doc)
if selected_index >= list.size():
return
var teil: Dictionary = list[selected_index]
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var before: Dictionary = main.history.snapshot(["gebaeude"])
teil["pos"] = [float(pos[0]), BuildOps.snap_y(float(pos[1]) + delta_m), float(pos[2])]
# Symmetrie (M4): Zwilling heben/absenken mit (y bleibt gespiegelt identisch).
BuildOps.update_mirrored(main.doc, selected_index, _sym_mode())
main.history.commit("%s %s" % [_piece_label(selected_index),
"angehoben" if delta_m > 0.0 else "abgesenkt"],
before, main.history.snapshot(["gebaeude"]), "bauen-lift:%d" % selected_index)
_after_build_change()
view_builder.call("set_gebaeude_selected", selected_index)
## Pfeiltasten (Karte t_55257792): Bauteil im 2-m-Modulraster verschieben —
## EINE Zelle pro Tastendruck. Das exakte ±2-m-Delta erhält die Raster-
## Ausrichtung ("ecke" = gerade Meter, "mitte" = ungerade Meter bleibt), Y
## bleibt (Höhe ändert die Pfeiltaste nicht). EIN Undo-Schritt pro Tastendruck.
func nudge_selected(delta: Vector3) -> void:
if selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — Umschalt+Klick wählt ein Bauteil aus, dann verschieben die Pfeiltasten es im 2-m-Raster.")
return
var list := BuildOps.pieces(main.doc)
if selected_index >= list.size():
selected_index = -1
return
var teil: Dictionary = list[selected_index]
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var before: Dictionary = main.history.snapshot(["gebaeude"])
var nx := float(pos[0]) + delta.x
var nz := float(pos[2]) + delta.z
teil["pos"] = [nx, float(pos[1]), nz]
# Symmetrie (M4): Zwilling wandert gespiegelt mit (Raster-Parität bleibt).
BuildOps.update_mirrored(main.doc, selected_index, _sym_mode())
main.history.commit("%s verschoben (Pfeiltaste)" % _piece_label(selected_index),
before, main.history.snapshot(["gebaeude"]))
_after_build_change()
view_builder.call("set_gebaeude_selected", selected_index)
main.call("_set_status", "%s — 2 m pro Tastendruck, Raster bleibt eingehalten." % _piece_label(selected_index))
## Strg+Bild↑ (Karte t_55257792): Bauteil direkt auf das Terrain absetzen
## (Fußpunkt = Terrain-Höhe, auf 5 cm gerastet). EIN Undo-Schritt.
func snap_selected_to_floor() -> void:
if selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — Umschalt+Klick wählt ein Bauteil aus, dann setzt Strg+Bild↑ es auf den Boden.", true)
return
var list := BuildOps.pieces(main.doc)
if selected_index >= list.size():
selected_index = -1
return
var teil: Dictionary = list[selected_index]
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var ground := SvmapDocument.terrain_height_at(main.doc, float(pos[0]), float(pos[2]))
var target := BuildOps.snap_y(ground)
if absf(float(pos[1]) - target) < 0.0001:
main.call("_set_status", "%s liegt schon auf dem Boden." % _piece_label(selected_index))
return
var before: Dictionary = main.history.snapshot(["gebaeude"])
teil["pos"] = [float(pos[0]), target, float(pos[2])]
# Symmetrie (M4): Zwilling mit absenken (y bleibt gespiegelt identisch).
BuildOps.update_mirrored(main.doc, selected_index, _sym_mode())
main.history.commit("%s auf den Boden gesetzt" % _piece_label(selected_index),
before, main.history.snapshot(["gebaeude"]))
_after_build_change()
view_builder.call("set_gebaeude_selected", selected_index)
main.call("_set_status", "%s auf den Boden gesetzt — Strg+Z nimmt es zurück." % _piece_label(selected_index))
func delete_selected() -> void:
if selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — Umschalt+Klick wählt ein Bauteil aus (Entf löscht es dann).", true)
return
var list: Array = BuildOps.pieces(main.doc)
if selected_index >= list.size():
selected_index = -1
return
var label := _piece_label(selected_index)
var before: Dictionary = main.history.snapshot(["gebaeude"])
# Symmetrie (M4): bei eingeschalteter Symmetrie den sym-Zwilling mit löschen.
var mirror_msg: String = BuildOps.remove_mirrored(main.doc, selected_index, _sym_mode())
main.history.commit("%s gelöscht" % label, before, main.history.snapshot(["gebaeude"]))
selected_index = -1
_after_build_change()
main.call("_set_status", "%s — Strg+Z holt es zurück.%s" % [
label.replace(" (Teil", " gelöscht (Teil"),
"" if mirror_msg.is_empty() else " " + mirror_msg])
func _after_build_change() -> void:
main.call("_after_structure_change")
if selected_index >= 0 and selected_index < BuildOps.piece_count(main.doc):
view_builder.call("set_gebaeude_selected", selected_index)
## R-Taste: Auswahl drehen — sonst die Vorschau (90°-Raster).
func rotate_input() -> void:
if selected_index >= 0:
rotate_selected()
return
ghost_rot = wrapf(ghost_rot + 90.0, 0.0, 360.0)
if _ghost != null and is_instance_valid(_ghost):
_ghost.rotation.y = deg_to_rad(ghost_rot)
main.call("_set_status", "Vorschau gedreht auf %.0f° — Linksklick setzt das Bauteil so." % ghost_rot)
## Teil per Baum/Index anwählen (ohne Maus — auch für Demos/Tests).
func select_index(idx: int) -> void:
if idx < 0 or idx >= BuildOps.piece_count(main.doc):
selected_index = -1
view_builder.call("set_gebaeude_selected", -1)
return
selected_index = idx
view_builder.call("set_gebaeude_selected", idx)
main.call("_set_status", "%s ausgewählt — R drehen · Bild↑/↓ anheben · Entf löschen · Rechtsklick: Auswahl weg." % _piece_label(idx))
# ---------------------------------------------------------- Demo/Headless ---
## Demo-Hook: Bauteil programmatisch setzen (inkl. History) — exakt wie Klick.
func demo_place(p_kind: String, pos: Vector3, rot_deg: float = 0.0) -> Dictionary:
var before: Dictionary = main.history.snapshot(["gebaeude"])
var teil := BuildOps.make_piece(p_kind, pos, rot_deg)
BuildOps.ensure_block(main.doc).append(teil)
# Symmetrie (M4): Zwilling im selben Undo-Schritt (wie beim Klick).
BuildOps.place_mirrored(main.doc, teil, _sym_mode())
main.history.commit("%s gesetzt" % BuildOps.kind_label(p_kind),
before, main.history.snapshot(["gebaeude"]))
return teil
## Aktiver Symmetrie-Modus aus main (M1-Schalter). Defensive über main.get():
## Mock-Mains (Tests) haben sym_mode nicht — dann gilt OFF (altes Verhalten),
## statt mit SCRIPT ERROR abzustürzen (Lektion: SCRIPT ERROR in -s-Suiten = rot).
func _sym_mode() -> int:
var v: Variant = main.get("sym_mode") if main != null else null
return SymmetryCore.Mode.OFF if v == null else int(v)
## Demo-Hook: Ghost an Position parken (für Screenshots ohne Maus).
func demo_park_ghost(p_kind: String, pos: Vector3, rot_deg: float = 0.0) -> void:
set_kind(p_kind)
_cursor = pos
_cursor_valid = true
if _ghost != null and is_instance_valid(_ghost):
_ghost.position = BuildOps.snapped_pos(p_kind, pos)
_ghost.rotation.y = deg_to_rad(rot_deg)
_ghost.visible = true

View file

@ -1 +0,0 @@
uid://vukeqokihvko

View file

@ -46,6 +46,13 @@ var _ghost_mi: MeshInstance3D # Kind-Mesh, gehoben um half_height —
# exakt wie der Viewport-Bauer (map_view_builder). # exakt wie der Viewport-Bauer (map_view_builder).
var _ghost_active := false # Ghost sichtbar weil Cursor über der Map ist var _ghost_active := false # Ghost sichtbar weil Cursor über der Map ist
# Bauen-Modus (Bauteile bau_*): der Ghost rastet über BuildSnap (Kante/Zelle/
# Ebene), jeder Klick setzt ein Teil in das laufende Gebäude (= Gruppe).
var build_group_id := -1 # laufendes Gebäude; -1 = nächstes Teil beginnt ein neues
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)
# 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
# die flache Basis-Ebene. Dekodiert einmal pro Map-Änderung, nicht pro Mausbewegung. # die flache Basis-Ebene. Dekodiert einmal pro Map-Änderung, nicht pro Mausbewegung.
@ -217,12 +224,19 @@ func start_placement(oid: String) -> void:
place_rot_deg = 0.0 place_rot_deg = 0.0
place_tilt_x = 0.0 place_tilt_x = 0.0
place_tilt_z = 0.0 place_tilt_z = 0.0
_place_series = false # U5: jeder frische Start startet im Einmal-Modus _place_series = BauKatalog.ist_bau(oid) # U5: Einmal-Modus; Bauteile = Serie (Esc beendet)
_ghost_active = false _ghost_active = false
_build_last = {}
_build_hit = {}
_build_hint = ""
refresh_terrain_cache() # Cursor trifft das GELÄNDE — Cache muss frisch sein refresh_terrain_cache() # Cursor trifft das GELÄNDE — Cache muss frisch sein
_ensure_ghost() _ensure_ghost()
_ensure_ghost_visible() _ensure_ghost_visible()
var label: String = main.object_label(oid) var label: String = main.object_label(oid)
if BauKatalog.ist_bau(oid):
main.call("_set_status", "%s: Maus über die Karte — die Vorschau rastet auf Zelle/Kante ein. Klick setzt, R dreht, Esc beendet. Klick auf die Wandseite ersetzt das Teil (z. B. Tür), Klick auf die Oberkante stapelt." % label)
main.call("_highlight_library", oid)
return
var hinweis := "" if not GlbMeshStore.fehlt(oid) \ var hinweis := "" if not GlbMeshStore.fehlt(oid) \
else " (Platzhalter — echte GLB fehlt für %s)" % oid else " (Platzhalter — echte GLB fehlt für %s)" % oid
main.call("_set_status", "%s%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, ESC bricht ab (Rechte Maustaste = Flug)." % [label, hinweis]) main.call("_set_status", "%s%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, ESC bricht ab (Rechte Maustaste = Flug)." % [label, hinweis])
@ -236,6 +250,8 @@ func cancel_placement() -> void:
place_oid = "" place_oid = ""
_place_series = false _place_series = false
_ghost_active = false _ghost_active = false
_build_last = {}
_build_hit = {}
main.call("_highlight_library", "") main.call("_highlight_library", "")
main.call("_base_tool_status") main.call("_base_tool_status")
@ -303,6 +319,9 @@ func _ensure_ghost_visible() -> void:
func _ghost_rotation_anwenden() -> void: func _ghost_rotation_anwenden() -> void:
if _ghost == null or not is_instance_valid(_ghost): if _ghost == null or not is_instance_valid(_ghost):
return return
if BauKatalog.ist_bau(place_oid):
_bau_ghost_neu() # Drehung ergibt sich aus Kante/Zelle + R
return
_ghost.rotation_degrees = Vector3(place_tilt_x, place_rot_deg, place_tilt_z) _ghost.rotation_degrees = Vector3(place_tilt_x, place_rot_deg, place_tilt_z)
@ -323,6 +342,9 @@ func _placement_grid_enabled() -> bool:
func _ghost_update(viewport_pos: Vector2) -> void: func _ghost_update(viewport_pos: Vector2) -> void:
if BauKatalog.ist_bau(place_oid):
_bau_ghost_update(viewport_pos)
return
var p: Variant = _ground_point(viewport_pos) var p: Variant = _ground_point(viewport_pos)
if p == null: if p == null:
_ghost_active = false _ghost_active = false
@ -337,6 +359,9 @@ func _ghost_update(viewport_pos: Vector2) -> void:
func _place_object(viewport_pos: Vector2, mb_shift_held := false) -> void: func _place_object(viewport_pos: Vector2, mb_shift_held := false) -> void:
if BauKatalog.ist_bau(place_oid):
_place_bau(viewport_pos)
return
var p: Variant = _ground_point(viewport_pos) var p: Variant = _ground_point(viewport_pos)
if p == null: if p == null:
main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true) main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
@ -415,6 +440,184 @@ func _place_object(viewport_pos: Vector2, mb_shift_held := false) -> void:
main.call("_set_status", "%s gesetzt und ausgewählt. Ziehen verschiebt, R oder Mausrad dreht, Entf löscht, Strg+Z nimmt es zurück." % main.object_label(placed_oid)) main.call("_set_status", "%s gesetzt und ausgewählt. Ziehen verschiebt, R oder Mausrad dreht, Entf löscht, Strg+Z nimmt es zurück." % main.object_label(placed_oid))
# ----------------------------------------------------- Bauen (bau_*) -----
## Strahl → Treffer auf Gelände ODER einem gesetzten Objekt (Bauteile müssen
## sich auf Wänden/Böden stapeln lassen). Rückgabe {punkt, obj (-1 = Gelände), dir}
## oder {} bei Leerlauf.
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)
var best_t := INF
var out := {}
if ground != null:
best_t = (ground as Vector3).distance_to(origin)
out = {"punkt": ground, "obj": -1, "dir": dir}
for marker in view_builder.object_markers:
if marker == null or not is_instance_valid(marker):
continue
var center: Vector3 = marker.global_position + Vector3.UP * float(marker.get_meta("half_height", 1.0))
var to_center := center - origin
var tca := to_center.dot(dir)
if tca <= 0.0:
continue
var radius := float(marker.get_meta("pick_radius", 1.0))
if to_center.length_squared() - tca * tca > radius * radius:
continue
var mi := SelectionMath.marker_mesh(marker)
if mi == null or mi.mesh == null:
continue
var mt := SelectionMath.ray_mesh_distance(mi.global_transform,
SelectionMath.mesh_faces(mi.mesh), origin, dir)
if mt >= 0.0 and mt < best_t:
best_t = mt
out = {"punkt": origin + dir * mt, "obj": int(marker.get_meta("obj_index", -1)), "dir": dir}
return out
func _bau_ghost_update(viewport_pos: Vector2) -> void:
_build_hit = _build_ray_hit(viewport_pos)
if _build_hit.is_empty():
_ghost_active = false
_ghost.visible = false
_build_last = {}
return
_bau_ghost_neu()
## Snap neu berechnen (Mausbewegung ODER R-Taste) und den Ghost setzen.
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)
func _bau_ghost_neu() -> void:
_build_last = _bau_snap()
if _build_last.is_empty():
return
var pos: Vector3 = _build_last["pos"]
var half := _terrain_half()
var innerhalb := absf(pos.x) <= half.x and absf(pos.z) <= half.y
_ghost.position = pos
_ghost.rotation_degrees = Vector3(0.0, float(_build_last["rot"]), 0.0)
_ghost_active = true
_ghost.visible = true
var mat := _ghost_mi.material_override as StandardMaterial3D
var hinweis := ""
if not bool(_build_last["ok"]) or not innerhalb:
mat.albedo_color = Color(1.0, 0.25, 0.2, 0.5)
hinweis = str(_build_last["grund"]) if not bool(_build_last["ok"]) else "Außerhalb der Map."
elif int(_build_last["ersetze"]) >= 0:
mat.albedo_color = Color(1.0, 0.7, 0.2, 0.55)
hinweis = "Klick ersetzt das vorhandene Teil durch %s (Strg+Z holt es zurück)." % main.object_label(place_oid)
else:
mat.albedo_color = Color(0.6, 0.9, 1.0, 0.45)
if hinweis != _build_hint:
_build_hint = hinweis
if not hinweis.is_empty():
main.call("_set_status", hinweis)
## Klick im Bauen-Modus: Teil setzen/ersetzen — ein Undo-Schritt, Teil landet im
## laufenden Gebäude (Gruppe), Serie bleibt aktiv (Esc beendet).
func _place_bau(viewport_pos: Vector2) -> void:
_build_hit = _build_ray_hit(viewport_pos)
if _build_hit.is_empty():
main.call("_set_status", "Da war nichts unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
return
var snap := _bau_snap()
if snap.is_empty():
return
var pos: Vector3 = snap["pos"]
var half := _terrain_half()
if absf(pos.x) > half.x or absf(pos.z) > half.y:
main.call("_set_status", "Das wäre außerhalb der Map gelandet — klicke innerhalb der grünen Fläche.", true)
return
if not bool(snap["ok"]):
main.call("_set_status", str(snap["grund"]), true)
return
var objects: Array = main.doc["objects"]
var ersetzt := int(snap["ersetze"]) >= 0
var lib: Dictionary = main.manifest.get("objects", {})
var poly := float((lib.get(place_oid, {}) as Dictionary).get("poly", 0))
var sym_mode: int = _sym_mode()
var sym_factor := 1.0
if sym_mode != SymmetryCore.Mode.OFF and not SymmetryCore.maps_to_self(
sym_mode, pos, SymmetryCore.map_center(main.doc)):
sym_factor = 2.0
if not ersetzt and objects.size() + int(sym_factor) > SvmapIO.MAX_OBJECTS:
main.call("_set_status", "Limit erreicht: In eine Map passen maximal %d Objekte — das Löschen von Objekten macht wieder Platz." % SvmapIO.MAX_OBJECTS, true)
return
var used := SvmapDocument.poly_usage(main.doc, main.manifest)
var budget := float(SvmapIO.MAX_TOTAL_POLY)
if used + poly * sym_factor > budget:
main.call("_set_status",
"Kein Platz mehr im Poly-Budget (%s von %s belegt) — lass ein Teil weg oder drück Strg+Z."
% [main._fmt_int(int(used)), main._fmt_int(int(budget))], true)
return
var keys := ["objects", "group_names"]
var before: Dictionary = main.history.snapshot(keys)
if not before.has("group_names"):
before["group_names"] = null # Undo entfernt die neu angelegten Gebäude-Namen wieder
var label: String = main.object_label(place_oid)
var gid := _bau_gruppe()
var spiegel := -1
if sym_mode != SymmetryCore.Mode.OFF and sym_factor > 1.0:
spiegel = _bau_spiegel_gruppe(gid)
BauOps.setze_teil(main.doc, place_oid, snap, gid, sym_mode, spiegel)
main.history.commit("%s bauen" % label if not ersetzt else "%s einsetzen" % label,
before, main.history.snapshot(keys))
main.call("_after_structure_change")
_build_hit = _build_ray_hit(viewport_pos)
_bau_ghost_neu()
main.call("_set_status", "%s %s (%s, %d Teile). Weiter klicken — R dreht, Esc beendet, Strg+Z nimmt das letzte Teil zurück." % [
label, "eingesetzt" if ersetzt else "gesetzt", GroupState.display_name(main.doc, gid),
GroupOps.members_of(main.doc, gid).size()])
main.call("_refresh_bau_info")
## Laufendes Gebäude (Gruppe): bestehende Gruppe weiterführen oder neue anlegen
## und benennen. Rückgabe: Gruppen-ID.
func _bau_gruppe() -> int:
if build_group_id >= 0 and not GroupOps.members_of(main.doc, build_group_id).is_empty():
return build_group_id
build_group_id = BauOps.gruppe_neu(main.doc)
return build_group_id
## Gruppe für den Spiegel-Zwilling des laufenden Gebäudes (1 Gebäude ↔ 1 Spiegel).
var _bau_spiegel: Dictionary = {} # laufende ID → Spiegel-ID
func _bau_spiegel_gruppe(gid: int) -> int:
var sp := int(_bau_spiegel.get(gid, -1))
if sp >= 0 and not GroupOps.members_of(main.doc, sp).is_empty():
return sp
sp = maxi(GroupOps.next_group_id(main.doc), gid + 1)
GroupState.set_group_name(main.doc, sp, "%s (Spiegel)" % GroupState.display_name(main.doc, gid))
_bau_spiegel[gid] = sp
return sp
## Nächstes Teil beginnt ein NEUES Gebäude (Button „Neues Gebäude“).
func neues_gebaeude() -> void:
build_group_id = -1
_bau_spiegel.clear()
## Demo-Hook: Ghost an einem Punkt parken (Screenshots ohne Maus).
func demo_park_bau(punkt: Vector3) -> void:
_build_hit = {"punkt": punkt, "obj": -1, "dir": Vector3.ZERO}
_bau_ghost_neu()
# --------------------------------------------------------------- Auswahl ---- # --------------------------------------------------------------- Auswahl ----
## Einzel-Auswahl (Klick ins Objekt / Klick im Baum): ersetzt die Auswahl. ## Einzel-Auswahl (Klick ins Objekt / Klick im Baum): ersetzt die Auswahl.
@ -630,7 +833,9 @@ func delete_selected() -> void:
# Symmetrie (M3, t_34908425): die Partner der Auswahl löschen sich MIT — # Symmetrie (M3, t_34908425): die Partner der Auswahl löschen sich MIT —
# ein Paar lebt oder stirbt zusammen, IM SELBEN Undo-Schritt. # ein Paar lebt oder stirbt zusammen, IM SELBEN Undo-Schritt.
doomed = ObjectOps.symmetry_expand_delete(main.doc, doomed, _sym_mode()) doomed = ObjectOps.symmetry_expand_delete(main.doc, doomed, _sym_mode())
var before: Dictionary = main.history.snapshot(["objects"]) var before: Dictionary = main.history.snapshot(["objects", "group_names"])
if not before.has("group_names"):
before["group_names"] = null
var label: String var label: String
if doomed.size() == 1: if doomed.size() == 1:
label = "%s löschen" % main.object_label(str(objects[doomed[0]].get("id", ""))) label = "%s löschen" % main.object_label(str(objects[doomed[0]].get("id", "")))
@ -641,7 +846,8 @@ func delete_selected() -> void:
doomed.reverse() doomed.reverse()
for i in doomed: for i in doomed:
objects.remove_at(i) objects.remove_at(i)
main.history.commit(label, before, main.history.snapshot(["objects"])) GroupState.prune_orphans(main.doc) # Gebäude ohne Teile verlieren ihren Namen
main.history.commit(label, before, main.history.snapshot(["objects", "group_names"]))
clear_selection(true) clear_selection(true)
main.call("_after_structure_change") main.call("_after_structure_change")
main.call("_set_status", "%s ist erledigt — Strg+Z (Rückgängig) holt %s zurück." % [ main.call("_set_status", "%s ist erledigt — Strg+Z (Rückgängig) holt %s zurück." % [
@ -698,8 +904,23 @@ func duplicate_selected() -> void:
"die Kopien" if sources.size() > 1 else "eine Kopie", "die Kopien" if sources.size() > 1 else "eine Kopie",
main._fmt_int(int(used)), main._fmt_int(int(budget))], true) main._fmt_int(int(used)), main._fmt_int(int(budget))], true)
return return
var before: Dictionary = main.history.snapshot(["objects"]) var before: Dictionary = main.history.snapshot(["objects", "group_names"])
var new_indices: Array = ObjectOps.duplicate_objects(main.doc, sources, bool(main.get("grid_snap"))) if not before.has("group_names"):
before["group_names"] = null # Undo entfernt die Namen der Kopie-Gruppen wieder
# Gebäude (Bauteile): Kopie rückt um die Gebäudebreite weg statt 2 m — sonst
# läge sie halb im Original.
var offset_x := ObjectOps.DUPLICATE_OFFSET_M.x
var bau_min := INF
var bau_max := -INF
for i in sources:
var so: Dictionary = objects[i]
if BauKatalog.ist_bau(str(so.get("id", ""))):
var spx := float((so.get("pos", [0.0, 0.0, 0.0]) as Array)[0])
bau_min = minf(bau_min, spx)
bau_max = maxf(bau_max, spx)
if bau_min < INF:
offset_x = maxf(offset_x, ceilf((bau_max - bau_min + 4.0) / 2.0) * 2.0)
var new_indices: Array = ObjectOps.duplicate_objects(main.doc, sources, bool(main.get("grid_snap")), offset_x)
if new_indices.is_empty(): if new_indices.is_empty():
clear_selection(true) clear_selection(true)
return return
@ -717,7 +938,7 @@ func duplicate_selected() -> void:
label = "%s duplizieren" % main.object_label(str((objects[sources[0]] as Dictionary).get("id", ""))) label = "%s duplizieren" % main.object_label(str((objects[sources[0]] as Dictionary).get("id", "")))
else: else:
label = "%d Objekte duplizieren" % new_indices.size() label = "%d Objekte duplizieren" % new_indices.size()
main.history.commit(label, before, main.history.snapshot(["objects"])) main.history.commit(label, before, main.history.snapshot(["objects", "group_names"]))
main.call("_after_structure_change") main.call("_after_structure_change")
# Kopien direkt anwählen — Sly kann sie gleich weiterziehen. Die frischen # Kopien direkt anwählen — Sly kann sie gleich weiterziehen. Die frischen
# Zwillinge wandern mit in die Auswahl (beide Seiten sichtbar markiert). # Zwillinge wandern mit in die Auswahl (beide Seiten sichtbar markiert).
@ -1195,7 +1416,7 @@ func _unhandled_input(event: InputEvent) -> void:
if _camera_flying(): if _camera_flying():
return # Mausrad = Flug-Tempo (orbit_camera), kein Objekt-Drehen return # Mausrad = Flug-Tempo (orbit_camera), kein Objekt-Drehen
if placing: if placing:
place_rot_deg = fmod(place_rot_deg + ROT_STEP_DEG + 3600.0, 360.0) place_rot_deg = fmod(place_rot_deg + (90.0 if BauKatalog.ist_bau(place_oid) else ROT_STEP_DEG) + 3600.0, 360.0)
_ghost_rotation_anwenden() _ghost_rotation_anwenden()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif selected_index >= 0 and not _grabbing: elif selected_index >= 0 and not _grabbing:
@ -1205,7 +1426,7 @@ func _unhandled_input(event: InputEvent) -> void:
if _camera_flying(): if _camera_flying():
return # Mausrad = Flug-Tempo (orbit_camera), kein Objekt-Drehen return # Mausrad = Flug-Tempo (orbit_camera), kein Objekt-Drehen
if placing: if placing:
place_rot_deg = fmod(place_rot_deg - ROT_STEP_DEG + 3600.0, 360.0) place_rot_deg = fmod(place_rot_deg - (90.0 if BauKatalog.ist_bau(place_oid) else ROT_STEP_DEG) + 3600.0, 360.0)
_ghost_rotation_anwenden() _ghost_rotation_anwenden()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif selected_index >= 0 and not _grabbing: elif selected_index >= 0 and not _grabbing:

View file

@ -108,8 +108,9 @@ const GROUP_IDS := {
## Diese Zuordnung zeigt Pack-/Pipeline-Objekte in sinnvollen Gruppen statt ## Diese Zuordnung zeigt Pack-/Pipeline-Objekte in sinnvollen Gruppen statt
## alles in einer Mega-„Sonstiges"-Gruppe abzulegen (das war der eigentliche ## alles in einer Mega-„Sonstiges"-Gruppe abzulegen (das war der eigentliche
## Übersichts-Killer). Reihenfolge = Anzeige-Reihenfolge nach GROUP_ORDER. ## Übersichts-Killer). Reihenfolge = Anzeige-Reihenfolge nach GROUP_ORDER.
const KAT_ORDER := ["mauern", "barrieren", "gebaeude", "requisiten", "fahrzeuge", "natur_uebrig"] const KAT_ORDER := ["bauteile", "mauern", "barrieren", "gebaeude", "requisiten", "fahrzeuge", "natur_uebrig"]
const KAT_GROUPS := { const KAT_GROUPS := {
"bauteile": {"key": "bauteile", "label": "Bauteile (eigene Häuser)"},
"walls": {"key": "mauern", "label": "Mauern"}, "walls": {"key": "mauern", "label": "Mauern"},
"barriers": {"key": "barrieren", "label": "Barrieren"}, "barriers": {"key": "barrieren", "label": "Barrieren"},
"buildings": {"key": "gebaeude", "label": "Gebäude"}, "buildings": {"key": "gebaeude", "label": "Gebäude"},
@ -124,11 +125,15 @@ const THUMB_DISPLAY_PX := 128
static func object_label(oid: String) -> String: static func object_label(oid: String) -> String:
if BauKatalog.ist_bau(oid):
return BauKatalog.label(oid)
return NAMES.get(oid, oid.capitalize()) return NAMES.get(oid, oid.capitalize())
## Gruppe einer ID (Objekte, die keine festen Gruppe haben → "sonstiges"). ## Gruppe einer ID (Objekte, die keine festen Gruppe haben → "sonstiges").
static func group_of(oid: String) -> String: static func group_of(oid: String) -> String:
if BauKatalog.ist_bau(oid):
return "bauteile"
for key in GROUP_IDS: for key in GROUP_IDS:
if (GROUP_IDS[key] as Array).has(oid): if (GROUP_IDS[key] as Array).has(oid):
return str(key) return str(key)

View file

@ -99,7 +99,8 @@ static func snap_coord(value: float, enabled: bool, grid_m := GRID_M) -> float:
## Höhe — der Füßpunkt (pos.y) bleibt exakt. Rückgabe: neuer Index (-1 = ## Höhe — der Füßpunkt (pos.y) bleibt exakt. Rückgabe: neuer Index (-1 =
## ungültig). VOR dem Aufruf prüft die UI Budget/Objekt-Limit (hilfs-Meldung ## ungültig). VOR dem Aufruf prüft die UI Budget/Objekt-Limit (hilfs-Meldung
## statt Fehler). ## statt Fehler).
static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool) -> int: static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool,
offset_x := DUPLICATE_OFFSET_M.x) -> int:
var objects: Array = doc.get("objects", []) var objects: Array = doc.get("objects", [])
if index < 0 or index >= objects.size(): if index < 0 or index >= objects.size():
return -1 return -1
@ -109,7 +110,7 @@ static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool) ->
var copy: Dictionary = (source as Dictionary).duplicate(true) var copy: Dictionary = (source as Dictionary).duplicate(true)
var pos: Array = copy.get("pos", [0.0, 0.0, 0.0]) var pos: Array = copy.get("pos", [0.0, 0.0, 0.0])
if (pos as Array).size() == 3: if (pos as Array).size() == 3:
var nx := float(pos[0]) + DUPLICATE_OFFSET_M.x var nx := float(pos[0]) + offset_x
var nz := float(pos[2]) + DUPLICATE_OFFSET_M.y var nz := float(pos[2]) + DUPLICATE_OFFSET_M.y
copy["pos"] = [snap_coord(nx, grid_enabled), float(pos[1]), snap_coord(nz, grid_enabled)] copy["pos"] = [snap_coord(nx, grid_enabled), float(pos[1]), snap_coord(nz, grid_enabled)]
objects.append(copy) objects.append(copy)
@ -121,15 +122,37 @@ static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool) ->
## der Quellen bleibt erhalten, alle Indizes sind Originale (nur angehängt). ## der Quellen bleibt erhalten, alle Indizes sind Originale (nur angehängt).
## Rückgabe: Liste der NEUEN Indizes (in Quell-Reihenfolge). Die UI commitet ## Rückgabe: Liste der NEUEN Indizes (in Quell-Reihenfolge). Die UI commitet
## EINEN Undo-Schritt für alle Kopien zusammen. ## EINEN Undo-Schritt für alle Kopien zusammen.
static func duplicate_objects(doc: Dictionary, indices: Array, grid_enabled: bool) -> Array: static func duplicate_objects(doc: Dictionary, indices: Array, grid_enabled: bool,
offset_x := DUPLICATE_OFFSET_M.x) -> Array:
var new_indices: Array = [] var new_indices: Array = []
for i in indices: for i in indices:
var ni := duplicate_object(doc, int(i), grid_enabled) var ni := duplicate_object(doc, int(i), grid_enabled, offset_x)
if ni >= 0: if ni >= 0:
new_indices.append(ni) new_indices.append(ni)
regroup_copies(doc, new_indices)
return new_indices return new_indices
## Kopien gruppierter Objekte bilden eine NEUE Gruppe je Original-Gruppe — sonst
## würde die Kopie eines Gebäudes mit dem Original zu EINER Gruppe verschmelzen
## (Gruppen-ID wird von duplicate(true) mitkopiert). Name: "<alt> (Kopie)".
static func regroup_copies(doc: Dictionary, new_indices: Array) -> void:
var objects: Array = doc.get("objects", [])
var mapping := {}
for ni in new_indices:
var obj: Variant = objects[int(ni)]
var old_gid := GroupOps.group_id(obj)
if old_gid == GroupOps.UNGROUPED:
continue
if not mapping.has(old_gid):
var gid := GroupOps.next_group_id(doc)
mapping[old_gid] = gid
var old_name := GroupState.display_name(doc, old_gid)
objects[int(ni)]["group"] = gid # sofort belegen, damit next_group_id weiterzählt
GroupState.set_group_name(doc, gid, "%s (Kopie)" % old_name)
(obj as Dictionary)["group"] = int(mapping[old_gid])
## Anheben/Absenken in LIFT_STEP_M-Schritten (delta_m positiv = anheben). ## Anheben/Absenken in LIFT_STEP_M-Schritten (delta_m positiv = anheben).
## Objekte dürfen vom Terrain abweichen — die Höhe wohnt direkt in object.pos.y ## Objekte dürfen vom Terrain abweichen — die Höhe wohnt direkt in object.pos.y
## (das svmap-Format erlaubt freie y-Werte innerhalb der Match-Grenzen, V9 — ## (das svmap-Format erlaubt freie y-Werte innerhalb der Match-Grenzen, V9 —

View file

@ -9,7 +9,7 @@ extends Node
## - Kollider-Bau via KolliderBau (src/svmap/kollider_bau.gd — 1:1-Spiegel ## - Kollider-Bau via KolliderBau (src/svmap/kollider_bau.gd — 1:1-Spiegel
## des Quest-Clients, Karte t_1da96275): Terrain-Trimesh + Objekt-Kollider ## des Quest-Clients, Karte t_1da96275): Terrain-Trimesh + Objekt-Kollider
## (box/kugel/zylinder/verbund/trimesh inkl. library/kollisions-Quellen) ## (box/kugel/zylinder/verbund/trimesh inkl. library/kollisions-Quellen)
## + Bauteil-Trimeshe (BuildOps-Pieces: Treppen/Türen/Wände begehbar ## + Bauteil-Trimeshe (bau_*-Objekte: Treppen/Türen/Wände begehbar
## wie echte Geometrie). ## wie echte Geometrie).
## - PlaytestBody (CharacterBody3D, IDENTISCHE Parameter wie der ## - PlaytestBody (CharacterBody3D, IDENTISCHE Parameter wie der
## Testmap-Spieler inkl. Step-Up 0.45 m) anlegen, Terrain-Raster EINMAL ## Testmap-Spieler inkl. Step-Up 0.45 m) anlegen, Terrain-Raster EINMAL
@ -22,7 +22,6 @@ extends Node
## PlaytestBody; dieser Zustand managed nur Lifecycle + HUD. ## PlaytestBody; dieser Zustand managed nur Lifecycle + HUD.
const BODY_SCRIPT := "res://src/editor/playtest/playtest_body.gd" const BODY_SCRIPT := "res://src/editor/playtest/playtest_body.gd"
const BUILD_OPS_SCRIPT := "res://src/editor/build_ops.gd"
const KOLLIDER_SCRIPT := "res://src/svmap/kollider_bau.gd" const KOLLIDER_SCRIPT := "res://src/svmap/kollider_bau.gd"
const AVATAR_SCRIPT := "res://src/editor/playtest/playtest_avatar.gd" const AVATAR_SCRIPT := "res://src/editor/playtest/playtest_avatar.gd"
@ -188,30 +187,12 @@ func _spawn_start(doc: Dictionary) -> Vector3:
## - Terrain + Registry-Objekte: KolliderBau.baue_welt_kollider (1:1-Spiegel ## - Terrain + Registry-Objekte: KolliderBau.baue_welt_kollider (1:1-Spiegel
## von svmap_world.gd — box/kugel/zylinder/verbund/trimesh inkl. ## von svmap_world.gd — box/kugel/zylinder/verbund/trimesh inkl.
## library/kollisions-Quelldateien). ## library/kollisions-Quelldateien).
## - Bauteile (gebaeude): Trimesh aus dem gebackten Piece-Mesh — Türen ## - Bauteile (bau_*) sind normale Registry-Objekte mit Trimesh aus
## bleiben Durchgänge, Treppen sind begehbar (wie die sichtbare Geometrie). ## library/kollisions — Türen/Fenster bleiben Durchgänge, Treppen begehbar.
func _baue_kollider(doc: Dictionary) -> void: func _baue_kollider(doc: Dictionary) -> void:
var kb := load(KOLLIDER_SCRIPT) var kb := load(KOLLIDER_SCRIPT)
_kollider_root = kb.call("baue_welt_kollider", doc) _kollider_root = kb.call("baue_welt_kollider", doc)
add_child(_kollider_root) add_child(_kollider_root)
var build_ops := load(BUILD_OPS_SCRIPT)
if build_ops == null:
return
for teil in doc.get("gebaeude", []):
if not (teil is Dictionary):
continue
var mesh: Mesh = build_ops.call("piece_mesh", teil)
if mesh == null:
continue
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var pb := StaticBody3D.new()
pb.name = "Bauteil_Kollider_%d" % _kollider_root.get_child_count()
var cs := CollisionShape3D.new()
cs.shape = mesh.create_trimesh_shape()
pb.add_child(cs)
pb.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
pb.rotation.y = deg_to_rad(float(teil.get("rot", 0.0)))
_kollider_root.add_child(pb)
# ------------------------------------------------------------------- HUD ---- # ------------------------------------------------------------------- HUD ----

View file

@ -102,27 +102,6 @@ func _click(viewport_pos: Vector2) -> void:
var hit_obj := false var hit_obj := false
if obj_idx is int and int(obj_idx) >= 0: if obj_idx is int and int(obj_idx) >= 0:
hit_obj = true hit_obj = true
# 2) Bauteil-Treffer? (AABB-Strahl über BuildOps.piece_aabb — build_ops.gd
# wird nur gelesen/nutzend gerufen, nicht geändert.)
var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos)
var hit_build := false
if not hit_obj:
var t_terrain := INF
var ground: Variant = _ray_hit(viewport_pos)
if ground != null:
t_terrain = origin.distance_to(ground as Vector3)
var best_t := INF
for teil in main.doc.get("gebaeude", []):
if not (teil is Dictionary):
continue
var ab: AABB = BuildOps.piece_aabb(teil)
if ab.size.length_squared() <= 0.0:
continue
var t := TestStart.ray_aabb(origin, dir, ab)
if t >= 0.0 and t < best_t and t < t_terrain + 0.01:
best_t = t
hit_build = true
# 3) Punkt setzen: x/z vom Strahl-Treffer, Höhe IMMER vom Terrain (nie # 3) Punkt setzen: x/z vom Strahl-Treffer, Höhe IMMER vom Terrain (nie
# im Objekt schwebend, nie versunken). # im Objekt schwebend, nie versunken).
var hit: Variant = _ray_hit(viewport_pos) var hit: Variant = _ray_hit(viewport_pos)
@ -133,8 +112,6 @@ func _click(viewport_pos: Vector2) -> void:
var hinweis := "" var hinweis := ""
if hit_obj: if hit_obj:
hinweis = "auf dem Boden unter dem Objekt" hinweis = "auf dem Boden unter dem Objekt"
elif hit_build:
hinweis = "auf dem Boden unter dem Bauteil"
main.call("_test_start_apply", p.x, p.z, hinweis) main.call("_test_start_apply", p.x, p.z, hinweis)

View file

@ -81,7 +81,7 @@ static var _faces_cache: Dictionary = {}
## Strahl gegen die ECHTEN Dreiecke eines Meshes (Möller–Trumbore über ## Strahl gegen die ECHTEN Dreiecke eines Meshes (Möller–Trumbore über
## BuildOps._ray_tri). xform = globale Transform der MeshInstance (inkl. ## ray_tri). xform = globale Transform der MeshInstance (inkl.
## Objekt-Drehung/-Kippung, Platzhalter-Lift und Node-Scale). ## Objekt-Drehung/-Kippung, Platzhalter-Lift und Node-Scale).
## Rückgabe: Welt-Abstand vom Strahl-Ursprung zum nächsten Treffer, -1 = keiner. ## Rückgabe: Welt-Abstand vom Strahl-Ursprung zum nächsten Treffer, -1 = keiner.
## WARUM: Die Kugel-Näherung (pick_radius = halbe AABB-Diagonale) eines großen ## WARUM: Die Kugel-Näherung (pick_radius = halbe AABB-Diagonale) eines großen
@ -96,7 +96,7 @@ static func ray_mesh_distance(xform: Transform3D, faces: PackedVector3Array,
var ld: Vector3 = inv.basis * dir var ld: Vector3 = inv.basis * dir
var best := INF var best := INF
for i in int(faces.size() / 3): for i in int(faces.size() / 3):
var t := BuildOps._ray_tri(lo, ld, faces[i * 3], faces[i * 3 + 1], faces[i * 3 + 2]) var t := ray_tri(lo, ld, faces[i * 3], faces[i * 3 + 1], faces[i * 3 + 2])
if t >= 0.0 and t < best: if t >= 0.0 and t < best:
best = t best = t
if best == INF: if best == INF:
@ -121,3 +121,23 @@ static func marker_mesh(marker: Node3D) -> MeshInstance3D:
if child is MeshInstance3D: if child is MeshInstance3D:
return child as MeshInstance3D return child as MeshInstance3D
return null return null
## Möller–Trumbore: Abstand t entlang des Strahls zum Dreieck (a, b, c), -1 = kein Treffer.
static func ray_tri(origin: Vector3, dir: Vector3, a: Vector3, b: Vector3, c: Vector3) -> float:
var e1 := b - a
var e2 := c - a
var p := dir.cross(e2)
var det := e1.dot(p)
if absf(det) < 0.000001:
return -1.0
var inv := 1.0 / det
var tv := origin - a
var u := tv.dot(p) * inv
if u < 0.0 or u > 1.0:
return -1.0
var q := tv.cross(e1)
var v := dir.dot(q) * inv
if v < 0.0 or u + v > 1.0:
return -1.0
return e2.dot(q) * inv

View file

@ -5,14 +5,14 @@ class_name ShortcutPolicy
## und macht nur noch den Text. tests/test_shortcuts.gd prüft alle Kombos. ## und macht nur noch den Text. tests/test_shortcuts.gd prüft alle Kombos.
## ##
## Priorität (spec „Esc = Auswahl aufheben / Werkzeug abbrechen / Ghost abbrechen"): ## Priorität (spec „Esc = Auswahl aufheben / Werkzeug abbrechen / Ghost abbrechen"):
## 1. aktives Sonder-Werkzeug verlassen (Terrain/Spawns/Bauen → Objekt-Werkzeug) ## 1. aktives Sonder-Werkzeug verlassen (Terrain/Spawns → Objekt-Werkzeug)
## 2. Ghost-Preview (Platzieren) abbrechen ## 2. Ghost-Preview (Platzieren) abbrechen
## 3. Auswahl aufheben ## 3. Auswahl aufheben
## Rückgabe-Werte: ## Rückgabe-Werte:
## "exit_terrain" | "exit_spawns" | "exit_bauen" | "exit_teststart" | "cancel_grab" ## "exit_terrain" | "exit_spawns" | "exit_teststart" | "cancel_grab"
## "cancel_placement" | "clear_selection" | "none" ## "cancel_placement" | "clear_selection" | "none"
## grabbing (Karte t_55257792): G-Greifen läuft — Esc rollt die Positionen ## grabbing (Karte t_55257792): G-Greifen läuft — Esc rollt die Positionen
## zurück (Blender-Semantik). Vorrang NACH Werkzeug-Exit (im Terrain-/Bauen- ## zurück (Blender-Semantik). Vorrang NACH Werkzeug-Exit (im Terrain-
## Werkzeug gibt es kein Greifen), VOR Platzieren/Greifen-Auswahlheben. ## Werkzeug gibt es kein Greifen), VOR Platzieren/Greifen-Auswahlheben.
static func esc_action(tool_mode: String, placing: bool, has_selection: bool, static func esc_action(tool_mode: String, placing: bool, has_selection: bool,
grabbing := false) -> String: grabbing := false) -> String:
@ -20,8 +20,6 @@ static func esc_action(tool_mode: String, placing: bool, has_selection: bool,
return "exit_terrain" return "exit_terrain"
if tool_mode == "spawns": if tool_mode == "spawns":
return "exit_spawns" return "exit_spawns"
if tool_mode == "bauen":
return "exit_bauen"
if tool_mode == "teststart": if tool_mode == "teststart":
# Karte t_96e3c4ee (Wiederanbindung 20.10.): Esc verlässt das # Karte t_96e3c4ee (Wiederanbindung 20.10.): Esc verlässt das
# Test-Start-Werkzeug — der gesetzte Punkt BLEIBT bestehen. # Test-Start-Werkzeug — der gesetzte Punkt BLEIBT bestehen.

View file

@ -168,13 +168,10 @@ static func find_partner_index(list: Array, key: String, exclude_index: int = -1
## Nächste freie sym-Kennung im Dokument ("sym_1", "sym_2", …): scannt ## Nächste freie sym-Kennung im Dokument ("sym_1", "sym_2", …): scannt
## Objekte UND Bau-Teile, deterministic (kein Zufall, keine Uhr). ## Objekte (inkl. Bauteile), deterministic (kein Zufall, keine Uhr).
static func next_sym_key(doc: Dictionary) -> String: static func next_sym_key(doc: Dictionary) -> String:
var used: Dictionary = {} var used: Dictionary = {}
for block_key in ["objects", "gebaeude"]: var block: Array = doc.get("objects", [])
var block: Array = doc.get(block_key, [])
if not (block is Array):
continue
for item in block: for item in block:
if not (item is Dictionary): if not (item is Dictionary):
continue continue

View file

@ -3,7 +3,7 @@ class_name SymmetryOps
## M5 Ein-Klick „Karte symmetrisch machen“ + Symmetrie-Check (Karte t_60014669, ## M5 Ein-Klick „Karte symmetrisch machen“ + Symmetrie-Check (Karte t_60014669,
## Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md). Baut auf M1–M4 auf: ## Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md). Baut auf M1–M4 auf:
## Spiegel-Mathe und Partner-Links kommen aus SymmetryCore, das Anwenden auf ## Spiegel-Mathe und Partner-Links kommen aus SymmetryCore, das Anwenden auf
## Objekten aus ObjectOps, auf Bau-Teilen aus BuildOps und auf Spawn-Zonen aus ## Objekten aus ObjectOps, auf Bauteilen (normale Objekte) und auf Spawn-Zonen aus
## dem Spawn-Spiegel-Mechanismus (spawn_tool.gd — dort NUR BENUTZT, nie ## dem Spawn-Spiegel-Mechanismus (spawn_tool.gd — dort NUR BENUTZT, nie
## geändert). Diese Datei orchestriert den GANZEN Vorgang und den Check: ## geändert). Diese Datei orchestriert den GANZEN Vorgang und den Check:
## ##
@ -145,7 +145,6 @@ static func analyze(doc: Dictionary, mode: int) -> Dictionary:
var an := { var an := {
"mode": mode, "mode": mode,
"objects": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0}, "objects": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0},
"gebaeude": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0},
"spawns": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0}, "spawns": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0},
"terrain_cells": [], "terrain_cells": [],
"material_cells": [], # Material-Raster (fein, Karte 24.10.) — eigenes Gitter "material_cells": [], # Material-Raster (fein, Karte 24.10.) — eigenes Gitter
@ -160,16 +159,13 @@ static func analyze(doc: Dictionary, mode: int) -> Dictionary:
if mode == SymmetryCore.Mode.OFF: if mode == SymmetryCore.Mode.OFF:
return an return an
_analyze_objects(doc, mode, an) _analyze_objects(doc, mode, an)
_analyze_gebaeude(doc, mode, an)
_analyze_spawns(doc, mode, an) _analyze_spawns(doc, mode, an)
_analyze_terrain(doc, mode, an) _analyze_terrain(doc, mode, an)
an["conflicts"] = int(an["objects"]["diff"]) + int(an["gebaeude"]["diff"]) \ an["conflicts"] = int(an["objects"]["diff"]) \
+ int(an["spawns"]["diff"]) + (an["terrain_cells"] as Array).size() \ + int(an["spawns"]["diff"]) + (an["terrain_cells"] as Array).size() \
+ (an["material_cells"] as Array).size() + (an["material_cells"] as Array).size()
an["adds"] = (an["objects"]["singles_low"] as Array).size() \ an["adds"] = (an["objects"]["singles_low"] as Array).size() \
+ (an["objects"]["singles_high"] as Array).size() \ + (an["objects"]["singles_high"] as Array).size() \
+ (an["gebaeude"]["singles_low"] as Array).size() \
+ (an["gebaeude"]["singles_high"] as Array).size() \
+ (an["spawns"]["singles_low"] as Array).size() \ + (an["spawns"]["singles_low"] as Array).size() \
+ (an["spawns"]["singles_high"] as Array).size() + (an["spawns"]["singles_high"] as Array).size()
an["total_deviations"] = int(an["_dev_total"]) an["total_deviations"] = int(an["_dev_total"])
@ -301,53 +297,6 @@ static func _analyze_objects(doc: Dictionary, mode: int, an: Dictionary) -> void
_push_dev(an, "Objekt #%d hat keinen Zwilling" % int(idx)) _push_dev(an, "Objekt #%d hat keinen Zwilling" % int(idx))
static func _teil_differs(src: Dictionary, twin: Dictionary, mp: Vector3,
mode: int) -> bool:
var tpos := _pos_of(twin.get("pos", []))
if Vector2(mp.x - tpos.x, mp.z - tpos.z).length() > POS_EPS_M:
return true
if absf(tpos.y - mp.y) > POS_EPS_M:
return true
var trot := fposmod(float(twin.get("rot", 0.0))
- SymmetryCore.mirror_rot_y(mode, float(src.get("rot", 0.0))), 360.0)
var rotd := minf(trot, 360.0 - trot)
if rotd > ROT_EPS_DEG:
return true
return str(twin.get("id", "")) != str(src.get("id", ""))
static func _analyze_gebaeude(doc: Dictionary, mode: int, an: Dictionary) -> void:
var list: Array = doc.get("gebaeude", [])
var items: Array = []
for i in list.size():
var teil = list[i]
if not (teil is Dictionary):
continue
var pos: Array = teil.get("pos", [])
if (pos as Array).size() < 3:
continue
items.append({"idx": i, "pos": _pos_of(pos)})
var paired := _pair_items(mode, doc, items)
var pairs: Array = paired["pairs"]
var diff_n := 0
for p in pairs:
var low_i: int = p["low"]
var high_i: int = p["high"]
var low_ok: bool = _valid_link(list, low_i) == high_i
var high_ok: bool = _valid_link(list, high_i) == low_i
if not (low_ok and high_ok):
an["links"] = int(an["links"]) + 1
if _teil_differs(list[low_i], list[high_i], p["mirror"], mode):
diff_n += 1
_push_dev(an, "Bau-Teil #%d" % low_i)
an["gebaeude"]["pairs"] = pairs
an["gebaeude"]["singles_low"] = paired["singles_low"]
an["gebaeude"]["singles_high"] = paired["singles_high"]
an["gebaeude"]["diff"] = diff_n
for idx in (paired["singles_low"] as Array) + (paired["singles_high"] as Array):
_push_dev(an, "Bau-Teil #%d hat keinen Zwilling" % int(idx))
# ------------------------------------------------------------------ Spawns -- # ------------------------------------------------------------------ Spawns --
## Nur Teams 0/1 (TDM-Konvention wie M4): der Spiegel-Zwilling eines Spawn- ## Nur Teams 0/1 (TDM-Konvention wie M4): der Spiegel-Zwilling eines Spawn-
## Punkts liegt im GEGNER-Team. Kopplung läuft über design_notes.spawn_mirror. ## Punkts liegt im GEGNER-Team. Kopplung läuft über design_notes.spawn_mirror.
@ -538,12 +487,10 @@ static func apply(doc: Dictionary, mode: int, quelle_hoch: bool) -> Dictionary:
var an := analyze(doc, mode) var an := analyze(doc, mode)
var c := { var c := {
"objects_added": 0, "objects_fixed": 0, "objects_linked": 0, "objects_added": 0, "objects_fixed": 0, "objects_linked": 0,
"parts_added": 0, "parts_fixed": 0, "parts_linked": 0,
"spawns_added": 0, "spawns_fixed": 0, "spawns_linked": 0, "spawns_added": 0, "spawns_fixed": 0, "spawns_linked": 0,
"terrain_cells": 0, "skipped": 0, "terrain_cells": 0, "skipped": 0,
} }
_apply_objects(doc, mode, an, quelle_hoch, c) _apply_objects(doc, mode, an, quelle_hoch, c)
_apply_gebaeude(doc, mode, an, quelle_hoch, c)
_apply_terrain(doc, mode, an, quelle_hoch, c) _apply_terrain(doc, mode, an, quelle_hoch, c)
# Spawns ZULETZT: die Zwillinge sitzen auf der Terrain-Höhe — die stimmt # Spawns ZULETZT: die Zwillinge sitzen auf der Terrain-Höhe — die stimmt
# erst nach dem Boden-Ausgleich (M2/M4-Konvention: y = Terrainhöhe). # erst nach dem Boden-Ausgleich (M2/M4-Konvention: y = Terrainhöhe).
@ -553,18 +500,18 @@ static func apply(doc: Dictionary, mode: int, quelle_hoch: bool) -> Dictionary:
static func _apply_report(c: Dictionary, quelle_hoch: bool, mode: int) -> String: static func _apply_report(c: Dictionary, quelle_hoch: bool, mode: int) -> String:
var erg: Array = [] var erg: Array = []
for entry in [["objects_added", "Objekte ergänzt"], ["parts_added", "Bau-Teile ergänzt"], for entry in [["objects_added", "Objekte ergänzt"],
["spawns_added", "Spawn-Punkte ergänzt"], ["terrain_cells", "Bodenstellen angeglichen"], ["spawns_added", "Spawn-Punkte ergänzt"], ["terrain_cells", "Bodenstellen angeglichen"],
["objects_fixed", "Objekt-Widersprüche"], ["parts_fixed", "Bau-Teil-Widersprüche"], ["objects_fixed", "Objekt-Widersprüche"],
["spawns_fixed", "Spawn-Widersprüche"], ["objects_linked", "Objekt-Paare verknüpft"], ["spawns_fixed", "Spawn-Widersprüche"], ["objects_linked", "Objekt-Paare verknüpft"],
["parts_linked", "Bau-Teil-Paare verknüpft"], ["spawns_linked", "Spawn-Paare verknüpft"]]: ["spawns_linked", "Spawn-Paare verknüpft"]]:
var n := int(c[entry[0]]) var n := int(c[entry[0]])
if n > 0: if n > 0:
erg.append("%d %s" % [n, entry[1]]) erg.append("%d %s" % [n, entry[1]])
if erg.is_empty(): if erg.is_empty():
return "Nichts zu tun — die Karte ist schon symmetrisch ✓" return "Nichts zu tun — die Karte ist schon symmetrisch ✓"
var sides := side_labels(mode) var sides := side_labels(mode)
var conflict_sum := int(c["objects_fixed"]) + int(c["parts_fixed"]) + int(c["spawns_fixed"]) + int(c["terrain_cells"]) var conflict_sum := int(c["objects_fixed"]) + int(c["spawns_fixed"]) + int(c["terrain_cells"])
var text := "Fertig ✓ — " + ", ".join(PackedStringArray(erg)) var text := "Fertig ✓ — " + ", ".join(PackedStringArray(erg))
if conflict_sum > 0: if conflict_sum > 0:
text += ". Bei %d Widersprüchen hat %s gewonnen" % [conflict_sum, str(sides[1] if quelle_hoch else sides[0])] text += ". Bei %d Widersprüchen hat %s gewonnen" % [conflict_sum, str(sides[1] if quelle_hoch else sides[0])]
@ -618,42 +565,6 @@ static func _apply_objects(doc: Dictionary, mode: int, an: Dictionary,
c["skipped"] = int(c["skipped"]) + 1 c["skipped"] = int(c["skipped"]) + 1
static func _write_teil_from(twin: Dictionary, donor: Dictionary, mp: Vector3,
mode: int) -> void:
twin["pos"] = [mp.x, mp.y, mp.z]
twin["rot"] = SymmetryCore.mirror_rot_y(mode, float(donor.get("rot", 0.0)))
static func _apply_gebaeude(doc: Dictionary, mode: int, an: Dictionary,
quelle_hoch: bool, c: Dictionary) -> void:
var list: Array = doc.get("gebaeude", [])
if (list as Array).is_empty():
return
for p in (an["gebaeude"]["pairs"] as Array):
var low_i: int = p["low"]
var high_i: int = p["high"]
var donor_i: int = high_i if quelle_hoch else low_i
var twin_i: int = low_i if quelle_hoch else high_i
var donor: Dictionary = list[donor_i]
var twin: Dictionary = list[twin_i]
var key_d := SymmetryCore.sym_of(donor)
var key_t := SymmetryCore.sym_of(twin)
if key_d != key_t or key_d.is_empty():
# Ohne/widerstreitende Kopplung: frische gemeinsame Kennung.
_fresh_pair_link(doc, list, donor_i, twin_i)
c["parts_linked"] = int(c["parts_linked"]) + 1
var mp := SymmetryCore.mirror_point(mode, _pos_of(donor.get("pos", [])), _center_of(doc))
if _teil_differs(donor, twin, mp, mode):
_write_teil_from(twin, donor, mp, mode)
c["parts_fixed"] = int(c["parts_fixed"]) + 1
for idx in (an["gebaeude"]["singles_low"] as Array) + (an["gebaeude"]["singles_high"] as Array):
var msg := BuildOps.place_mirrored(doc, list[int(idx)], mode)
if not msg.contains("V9") and not msg.contains("nicht gedoppelt"):
c["parts_added"] = int(c["parts_added"]) + 1
else:
c["skipped"] = int(c["skipped"]) + 1
static func _apply_terrain(doc: Dictionary, mode: int, an: Dictionary, static func _apply_terrain(doc: Dictionary, mode: int, an: Dictionary,
quelle_hoch: bool, c: Dictionary) -> void: quelle_hoch: bool, c: Dictionary) -> void:
_apply_materials(doc, mode, an, quelle_hoch, c) _apply_materials(doc, mode, an, quelle_hoch, c)

View file

@ -52,7 +52,6 @@ static func create_new(map_name: String, size_m: float, weather: String) -> Dict
"materials_r8_b64": Marshalls.raw_to_base64(zero_materials), "materials_r8_b64": Marshalls.raw_to_base64(zero_materials),
}, },
"objects": [], "objects": [],
"gebaeude": [], # Bauen-Modus (Karte t_97ea59c5) — Bauteile, optional
"spawn_zones": _default_spawn_zones(size), "spawn_zones": _default_spawn_zones(size),
"bounds": { "bounds": {
"min": [-half, -10.0, -half], "min": [-half, -10.0, -half],
@ -271,13 +270,12 @@ static func resize_terrain(doc: Dictionary, new_size_x_m: float,
var old_bounds: Dictionary = doc.get("bounds", {}) var old_bounds: Dictionary = doc.get("bounds", {})
var old_min: Array = old_bounds.get("min", [-half_x, -10.0, -half_z]) var old_min: Array = old_bounds.get("min", [-half_x, -10.0, -half_z])
var old_max: Array = old_bounds.get("max", [half_x, 80.0, half_z]) var old_max: Array = old_bounds.get("max", [half_x, 80.0, half_z])
return { var out := {
"format_version": SvmapIO.FORMAT_VERSION, "format_version": SvmapIO.FORMAT_VERSION,
"info": (doc.get("info", {}) as Dictionary).duplicate(), "info": (doc.get("info", {}) as Dictionary).duplicate(),
"terrain": terrain, "terrain": terrain,
# R1: Objekte/Bauteile/Spawns unverändert übernehmen (Filter in R2). # R1: Objekte/Bauteile/Spawns unverändert übernehmen (Filter in R2).
"objects": (doc.get("objects", []) as Array).duplicate(), "objects": (doc.get("objects", []) as Array).duplicate(),
"gebaeude": (doc.get("gebaeude", []) as Array).duplicate(),
"spawn_zones": (doc.get("spawn_zones", {}) as Dictionary).duplicate(true), "spawn_zones": (doc.get("spawn_zones", {}) as Dictionary).duplicate(true),
"bounds": { "bounds": {
"min": [-half_x, float(old_min[1]), -half_z], "min": [-half_x, float(old_min[1]), -half_z],
@ -285,6 +283,15 @@ static func resize_terrain(doc: Dictionary, new_size_x_m: float,
}, },
"weather": doc.get("weather", ""), "weather": doc.get("weather", ""),
} }
# Optionale Zusatz-Blöcke (group_names = Gebäude-/Gruppen-Namen, design_notes,
# Himmel …) gehören nicht dem Resize und wandern unverändert mit — nur die
# Prüfsumme wird verworfen.
for key in doc.keys():
if not out.has(key) and key != "checksum":
var v: Variant = doc[key]
out[key] = (v as Array).duplicate(true) if v is Array \
else ((v as Dictionary).duplicate(true) if v is Dictionary else v)
return out
# ------------------------------------------------- R2: Out-of-Bounds-Liste ---- # ------------------------------------------------- R2: Out-of-Bounds-Liste ----

View file

@ -59,9 +59,10 @@ static func text() -> String:
])) ]))
out += _header("Bauen (F3)") out += _header("Bauen (F3)")
out += _table(_rows([ out += _table(_rows([
["Linksklick", "Bauteil aufs 2-m-Raster setzen, auch auf andere Teile (stapeln)"], ["Teil im Tab Bauen", "wählen, dann in die Karte klicken — Wände rasten an die Zellkante, Böden/Dächer in die Zellmitte"],
["R / Pfeile / Bild↑↓", "drehen / verschieben / anheben"], ["Klick auf Wandoberkante", "stapelt das nächste Geschoss; Klick auf die Seite ersetzt das Teil (Wand → Tür)"],
["Umschalt+Klick", "Bauteil auswählen; Entf löscht"], ["R / Esc", "Teil umdrehen bzw. drehen / Bauen beenden"],
["Gebäude", "jedes Haus ist eine Gruppe: „Ganzes Gebäude auswählen“, kopieren, löschen im Tab Bauen"],
])) ]))
out += _header("Spawn-Zonen (F4) und Test-Start (T)") out += _header("Spawn-Zonen (F4) und Test-Start (T)")
out += _table(_rows([ out += _table(_rows([

View file

@ -57,8 +57,7 @@ var gizmo # src/viewport/gizmo.gd (Achsen-Pfeile, Kind von i
var gizmo_step_m := 0.5 # Gizmo-Schrittweite in m (Panel-Feld; überlebt UI-Rebuilds) var gizmo_step_m := 0.5 # Gizmo-Schrittweite in m (Panel-Feld; überlebt UI-Rebuilds)
var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1) var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1)
var spawn_tool # src/editor/spawn_tool.gd (Zonen, Karte t_c4162c5d) var spawn_tool # src/editor/spawn_tool.gd (Zonen, Karte t_c4162c5d)
var build_tool # src/editor/build_tool.gd (Bauen-Modus, Karte t_97ea59c5) var tool_mode := "objects" # "objects" | "terrain" | "spawns" | "teststart" (Bauen = Objekt-Platzieren mit Rastern)
var tool_mode := "objects" # "objects" | "terrain" | "spawns" | "bauen" (Modular-Builder)
# Symmetrie-Modus (Karte t_f5935023, M1): Modus + Toolbar-Schalter # Symmetrie-Modus (Karte t_f5935023, M1): Modus + Toolbar-Schalter
# + Statuszeilen-Anzeige. Standard = Punktspiegelung (Sly 20.10.). M1 legt nur # + Statuszeilen-Anzeige. Standard = Punktspiegelung (Sly 20.10.). M1 legt nur
@ -104,9 +103,6 @@ var library_buttons: Dictionary = {} # weiter in assets_mod (Bequemlichkeit)
var _library_group_rows: Array = [] # weiter in assets_mod (Bequemlichkeit) var _library_group_rows: Array = [] # weiter in assets_mod (Bequemlichkeit)
var spawn_status: Label # weiter in check_mod var spawn_status: Label # weiter in check_mod
var terrain_info: Label # weiter in map_mod var terrain_info: Label # weiter in map_mod
var build_edit_btn: Button # weiter in terrain_mod
var build_panel: VBoxContainer # weiter in terrain_mod
var build_kind_buttons: Dictionary = {} # weiter in terrain_mod
var terrain_mode_lower: Button # weiter in terrain_mod (Test-Vertrag) var terrain_mode_lower: Button # weiter in terrain_mod (Test-Vertrag)
var terrain_size_btn_small: Button # weiter in terrain_mod var terrain_size_btn_small: Button # weiter in terrain_mod
var terrain_size_btn_medium: Button # weiter in terrain_mod var terrain_size_btn_medium: Button # weiter in terrain_mod
@ -406,9 +402,6 @@ func _panel_group_built(gruppe: String, tabs: TabContainer) -> void:
prop_name = map_mod.prop_name prop_name = map_mod.prop_name
spawn_status = check_mod.spawn_status spawn_status = check_mod.spawn_status
terrain_info = map_mod.terrain_info terrain_info = map_mod.terrain_info
build_edit_btn = bauen_mod.build_edit_btn
build_panel = bauen_mod.build_panel
build_kind_buttons = bauen_mod.build_kind_buttons
# Malen-Widgets wohnen im Malen-Tab; das Terrain-Modul hält die # Malen-Widgets wohnen im Malen-Tab; das Terrain-Modul hält die
# alten Namen als Aliase (Tests/Werkzeug-Vertrag). # alten Namen als Aliase (Tests/Werkzeug-Vertrag).
terrain_mod.terrain_palette = malen_mod.palette terrain_mod.terrain_palette = malen_mod.palette
@ -617,11 +610,6 @@ func _build_center_viewport() -> SubViewportContainer:
world.add_child(spawn_tool) world.add_child(spawn_tool)
spawn_tool.setup(self, camera, view_builder, interaction) spawn_tool.setup(self, camera, view_builder, interaction)
# Bauen-Werkzeug (Karte t_97ea59c5) — ditto eigener Eingabe-Knoten.
build_tool = Node3D.new()
build_tool.set_script(load("res://src/editor/build_tool.gd"))
world.add_child(build_tool)
build_tool.setup(self, camera, view_builder, interaction)
# Test-Start-Werkzeug (T1b, Wiederanbindung 20.10.): Marker in der Karte, # Test-Start-Werkzeug (T1b, Wiederanbindung 20.10.): Marker in der Karte,
# Klick setzt den Startpunkt. Lebt — wie die übrigen Werkzeuge — in main, # Klick setzt den Startpunkt. Lebt — wie die übrigen Werkzeuge — in main,
@ -1162,7 +1150,7 @@ func _start_playtest() -> void:
## Editor-Input einfrieren/freigeben (Kamera-Flug, Werkzeuge, Gizmo). ## Editor-Input einfrieren/freigeben (Kamera-Flug, Werkzeuge, Gizmo).
## Nur auf die Werkzeug-Nodes — die Sicht (view_builder) bleibt 1:1 stehen. ## Nur auf die Werkzeug-Nodes — die Sicht (view_builder) bleibt 1:1 stehen.
func _playtest_set_frozen(on: bool) -> void: func _playtest_set_frozen(on: bool) -> void:
for t in [camera, interaction, terrain_tool, spawn_tool, build_tool, test_start_tool]: for t in [camera, interaction, terrain_tool, spawn_tool, test_start_tool]:
if t == null: if t == null:
continue continue
t.set_process_unhandled_input(not on) t.set_process_unhandled_input(not on)
@ -1235,13 +1223,18 @@ func _on_open_selected(path: String) -> void:
# optisch gleich, ab jetzt ~1 m fein malbar. Die Datei ändert sich erst # optisch gleich, ab jetzt ~1 m fein malbar. Die Datei ändert sich erst
# beim nächsten Speichern (deshalb kein dirty). # beim nächsten Speichern (deshalb kein dirty).
SvmapIO.upgrade_material_layer(doc.get("terrain", {})) SvmapIO.upgrade_material_layer(doc.get("terrain", {}))
# Alte Bau-Sektion „gebaeude“ (vor 25.10.) → normale Bauteil-Objekte in einer Gruppe.
var uebernommen := BauOps.migriere_gebaeude(doc)
current_path = path current_path = path
dirty = false dirty = uebernommen > 0
history.begin(doc) history.begin(doc)
_clear_edit_state() _clear_edit_state()
_refresh_all() _refresh_all()
_test_start_reload() # Karte t_96e3c4ee: Sidecar der geladenen Map lesen _test_start_reload() # Karte t_96e3c4ee: Sidecar der geladenen Map lesen
var map_name := str(doc.get("info", {}).get("name", "?")) var map_name := str(doc.get("info", {}).get("name", "?"))
if uebernommen > 0:
_set_status("Map „%s“ geladen. %d alte Bauteile wurden in das neue Bausystem übernommen (Gruppe „Gebäude“) — Wandhöhe ist jetzt 3 m, bitte kurz prüfen und speichern." % [map_name, uebernommen])
else:
_set_status("Map „%s“ geladen (%d Objekte). Klicke ein Objekt in der Karte an, um es anzupassen." % [map_name, SvmapDocument.object_count(doc)]) _set_status("Map „%s“ geladen (%d Objekte). Klicke ein Objekt in der Karte an, um es anzupassen." % [map_name, SvmapDocument.object_count(doc)])
@ -1347,7 +1340,6 @@ func _update_tool_label() -> void:
var names := { var names := {
"objects": "Objekte (F2)", "objects": "Objekte (F2)",
"terrain": "Terrain (F1)", "terrain": "Terrain (F1)",
"bauen": "Bauen (F3)",
"spawns": "Spawn-Zonen (F4)", "spawns": "Spawn-Zonen (F4)",
} }
tool_label.text = "Werkzeug: %s" % str(names.get(tool_mode, tool_mode)) tool_label.text = "Werkzeug: %s" % str(names.get(tool_mode, tool_mode))
@ -1489,7 +1481,7 @@ func _unhandled_key_input(event: InputEvent) -> void:
KEY_F2: KEY_F2:
_set_tool_mode("objects") _set_tool_mode("objects")
KEY_F3: KEY_F3:
_set_tool_mode("bauen") _open_bauen_tab()
KEY_F4: KEY_F4:
_set_tool_mode("spawns") _set_tool_mode("spawns")
KEY_F5: KEY_F5:
@ -1527,9 +1519,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
var r_delta := -90.0 if event.shift_pressed else 90.0 var r_delta := -90.0 if event.shift_pressed else 90.0
if ctrl and not event.alt_pressed: if ctrl and not event.alt_pressed:
# Strg+R / Strg+Umschalt+R: KIPPEN um X (±15°, Karte t_e166e656 # Strg+R / Strg+Umschalt+R: KIPPEN um X (±15°, Karte t_e166e656
# — „nicht nur rotieren, sondern auch kippen“). Im Bauen-Modus # — „nicht nur rotieren, sondern auch kippen“). Bauteile werden
# gibt es kein Kippen (Bauteile sind fix ausgerichtet). # nie gekippt (fest ausgerichtet).
if tool_mode == "bauen": if BauKatalog.ist_bau(interaction.place_oid):
return return
var t_delta: float = -interaction.TILT_STEP_DEG if event.shift_pressed else interaction.TILT_STEP_DEG var t_delta: float = -interaction.TILT_STEP_DEG if event.shift_pressed else interaction.TILT_STEP_DEG
if interaction.placing: if interaction.placing:
@ -1540,7 +1532,7 @@ func _unhandled_key_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif event.alt_pressed and not ctrl: elif event.alt_pressed and not ctrl:
# Alt+R / Alt+Umschalt+R: KIPPEN um Z (±15°, Karte t_e166e656). # Alt+R / Alt+Umschalt+R: KIPPEN um Z (±15°, Karte t_e166e656).
if tool_mode == "bauen": if BauKatalog.ist_bau(interaction.place_oid):
return return
var t_delta2: float = -interaction.TILT_STEP_DEG if event.shift_pressed else interaction.TILT_STEP_DEG var t_delta2: float = -interaction.TILT_STEP_DEG if event.shift_pressed else interaction.TILT_STEP_DEG
if interaction.placing: if interaction.placing:
@ -1550,14 +1542,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
interaction.tilt_selected(false, t_delta2) interaction.tilt_selected(false, t_delta2)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif not ctrl and not event.alt_pressed: elif not ctrl and not event.alt_pressed:
# R = Vierteldrehung 90° (Karte t_55257792): Bauen-Modus dreht # R = Vierteldrehung 90° (Karte t_55257792): Objekt-Auswahl UND Ghost
# Vorschau/Auswahl (wie bisher), Objekt-Auswahl UND Ghost drehen # drehen in 90°-Schritten (Umschalt+R = zurück). Feindrehen 15°
# jetzt auch in 90°-Schritten (Umschalt+R = zurück). Feindrehen # bleibt am Mausrad (Bauteile: 90° pro Rad-Tick).
# 15° bleibt am Mausrad.
if tool_mode == "bauen":
build_tool.call("rotate_input")
get_viewport().set_input_as_handled()
return
if interaction.placing: if interaction.placing:
interaction.place_rot_deg = wrapf(interaction.place_rot_deg + r_delta, 0.0, 360.0) interaction.place_rot_deg = wrapf(interaction.place_rot_deg + r_delta, 0.0, 360.0)
interaction._ghost_rotation_anwenden() interaction._ghost_rotation_anwenden()
@ -1601,9 +1588,6 @@ func _unhandled_key_input(event: InputEvent) -> void:
"exit_spawns": "exit_spawns":
_set_tool_mode("objects") _set_tool_mode("objects")
_set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.") _set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.")
"exit_bauen":
_set_tool_mode("objects")
_set_status("Bauen-Modus beendet — zurück zum Objekt-Werkzeug.")
"exit_teststart": "exit_teststart":
# Der gesetzte Test-Start bleibt bestehen (nur Werkzeug aus). # Der gesetzte Test-Start bleibt bestehen (nur Werkzeug aus).
_set_tool_mode("objects") _set_tool_mode("objects")
@ -1624,8 +1608,6 @@ func _unhandled_key_input(event: InputEvent) -> void:
return return
if tool_mode == "spawns": if tool_mode == "spawns":
spawn_tool.call("delete_selected") spawn_tool.call("delete_selected")
elif tool_mode == "bauen":
build_tool.call("delete_selected")
elif tool_mode == "teststart": elif tool_mode == "teststart":
# Karte t_96e3c4ee: Entf im Test-Start-Werkzeug = Punkt löschen. # Karte t_96e3c4ee: Entf im Test-Start-Werkzeug = Punkt löschen.
_test_start_delete() _test_start_delete()
@ -1640,26 +1622,17 @@ func _unhandled_key_input(event: InputEvent) -> void:
if interaction.placing: if interaction.placing:
return return
if ctrl: if ctrl:
# Strg+Bild↑ (Karte t_55257792): Auswahl/Bauteil auf den Boden. # Strg+Bild↑ (Karte t_55257792): Auswahl auf den Boden.
if tool_mode == "bauen": if tool_mode == "objects" and interaction.selected_index >= 0:
build_tool.call("snap_selected_to_floor")
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.snap_selection_to_floor() interaction.snap_selection_to_floor()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif not ctrl: elif not ctrl:
if tool_mode == "bauen": if tool_mode == "objects" and interaction.selected_index >= 0:
build_tool.call("lift_selected", ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.lift_selected(ObjectOps.LIFT_STEP_M) interaction.lift_selected(ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
KEY_PAGEDOWN: KEY_PAGEDOWN:
if not ctrl and not interaction.placing: if not ctrl and not interaction.placing:
if tool_mode == "bauen": if tool_mode == "objects" and interaction.selected_index >= 0:
build_tool.call("lift_selected", -ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.lift_selected(-ObjectOps.LIFT_STEP_M) interaction.lift_selected(-ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT: KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT:
@ -1681,10 +1654,7 @@ func _unhandled_key_input(event: InputEvent) -> void:
side = -1 side = -1
var delta := MoveMath.nudge_delta( var delta := MoveMath.nudge_delta(
float(camera.call("view_yaw_deg")), fwd, side) float(camera.call("view_yaw_deg")), fwd, side)
if tool_mode == "bauen": if tool_mode == "objects" and interaction.selected_index >= 0:
build_tool.call("nudge_selected", delta)
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.nudge_selection(delta) interaction.nudge_selection(delta)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
KEY_BRACKETLEFT, KEY_BRACKETRIGHT: KEY_BRACKETLEFT, KEY_BRACKETRIGHT:
@ -1727,6 +1697,48 @@ func _on_library_collapse_all() -> void:
## Der Bibliothek-Button des aktiven Platzier-Objekts leuchtet auf. ## Der Bibliothek-Button des aktiven Platzier-Objekts leuchtet auf.
func _highlight_library(oid: String) -> void: func _highlight_library(oid: String) -> void:
assets_mod.highlight_library(oid) assets_mod.highlight_library(oid)
if bauen_mod != null:
bauen_mod.sync_platzierung()
## Bauen-Tab: Gebäude-Info nachziehen (nach Setzen/Löschen/Undo).
func _refresh_bau_info() -> void:
if bauen_mod != null:
bauen_mod.refresh_info()
## „Neues Gebäude beginnen“: nächstes Teil startet eine neue Gruppe.
func _bau_neues_gebaeude() -> void:
interaction.call("neues_gebaeude")
_refresh_bau_info()
_set_status("Neues Gebäude: das nächste Bauteil beginnt eine neue Gruppe.")
## Alle Teile des Gebäudes wählen, zu dem die Auswahl gehört.
func _bau_gebaeude_waehlen() -> bool:
var sel: Array = interaction.selected_indices
if sel.is_empty():
_set_status("Nichts markiert — klicke zuerst ein Bauteil des Gebäudes an.", true)
return false
var alle := {}
for i in sel:
for m in BauOps.gebaeude_von(doc, int(i)):
alle[int(m)] = true
var liste: Array = alle.keys()
liste.sort()
interaction.call("select_many", liste, true)
_set_status("Ganzes Gebäude gewählt (%d Teile) — Ziehen verschiebt, R dreht, Strg+D kopiert, Entf löscht." % liste.size())
return true
func _bau_gebaeude_kopieren() -> void:
if _bau_gebaeude_waehlen():
interaction.call("duplicate_selected")
func _bau_gebaeude_loeschen() -> void:
if _bau_gebaeude_waehlen():
interaction.call("delete_selected")
func object_label(oid: String) -> String: func object_label(oid: String) -> String:
@ -1793,8 +1805,11 @@ func _on_right_tab_changed(idx: int) -> void:
terrain_mod.terrain_mode_paint.button_pressed = true terrain_mod.terrain_mode_paint.button_pressed = true
_set_tool_mode("terrain") _set_tool_mode("terrain")
elif tab == bauen_mod: elif tab == bauen_mod:
if tool_mode != "bauen": if tool_mode != "objects":
_set_tool_mode("bauen") _set_tool_mode("objects")
# Das Verlassen des Terrain-Werkzeugs schaltet den Tab auf Malen/Terrain
# zurück — der Nutzer wollte aber Bauen.
(ui_right_panel as TabContainer).current_tab = bauen_mod.get_index()
func _on_terrain_btn_pressed() -> void: func _on_terrain_btn_pressed() -> void:
@ -1812,34 +1827,24 @@ func _set_tool_mode(mode: String) -> void:
if mode == tool_mode: if mode == tool_mode:
_sync_terrain_button() _sync_terrain_button()
_sync_spawn_button() _sync_spawn_button()
_sync_build_button()
_sync_test_start_button() _sync_test_start_button()
_update_tool_label() _update_tool_label()
return return
if mode == "terrain": if mode == "terrain":
_leave_spawn_mode() _leave_spawn_mode()
_leave_build_mode()
_leave_test_start_mode() _leave_test_start_mode()
_enter_terrain_mode() _enter_terrain_mode()
elif mode == "spawns": elif mode == "spawns":
_leave_terrain_mode() _leave_terrain_mode()
_leave_build_mode()
_leave_test_start_mode() _leave_test_start_mode()
_enter_spawn_mode() _enter_spawn_mode()
elif mode == "bauen":
_leave_terrain_mode()
_leave_spawn_mode()
_leave_test_start_mode()
_enter_build_mode()
elif mode == "teststart": elif mode == "teststart":
_leave_terrain_mode() _leave_terrain_mode()
_leave_spawn_mode() _leave_spawn_mode()
_leave_build_mode()
_enter_test_start_mode() _enter_test_start_mode()
else: else:
_leave_terrain_mode() _leave_terrain_mode()
_leave_spawn_mode() _leave_spawn_mode()
_leave_build_mode()
_leave_test_start_mode() _leave_test_start_mode()
_enter_objects_mode() _enter_objects_mode()
_update_tool_label() _update_tool_label()
@ -1898,50 +1903,27 @@ func _enter_objects_mode() -> void:
_base_tool_status() _base_tool_status()
# ------------------------------------------------------ Bauen-Werkzeug ------ # ------------------------------------------------------------- Bauen ------
# (Karte t_97ea59c5 — Modular-Builder-Pieces direkt im App-Skelett; die # Bauteile sind normale Bibliotheks-Objekte (bau_*): das Bauen-Panel startet die
# Godot-Editor-UI des Plugins bleibt unangetastet im Addon aktivierbar.) # Platzierung in interaction.gd (Ghost rastet über BuildSnap). Kein eigener
# U1: die Bauteil-Widgets wohnen im Terrain-Tab (terrain_panel.gd, Werkzeug- # Werkzeug-Modus mehr — Auswahl, Drehen, Kopieren, Löschen, Undo laufen über die
# Zusammenhang); die Werkzeug-Logik bleibt hier im Koordinator. Tests/Tools # Objekt-Werkzeuge.
# erreichen sie über main.build_edit_btn/main.build_kind_buttons wie vorher.
func _on_build_btn_pressed() -> void: ## F3 / Menü: Bauen-Tab zeigen (wechselt aus Terrain-/Spawn-Modus zurück zu Objekten).
if build_edit_btn.button_pressed: func _open_bauen_tab() -> void:
_set_tool_mode("bauen") if tool_mode != "objects":
else:
_set_tool_mode("objects") _set_tool_mode("objects")
func _on_build_kind_pressed(kind_id: String) -> void:
if tool_mode != "bauen":
_set_tool_mode("bauen")
for other in build_kind_buttons:
(build_kind_buttons[other] as Button).button_pressed = false
(build_kind_buttons[kind_id] as Button).button_pressed = true
build_tool.call("set_kind", kind_id)
func _enter_build_mode() -> void:
interaction.call("cancel_placement")
interaction.call("clear_selection", true)
tool_mode = "bauen"
help_label.text = "Linksklick: Bauteil setzen · R: drehen · Entf: löschen · H: Hilfe"
build_edit_btn.button_pressed = true
if ui_right_panel is TabContainer and bauen_mod != null: if ui_right_panel is TabContainer and bauen_mod != null:
(ui_right_panel as TabContainer).current_tab = bauen_mod.get_index() (ui_right_panel as TabContainer).current_tab = bauen_mod.get_index()
build_tool.call("activate") _set_status("Bauen: Bauteil rechts anklicken, dann in die Karte klicken — Wände rasten an Zellkanten, Böden in die Zellmitte, alles aufs 2-m-Raster.")
_set_status("Bauen-Modus an: Bauteil rechts anklicken, dann in die Karte klicken — alles rastet aufs 2-m-Raster.")
func _leave_build_mode() -> void: ## Bauteil im Bauen-Tab angeklickt → Platzieren mit Snap starten.
if build_tool != null: func _on_bauteil_gewaehlt(oid: String) -> void:
build_tool.call("deactivate") if tool_mode != "objects":
_sync_build_button() _set_tool_mode("objects")
interaction.call("start_placement", oid)
func _sync_build_button() -> void:
build_edit_btn.button_pressed = tool_mode == "bauen"
func _sync_terrain_button() -> void: func _sync_terrain_button() -> void:
@ -2067,6 +2049,7 @@ func _registry_toolbar_button(id: String) -> Control:
## Nach undo/redo/platzieren/löschen: Struktur hat sich geändert → Ansicht neu. ## Nach undo/redo/platzieren/löschen: Struktur hat sich geändert → Ansicht neu.
func _after_structure_change() -> void: func _after_structure_change() -> void:
dirty = true dirty = true
_refresh_bau_info()
_refresh_file_label() _refresh_file_label()
_refresh_stats() _refresh_stats()
_refresh_history_buttons() _refresh_history_buttons()
@ -2087,8 +2070,6 @@ func _after_structure_change() -> void:
interaction.call("refresh_terrain_cache") # t_4092e3a0: Objekt-Cursor trifft das Gelände interaction.call("refresh_terrain_cache") # t_4092e3a0: Objekt-Cursor trifft das Gelände
if tool_mode == "spawns" and spawn_tool != null: if tool_mode == "spawns" and spawn_tool != null:
spawn_tool.call("refresh_from_doc") spawn_tool.call("refresh_from_doc")
if tool_mode == "bauen" and build_tool != null:
build_tool.call("refresh_from_doc")
# --------------------------------------------------------------- Anzeige --- # --------------------------------------------------------------- Anzeige ---
@ -2205,7 +2186,7 @@ func _refresh_stats() -> void:
var poly_pct := 100.0 * poly / budget var poly_pct := 100.0 * poly / budget
stats_objects.text = "%d Objekte · Poly %.0f %%" % [count, poly_pct] stats_objects.text = "%d Objekte · Poly %.0f %%" % [count, poly_pct]
stats_objects.tooltip_text = "Objekte: %d / 5000\nPoly-Budget: %s / %s (%.0f %%)\nBauteile: %d" % [ stats_objects.tooltip_text = "Objekte: %d / 5000\nPoly-Budget: %s / %s (%.0f %%)\nBauteile: %d" % [
count, _fmt_int(int(poly)), _fmt_int(budget), poly_pct, BuildOps.piece_count(doc)] count, _fmt_int(int(poly)), _fmt_int(budget), poly_pct, BauOps.teile_zahl(doc)]
# Nur bei knappem Budget auffällig färben (sonst ruhiger Nebentext). # Nur bei knappem Budget auffällig färben (sonst ruhiger Nebentext).
stats_objects.add_theme_color_override("font_color", get_theme_color( stats_objects.add_theme_color_override("font_color", get_theme_color(
"warning" if poly_pct >= 90.0 or count >= 4500 else "field", "Editor")) "warning" if poly_pct >= 90.0 or count >= 4500 else "field", "Editor"))
@ -2641,42 +2622,28 @@ func _start_groups_demo(args: PackedStringArray) -> void:
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
## `godot ... -- --bauen-demo [ordner]` — Desktop-Smoke (Karte t_97ea59c5): ## `godot ... -- --bauen-demo [ordner]` — Desktop-Smoke: baut programmatisch ein
## baut programmatisch ein kleines Haus (Fundament+Wände+Giebel+Dach+Tür-Wand ## kleines Haus mit denselben Funktionen wie der Klick (BauSnap + BauOps), zeigt
## +Treppe), zeigt Auswahl + Ghost, speichert/lädt die Map zum Beweis. Die App ## Ghost, Gebäude-Auswahl und Weiterbauen nach Speichern/Laden. Die App bleibt
## bleibt offen (Sly sieht das Ergebnis live). ## offen (Sly sieht das Ergebnis live).
func _start_bauen_demo(args: PackedStringArray) -> void: func _start_bauen_demo(args: PackedStringArray) -> void:
var out_dir := "/tmp/scopedvr_bauen" var out_dir := "/tmp/scopedvr_bauen"
var idx := args.find("--bauen-demo") var idx := args.find("--bauen-demo")
if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"): if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"):
out_dir = args[idx + 1] out_dir = args[idx + 1]
DirAccess.make_dir_recursive_absolute(out_dir) DirAccess.make_dir_recursive_absolute(out_dir)
_set_tool_mode("objects")
_set_tool_mode("bauen") interaction.call("start_placement", "bau_wand")
var bt = build_tool # untypisiert (script-erweitert) — kein := möglich interaction.call("demo_park_bau", Vector3(2.0, 0.0, 1.0))
# 1) Ghost-Vorschau am Cursor + erstes Fundament.
bt.call("demo_park_ghost", "wand", Vector3(2.0, 0.2, 2.0), 0.0)
_demo_camera(Vector3(3.0, 2.0, 8.0), 14.0, 32.0, 25.0) _demo_camera(Vector3(3.0, 2.0, 8.0), 14.0, 32.0, 25.0)
_set_status("Demo 1/3: Bauen-Modus — Bauteil rechts anklicken, die gelbe Vorschau rastet aufs 2-m-Raster.") _set_status("Demo 1/3: Bauen — die Vorschau rastet auf die Zellkante ein.")
await _demo_shot(out_dir + "/bauen_ghost.png") await _demo_shot(out_dir + "/bauen_ghost.png")
# 2) Kleines Haus komplett gesetzt (jedes Teil = EIN Undo-Schritt): interaction.call("cancel_placement")
# Fundament, 3 Wände, Tür-Wand, 2 Giebelwände, Dach, Treppe. var gid := BauOps.haus_demo(doc, history, Vector3(4.0, 0.0, 4.0))
bt.call("demo_place", "fundament", Vector3(3.0, 0.0, 3.0), 0.0)
bt.call("demo_place", "wand", Vector3(2.0, 0.2, 2.0), 0.0)
bt.call("demo_place", "wand", Vector3(2.0, 0.2, 4.0), 0.0)
bt.call("demo_place", "wand", Vector3(2.0, 0.2, 4.0), 90.0)
bt.call("demo_place", "tuer", Vector3(4.0, 0.2, 4.0), 90.0)
bt.call("demo_place", "giebelwand", Vector3(2.0, 2.2, 2.0), 0.0)
bt.call("demo_place", "giebelwand", Vector3(2.0, 2.2, 4.0), 0.0)
bt.call("demo_place", "dach_giebel", Vector3(3.0, 2.2, 3.0), 0.0)
bt.call("demo_place", "treppe", Vector3(8.0, 0.0, 7.0), 0.0)
_after_structure_change() _after_structure_change()
bt.call("select_index", 7) # Dach ausgewählt (orangener Rahmen) _demo_camera(Vector3(4.0, 1.5, 4.0), 13.0, 30.0, 35.0)
_demo_camera(Vector3(9.0, 4.5, 10.0), 16.0, 35.0, 40.0) _set_status("Demo 2/3: Kleines Haus gebaut (Gruppe %d) — Fundament, Wände, Tür, Fenster, Giebel, Dach, Treppe." % (gid + 1))
_set_status("Demo 2/3: Kleines Haus gebaut — Fundament, Wände, Tür-Öffnung, Giebelwände, Dach, Treppe. Dach ist ausgewählt (orange).")
await _demo_shot(out_dir + "/bauen_haus.png") await _demo_shot(out_dir + "/bauen_haus.png")
# 3) Speichern → Laden → weiterbauen (Abnahme-Flow): Map roundtrippen,
# dann eine zusätzliche Wand — sie muss nach dem Laden sauber passen.
_save_to(out_dir + "/bauen_demo.svmap") _save_to(out_dir + "/bauen_demo.svmap")
var reload := SvmapIO.load_from_file(out_dir + "/bauen_demo.svmap") var reload := SvmapIO.load_from_file(out_dir + "/bauen_demo.svmap")
if bool(reload["ok"]): if bool(reload["ok"]):
@ -2684,16 +2651,10 @@ func _start_bauen_demo(args: PackedStringArray) -> void:
history.begin(doc) history.begin(doc)
_clear_edit_state() _clear_edit_state()
_refresh_all() _refresh_all()
var before: Dictionary = history.snapshot(["gebaeude"]) _demo_camera(Vector3(4.0, 1.5, 4.0), 13.0, 30.0, 35.0)
BuildOps.ensure_block(doc).append(BuildOps.make_piece("fenster", Vector3(2.0, 0.2, 4.0), 90.0)) _set_status("Demo 3/3: Gespeichert und wieder geladen — das Haus ist als Gruppe erhalten.")
history.commit("Wand mit Fenster gesetzt", before, history.snapshot(["gebaeude"]))
_after_structure_change()
bt.call("demo_park_ghost", "fenster", Vector3(6.0, 0.2, 4.0), 90.0)
_demo_camera(Vector3(9.0, 4.5, 10.0), 16.0, 35.0, 40.0)
_set_status("Demo 3/3: Gespeichert, geladen und WEITERGEBAUT (Fenster-Wand dazwischen) — Strg+Z nimmt nur das neue Teil zurück.")
await _demo_shot(out_dir + "/bauen_weiter.png") await _demo_shot(out_dir + "/bauen_weiter.png")
print("BAUEN_DEMO_OK: 3 Screenshots in %s (%d Bauteile, Map OK)" % [ print("BAUEN_DEMO_OK: 3 Screenshots in %s (%d Bauteile, Map OK)" % [out_dir, BauOps.teile_zahl(doc)])
out_dir, BuildOps.piece_count(doc)])
if args.has("--bauen-demo-quit"): if args.has("--bauen-demo-quit"):
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
@ -2791,6 +2752,7 @@ func _on_resign_confirmed() -> void:
return return
doc = result["data"] doc = result["data"]
SvmapIO.upgrade_material_layer(doc.get("terrain", {})) # feines Material-Raster SvmapIO.upgrade_material_layer(doc.get("terrain", {})) # feines Material-Raster
BauOps.migriere_gebaeude(doc) # alte Bau-Sektion → Bauteil-Objekte
current_path = _resign_pending_path current_path = _resign_pending_path
dirty = true dirty = true
history.begin(doc) history.begin(doc)

View file

@ -1,12 +1,21 @@
extends "res://src/ui/panels/editor_panel.gd" extends "res://src/ui/panels/editor_panel.gd"
## Bauen-Tab (25.10., UI-Aufräumen): Bauteil-Auswahl für das Bauen-Werkzeug ## Bauen-Tab: Bauteile für eigene Gebäude (Fundament, Wände, Dach, Treppe) plus ein
## (Fundamente, Wände, Dächer …). Vorher im Terrain-Tab. Die Werkzeug-Logik ## paar passende Module aus dem Military-Desert-Pack. Ein Klick auf ein Teil startet
## bleibt in main.gd/build_tool.gd; main reicht die Felder unter den alten ## das Platzieren (Vorschau rastet an Zelle/Kante/Ebene, siehe build_snap.gd); jedes
## Namen (build_edit_btn, build_panel, build_kind_buttons) weiter. ## gesetzte Teil ist ein normales Objekt und gehört zum laufenden Gebäude (Gruppe).
## Gebäude kopieren/löschen/auswählen läuft über die Knöpfe unten — alles Undo-fähig.
var build_edit_btn: Button ## Pack-Module, die zum Gebäudebau passen (IDs stabil, siehe Bibliothek).
var build_panel: VBoxContainer const PACK_MODULE := [
var build_kind_buttons: Dictionary = {} # kind_id → Button {"label": "Container", "oids": ["container_001", "container_002", "container_003", "container_004",
"container_005", "container_006", "container_007", "container_008"]},
{"label": "Hangar-Tore", "oids": ["hangar_door_001", "hangar_door_002", "hangar_door_003"]},
{"label": "Betonsperren", "oids": ["barrier_001", "barrier_002", "barrier_003", "barrier_004"]},
]
var bau_buttons: Dictionary = {} # oid → Button
var info_label: Label
var _group: ButtonGroup
static func spec() -> Dictionary: static func spec() -> Dictionary:
@ -18,45 +27,107 @@ func panel_title() -> String:
func build() -> void: func build() -> void:
build_edit_btn = Button.new() _group = ButtonGroup.new()
build_edit_btn.text = "🏠 Bauen (Häuser)" _group.allow_unpress = true
build_edit_btn.custom_minimum_size = UIScale.v2(0, 48) 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."))
build_edit_btn.toggle_mode = true for gruppe in BauKatalog.GRUPPEN:
build_edit_btn.tooltip_text = "Fundamente, Wände, Dächer, Treppen und Türen im 2-Meter-Raster bauen (F3)" add_child(_field_label(str(gruppe["label"])))
build_edit_btn.pressed.connect(_on_build_btn_pressed) add_child(_knopf_raster(gruppe["oids"] as Array, true))
add_child(build_edit_btn) var pack := _pack_gruppen()
if not pack.is_empty():
build_panel = VBoxContainer.new() add_child(_separator())
build_panel.add_theme_constant_override("separation", UIScale.px(8)) add_child(_header_label("Pack-Module"))
add_child(build_panel) add_child(_muted_label("Fertige Teile aus dem Military-Desert-Pack — normale Objekte, werden auf den Boden gesetzt."))
for pg in pack:
build_panel.add_child(_field_label("Bauteil:")) add_child(_field_label(str(pg["label"])))
var build_grid := GridContainer.new() add_child(_knopf_raster(pg["oids"] as Array, false))
build_grid.columns = 2 add_child(_separator())
build_grid.add_theme_constant_override("h_separation", UIScale.px(8)) add_child(_header_label("Gebäude"))
build_grid.add_theme_constant_override("v_separation", UIScale.px(8)) info_label = _muted_label("")
build_panel.add_child(build_grid) add_child(info_label)
var build_group := ButtonGroup.new() add_child(_aktion("🏠 Neues Gebäude beginnen", "Das nächste Teil gehört zu einem neuen Gebäude (neue Gruppe).",
for kind_id in BuildOps.KINDS.keys(): func() -> void: main.call("_bau_neues_gebaeude")))
var kind_btn := Button.new() add_child(_aktion("🔲 Ganzes Gebäude auswählen", "Wählt alle Teile des Gebäudes, zu dem das markierte Teil gehört — zum Verschieben, Drehen, Kopieren.",
kind_btn.text = str(BuildOps.KINDS[kind_id]["label"]) func() -> void: main.call("_bau_gebaeude_waehlen")))
kind_btn.toggle_mode = true add_child(_aktion("📄 Gebäude kopieren", "Kopiert das ganze Gebäude als neues Gebäude (Strg+Z nimmt es zurück).",
kind_btn.button_group = build_group func() -> void: main.call("_bau_gebaeude_kopieren")))
kind_btn.custom_minimum_size = UIScale.v2(148, 44) add_child(_aktion("🗑 Gebäude löschen", "Löscht alle Teile des Gebäudes (Strg+Z holt es zurück).",
kind_btn.clip_text = true func() -> void: main.call("_bau_gebaeude_loeschen")))
kind_btn.tooltip_text = "%s — %s" % [str(BuildOps.KINDS[kind_id]["label"]), str(BuildOps.KINDS[kind_id].get("info", ""))] refresh_info()
kind_btn.pressed.connect(_on_build_kind_pressed.bind(str(kind_id)))
build_kind_buttons[str(kind_id)] = kind_btn
build_grid.add_child(kind_btn)
(build_kind_buttons["fundament"] as Button).set_pressed_no_signal(true)
func _on_build_btn_pressed() -> void: func _pack_gruppen() -> Array:
if build_edit_btn.button_pressed: var lib: Dictionary = main.manifest.get("objects", {}) if main != null else {}
main.call("_set_tool_mode", "bauen") var out: Array = []
for pg in PACK_MODULE:
var oids: Array = []
for oid in (pg["oids"] as Array):
if lib.has(oid):
oids.append(oid)
if not oids.is_empty():
out.append({"label": pg["label"], "oids": oids})
return out
func _knopf_raster(oids: Array, bau: bool) -> GridContainer:
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", UIScale.px(8))
grid.add_theme_constant_override("v_separation", UIScale.px(8))
var lib: Dictionary = main.manifest.get("objects", {})
for oid in oids:
var btn := Button.new()
btn.toggle_mode = true
btn.button_group = _group
btn.custom_minimum_size = UIScale.v2(148, 44)
btn.clip_text = true
var poly := int((lib.get(oid, {}) as Dictionary).get("poly", 0))
if bau:
btn.text = BauKatalog.label(oid)
btn.tooltip_text = "%s — %s\n%d Dreiecke" % [BauKatalog.label(oid),
str((BauKatalog.KINDS[oid] as Dictionary).get("info", "")), poly]
else: else:
main.call("_set_tool_mode", "objects") var name_de := str((lib.get(oid, {}) as Dictionary).get("name_de", oid))
btn.text = name_de
btn.tooltip_text = "%s — %d Dreiecke" % [name_de, poly]
btn.pressed.connect(_on_teil_gedrueckt.bind(str(oid), btn))
bau_buttons[str(oid)] = btn
grid.add_child(btn)
return grid
func _on_build_kind_pressed(kind_id: String) -> void: func _aktion(text: String, tip: String, cb: Callable) -> Button:
main.call("_on_build_kind_pressed", kind_id) var b := Button.new()
b.text = text
b.tooltip_text = tip
b.custom_minimum_size = UIScale.v2(0, 44)
b.pressed.connect(cb)
return b
func _on_teil_gedrueckt(oid: String, btn: Button) -> void:
if not btn.button_pressed:
main.interaction.call("cancel_placement")
return
main.call("_on_bauteil_gewaehlt", oid)
## Teil-Markierung an den laufenden Platzier-Zustand koppeln (Esc/Abbruch löst sie).
func sync_platzierung() -> void:
var aktiv := str(main.interaction.get("place_oid")) if bool(main.interaction.get("placing")) else ""
for oid in bau_buttons:
(bau_buttons[oid] as Button).set_pressed_no_signal(oid == aktiv)
## Info-Zeile: aktuelles Gebäude (Name, Teile, Dreiecke) bzw. Gesamtzahl.
func refresh_info() -> void:
if info_label == null or main == null or main.doc.is_empty():
return
var gebaeude := BauOps.gebaeude_gruppen(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"))
for g in gebaeude:
if int(g["gid"]) == gid:
text = "Aktuell: %s — %d Teile, %d Dreiecke.\n%s" % [g["name"], (g["indizes"] as Array).size(), int(g["dreiecke"]), text]
break
info_label.text = text

View file

@ -71,9 +71,6 @@ func _on_tree_selected() -> void:
var item := content_tree.get_selected() var item := content_tree.get_selected()
if item == null: if item == null:
return return
if item.has_meta("gebaeude_index"):
_on_tree_selected_gebaeude(int(item.get_meta("gebaeude_index")))
return
# Gruppe geklickt (Karte t_e3c0310a U2): alle Mitglieder auswählen. # Gruppe geklickt (Karte t_e3c0310a U2): alle Mitglieder auswählen.
if item.has_meta("group_id"): if item.has_meta("group_id"):
var g_indices: Array = [] var g_indices: Array = []
@ -111,13 +108,6 @@ func _on_tree_selected() -> void:
main.call("_set_status", "%d Objekte im Baum markiert — Entf löscht ALLE, Strg+D dupliziert ALLE (jeweils EIN Undo-Schritt)." % valid.size()) main.call("_set_status", "%d Objekte im Baum markiert — Entf löscht ALLE, Strg+D dupliziert ALLE (jeweils EIN Undo-Schritt)." % valid.size())
## Baum: Bauteil-Klick → Bauen-Modus + Teil anwählen (Karte t_97ea59c5).
func _on_tree_selected_gebaeude(gi: int) -> void:
if str(main.get("tool_mode")) != "bauen":
main.call("_set_tool_mode", "bauen")
main.build_tool.call("select_index", gi)
func mark_tree_selection(idx: int) -> void: func mark_tree_selection(idx: int) -> void:
mark_tree_selection_multi([idx]) mark_tree_selection_multi([idx])
@ -165,11 +155,10 @@ func refresh_tree() -> void:
var root := content_tree.create_item() var root := content_tree.create_item()
var objects: Array = main.doc.get("objects", []) var objects: Array = main.doc.get("objects", [])
var zones: Dictionary = main.doc.get("spawn_zones", {}) var zones: Dictionary = main.doc.get("spawn_zones", {})
var geb_list: Array = BuildOps.pieces(main.doc)
var group_ids: Array = GroupOps.active_group_ids(main.doc) var group_ids: Array = GroupOps.active_group_ids(main.doc)
# Leerer Zustand (Karte t_e3c0310a U2): Klartext-Hinweis statt leere Gruppen. # Leerer Zustand (Karte t_e3c0310a U2): Klartext-Hinweis statt leere Gruppen.
if objects.is_empty() and zones.is_empty() and geb_list.is_empty() and group_ids.is_empty(): if objects.is_empty() and zones.is_empty() and group_ids.is_empty():
var empty_item := content_tree.create_item(root) var empty_item := content_tree.create_item(root)
empty_item.set_text(0, "Noch keine Objekte — links in der Objekt-Bibliothek wählen") empty_item.set_text(0, "Noch keine Objekte — links in der Objekt-Bibliothek wählen")
empty_item.set_selectable(0, false) empty_item.set_selectable(0, false)
@ -218,16 +207,6 @@ func refresh_tree() -> void:
point_item.set_text(0, "%s (%.0f, %.0f)" % [ point_item.set_text(0, "%s (%.0f, %.0f)" % [
str(p.get("id", "?")), float(p.get("pos", [0, 0, 0])[0]), float(p.get("pos", [0, 0, 0])[2])]) str(p.get("id", "?")), float(p.get("pos", [0, 0, 0])[0]), float(p.get("pos", [0, 0, 0])[2])])
# Gebäude (Karte t_97ea59c5): Bauteile im Baum — Klick wählt das Teil an.
if not geb_list.is_empty():
var geb_item := content_tree.create_item(root)
geb_item.set_text(0, "Gebäude (%d Bauteile)" % geb_list.size())
for i in geb_list.size():
var teil: Dictionary = geb_list[i]
var g_child := content_tree.create_item(geb_item)
g_child.set_text(0, "%s #%d" % [BuildOps.kind_label(str(teil.get("id", "?"))), i + 1])
g_child.set_meta("gebaeude_index", i)
# Gruppen (Karte t_e3c0310a U2): aktive Gruppen als eigene Ebene im Baum. # Gruppen (Karte t_e3c0310a U2): aktive Gruppen als eigene Ebene im Baum.
# Klick auf eine Gruppe wählt ALLE Mitglieder in der 3D-Szene (Zwei-Wege). # Klick auf eine Gruppe wählt ALLE Mitglieder in der 3D-Szene (Zwei-Wege).
if not group_ids.is_empty(): if not group_ids.is_empty():

View file

@ -146,13 +146,10 @@ func _on_apply_pressed() -> void:
func _conflict_text(an: Dictionary) -> String: func _conflict_text(an: Dictionary) -> String:
var parts: Array = [] var parts: Array = []
var obj_n := int(an["objects"]["diff"]) var obj_n := int(an["objects"]["diff"])
var teil_n := int(an["gebaeude"]["diff"])
var spawn_n := int(an["spawns"]["diff"]) var spawn_n := int(an["spawns"]["diff"])
var boden_n := (an["terrain_cells"] as Array).size() + (an.get("material_cells", []) as Array).size() var boden_n := (an["terrain_cells"] as Array).size() + (an.get("material_cells", []) as Array).size()
if obj_n > 0: if obj_n > 0:
parts.append("%d Objekt-Paare unterscheiden sich" % obj_n) parts.append("%d Objekt-Paare unterscheiden sich" % obj_n)
if teil_n > 0:
parts.append("%d Bau-Teil-Paare unterscheiden sich" % teil_n)
if spawn_n > 0: if spawn_n > 0:
parts.append("%d Spawn-Punkt-Paare unterscheiden sich" % spawn_n) parts.append("%d Spawn-Punkt-Paare unterscheiden sich" % spawn_n)
if boden_n > 0: if boden_n > 0:

View file

@ -65,7 +65,7 @@ func _process(_delta: float) -> void:
return return
var inter = main.interaction var inter = main.interaction
var mode: String = str(main.get("tool_mode")) var mode: String = str(main.get("tool_mode"))
var want: bool = (mode == "objects" or mode == "bauen") \ var want: bool = mode == "objects" \
and not inter.selected_indices.is_empty() \ and not inter.selected_indices.is_empty() \
and not bool(inter.get("placing")) \ and not bool(inter.get("placing")) \
and main.get("playtest") == null and main.get("playtest") == null

View file

@ -178,6 +178,10 @@ static func _eintrag(oid: String) -> Dictionary:
static func mesh_fuer(oid: String) -> Mesh: static func mesh_fuer(oid: String) -> Mesh:
if oid.is_empty(): if oid.is_empty():
return null return null
# Bauteile (bau_*) entstehen aus dem Katalog — identisch zu den GLBs, die
# der Client lädt (tools/gen_bauteile.gd), aber ohne Modell-Ordner nötig.
if BauKatalog.ist_bau(oid):
return BauKatalog.mesh(oid)
if _cache.has(oid): if _cache.has(oid):
return _cache[oid] if _cache[oid] is Mesh else null return _cache[oid] if _cache[oid] is Mesh else null
for pfad in _kandidaten(oid): for pfad in _kandidaten(oid):
@ -199,6 +203,8 @@ static func mesh_fuer(oid: String) -> Mesh:
static func fehlt(oid: String) -> bool: static func fehlt(oid: String) -> bool:
if oid.is_empty(): if oid.is_empty():
return true return true
if BauKatalog.ist_bau(oid):
return false
if _cache.has(oid): if _cache.has(oid):
return not (_cache[oid] is Mesh) return not (_cache[oid] is Mesh)
for pfad in _kandidaten(oid): for pfad in _kandidaten(oid):

View file

@ -92,11 +92,8 @@ var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc
var fehlende_glbs: Array = [] # OIDs ohne echte GLB (Platzhalter-Fallback — Statuszeilen-Hinweis) var fehlende_glbs: Array = [] # OIDs ohne echte GLB (Platzhalter-Fallback — Statuszeilen-Hinweis)
var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius) var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius)
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant) var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
var gebaeude_markers: Array = [] # MeshInstance3D je Bauteil (Karte t_97ea59c5)
var grid_node: MeshInstance3D # Maßstabsgitter (Toggle „⊞ Gitter“, Karte t_7ac70014) var grid_node: MeshInstance3D # Maßstabsgitter (Toggle „⊞ Gitter“, Karte t_7ac70014)
var _gebaeude_baked: Dictionary = {} # faces/offsets für Ray-Pick (Bauen-Werkzeug)
var _gebaeude_sel: MeshInstance3D # Kontur-Mesh um das gewählte Bauteil
var _doc_ref: Dictionary = {} # letztes rebuild-Doc (Auswahl-Markierung) var _doc_ref: Dictionary = {} # letztes rebuild-Doc (Auswahl-Markierung)
var _base_mat: StandardMaterial3D var _base_mat: StandardMaterial3D
@ -118,10 +115,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
child.queue_free() child.queue_free()
object_markers = [] object_markers = []
spawn_markers = [] spawn_markers = []
gebaeude_markers = []
grid_node = null grid_node = null
_gebaeude_baked = {}
_gebaeude_sel = null
_doc_ref = data _doc_ref = data
_base_mat = StandardMaterial3D.new() _base_mat = StandardMaterial3D.new()
_base_mat.albedo_color = OBJECT_COLOR _base_mat.albedo_color = OBJECT_COLOR
@ -156,7 +150,6 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
grid_node.visible = terrain.is_empty() grid_node.visible = terrain.is_empty()
_build_objects(data.get("objects", []), manifest) _build_objects(data.get("objects", []), manifest)
_rebuild_spawn_markers(data) _rebuild_spawn_markers(data)
_build_gebaeude(data)
## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1): ## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1):
@ -628,70 +621,6 @@ func set_spawn_selected(team: int, pid: String) -> void:
label.modulate = _markierung_farbe("selection_outline") if is_sel else col label.modulate = _markierung_farbe("selection_outline") if is_sel else col
# ------------------------------------------------------------ Bauteile ------
# (Karte t_97ea59c5 — Modular-Builder im App-Skelett; Meshes kommen aus der
# CC0-Plugin-Runtime-Geometrie, Materialien = eigene flache Fallback-Farben.)
## Bauteile aus dem doc backen und als MeshInstances aufbauen — plus die
## Welt-Flächen cachen, damit das Bauen-Werkzeug präzise auf Wände/Dächer
## klicken (stapeln) und Teile auswählen kann.
func _build_gebaeude(data: Dictionary) -> void:
_gebaeude_baked = BuildOps.bake_faces(data)
var mats := {}
var list: Array = BuildOps.pieces(data)
for i in list.size():
var teil: Dictionary = list[i]
var mesh := BuildOps.piece_mesh(teil)
if mesh == null:
continue
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.material_override = BuildOps.piece_material(str(teil.get("id", "")), mats)
mi.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
mi.rotation.y = deg_to_rad(float(teil.get("rot", 0.0)))
add_child(mi)
gebaeude_markers.append(mi)
## Gebackene Welt-Flächen aller Bauteile (für das Bauen-Werkzeug: Klicken/
## Stapeln/Auswählen). Leeres faces = noch nichts gebaut.
func gebaeude_baked() -> Dictionary:
if _gebaeude_baked.is_empty():
return {"faces": PackedVector3Array(), "offsets": []}
return _gebaeude_baked
## Auswahl-Markierung (U6, t_639135dd): dezente Außenhüllen-Kontur um das
## Bauteil-Mesh — gleiche Technik wie bei den Objekten (selection_outline-
## Shader, Theme-Farbe). Keine halbtransparente Vollbox mehr, die das Teil
## verdeckt.
func set_gebaeude_selected(idx: int) -> void:
if _gebaeude_sel != null and is_instance_valid(_gebaeude_sel):
_gebaeude_sel.queue_free()
_gebaeude_sel = null
var list: Array = BuildOps.pieces(_doc_ref)
if idx < 0 or idx >= list.size():
return
var aabb := BuildOps.piece_aabb(list[idx])
if aabb.size.length() < 0.001:
return
var mesh := BuildOps.piece_mesh(list[idx])
if mesh == null:
return
# Kontur in GLEICHER Transform wie das echte Bauteil-Mesh (aabb ist
# welt-transformiert — nur als Größen-Guard; das Mesh selbst sitzt am
# Teil-Anker mit rot).
var kontur := MeshInstance3D.new()
kontur.mesh = mesh
var pos: Array = list[idx].get("pos", [0.0, 0.0, 0.0])
kontur.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
kontur.rotation.y = deg_to_rad(float(list[idx].get("rot", 0.0)))
kontur.material_override = _outline_mat_sel
add_child(kontur)
_gebaeude_sel = kontur
# ------------------------------------------------------ U6-Markierung ------- # ------------------------------------------------------ U6-Markierung -------
# (Karte t_639135dd — dezente Auswahl: Kontur statt Vollorange. Eine Quelle # (Karte t_639135dd — dezente Auswahl: Kontur statt Vollorange. Eine Quelle
# für Farben/Breiten ist das Theme; der Shader ist die Außenhüllen-Technik.) # für Farben/Breiten ist das Theme; der Shader ist die Außenhüllen-Technik.)

View file

@ -377,16 +377,14 @@ func fit_to_document(doc: Dictionary, map_mn: Vector3, map_mx: Vector3) -> void:
int(START_DISTANCE_M)) int(START_DISTANCE_M))
## Bounding-Box des Dokument-INHALTS: Objekte, Bauteile („gebaeude") und ## Bounding-Box des Dokument-INHALTS: Objekte (inkl. Bauteile) und
## Spawnpunkte — Terrain und Map-Bounds zählen NICHT (Karte K1b). ## Spawnpunkte — Terrain und Map-Bounds zählen NICHT (Karte K1b).
## Rückgabe: {mn: Vector3, mx: Vector3, count: int}; count 0 = leeres Dokument. ## Rückgabe: {mn: Vector3, mx: Vector3, count: int}; count 0 = leeres Dokument.
static func content_bounds(doc: Dictionary) -> Dictionary: static func content_bounds(doc: Dictionary) -> Dictionary:
var mn := Vector3(INF, INF, INF) var mn := Vector3(INF, INF, INF)
var mx := Vector3(-INF, -INF, -INF) var mx := Vector3(-INF, -INF, -INF)
var count := 0 var count := 0
for pts in [_content_points(doc.get("objects", []) as Array), for p: Vector3 in _content_points(doc.get("objects", []) as Array):
_content_points(doc.get("gebaeude", []) as Array)]:
for p: Vector3 in pts as Array:
mn = mn.min(p) mn = mn.min(p)
mx = mx.max(p) mx = mx.max(p)
count += 1 count += 1

View file

@ -1,8 +1,9 @@
extends SceneTree extends SceneTree
## Editor-Tests (headless): Karte t_97ea59c5 — Bauen-Modus (Modular-Builder). ## Editor-Tests (headless): Bauen-Modus (Umbau 25.10. — Bauteile sind normale
## Bibliotheks-Objekte `bau_*`, Gebäude = Gruppe).
## Ausführen: godot --headless --path . --script res://tests/test_bauen.gd ## Ausführen: godot --headless --path . --script res://tests/test_bauen.gd
## Exit 0 = alles grün. Läuft OHNE UI (BuildOps/SvmapIO/ActionHistory) — ## Exit 0 = alles grün. Läuft OHNE UI (BauKatalog/BuildSnap/BauOps/SvmapIO/
## GUI-Feinheiten (Ghost, Panel) verifiziert der Desktop-Smoke (--bauen-demo). ## ActionHistory) — Ghost/Panel prüft der Desktop-Smoke (--bauen-demo).
var failures := 0 var failures := 0
var checks := 0 var checks := 0
@ -21,7 +22,7 @@ func _init() -> void:
func _cleanup() -> void: func _cleanup() -> void:
for path in ["user://test_bauen.svmap", "user://test_bauen_legacy.svmap"]: for path in ["user://test_bauen.svmap"]:
if FileAccess.file_exists(path): if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
@ -37,189 +38,371 @@ func _check(cond: bool, label: String) -> void:
func _run_tests() -> void: func _run_tests() -> void:
_test_catalog() _test_catalog()
_test_make_piece_isolation() _test_registry()
_test_piece_meshes() _test_meshes()
_test_snap() _test_openings()
_test_bake_and_ray_pick() _test_stairs()
_test_undo_redo() _test_snap_cells_and_edges()
_test_save_load_roundtrip() _test_snap_levels()
_test_legacy_map_without_gebaeude() _test_replace_and_stack()
_test_ops_group_undo()
_test_duplicate_building()
_test_save_load()
_test_migration()
# ------------------------------------------------------------------ Tests --- # ------------------------------------------------------------------ Tests ---
func _test_catalog() -> void: func _test_catalog() -> void:
print("Katalog") print("Katalog")
_check(BuildOps.KINDS.size() == 10, "10 Bauteil-Typen im Katalog") _check(BauKatalog.KINDS.size() == 12, "12 Bauteile im Katalog")
for kind_id in BuildOps.KINDS.keys(): var in_gruppen := {}
var kind: Dictionary = BuildOps.KINDS[kind_id] for g in BauKatalog.GRUPPEN:
_check(str(kind.get("label", "")).length() > 3, "Label für %s" % str(kind_id)) for oid in (g["oids"] as Array):
_check((kind.get("punkte", []) as Array).size() >= 2, "Punkte für %s" % str(kind_id)) in_gruppen[oid] = true
_check((kind.get("raster", "") == "ecke") or (kind.get("raster", "") == "mitte"), _check(BauKatalog.KINDS.has(oid), "Gruppe verweist auf bekanntes Teil %s" % oid)
"Raster-Typ für %s" % str(kind_id)) _check(in_gruppen.size() == BauKatalog.KINDS.size(), "jedes Teil steht in genau einer Gruppe (%d)" % in_gruppen.size())
# Abnahme-Typen vorhanden: Fundament/Wand/Dach/Treppe/Tür for oid in BauKatalog.oids():
for need in ["fundament", "wand", "dach_giebel", "treppe", "tuer"]: _check(str(oid).begins_with("bau_"), "%s: ID mit bau_-Präfix" % oid)
_check(BuildOps.KINDS.has(need), "Katalog enthält %s" % need) _check(BauKatalog.label(oid).length() > 3, "%s: deutscher Name" % oid)
_check(["zelle", "kante", "ecke", "treppe"].has(BauKatalog.snap_art(oid)), "%s: Snap-Art" % oid)
func _test_make_piece_isolation() -> void: func _test_registry() -> void:
print("make_piece") print("Registry / Dateien")
var teil := BuildOps.make_piece("wand", Vector3(2.0, 0.2, 4.0), 90.0) var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://library/objects.json"))
_check(not teil.is_empty(), "Bauteil erzeugt") var lib: Dictionary = (parsed as Dictionary).get("objects", {})
_check(str(teil["id"]) == "wand", "id übernommen") for oid in BauKatalog.oids():
# pos kommt aus einem Vector3 (float32!) — Vergleiche mit Toleranz. _check(lib.has(oid), "%s steht in objects.json" % oid)
_check(absf(float(teil["pos"][0]) - 2.0) < 0.001 and absf(float(teil["pos"][1]) - 0.2) < 0.001 \ var e: Dictionary = lib.get(oid, {})
and absf(float(teil["pos"][2]) - 4.0) < 0.001, "Position übernommen") var soll := BauKatalog.registry_eintrag(oid)
_check(float(teil["rot"]) == 90.0, "Drehung übernommen") var gleich := int(e.get("poly", -1)) == int(soll["poly"]) and str(e.get("glb", "")) == str(soll["glb"]) \
# Punkte sind KOPIEN (kein Aliasing mit dem Katalog) and str(e.get("name_de", "")) == str(soll["name_de"]) \
var before_x: float = (BuildOps.KINDS["wand"]["punkte"] as Array)[1][0] and str((e.get("collision", {}) as Dictionary).get("quelle", "")) == str((soll["collision"] as Dictionary)["quelle"])
(teil["punkte"] as Array)[1][0] = 99.0 var sz: Array = (e.get("collision", {}) as Dictionary).get("size", [])
_check(float((BuildOps.KINDS["wand"]["punkte"] as Array)[1][0]) == before_x, var sz_soll: Array = (soll["collision"] as Dictionary)["size"]
"Katalog-Punkte bleiben unangetastet (keine Alias-Falle)") for k in 3:
var tuer := BuildOps.make_piece("tuer", Vector3.ZERO, 0.0) gleich = gleich and sz.size() == 3 and absf(float(sz[k]) - float(sz_soll[k])) < 0.002
_check((tuer["details"] as Array).size() == 1, "Tür-Wand hat 1 Öffnung") _check(gleich, "%s: Registry = Katalog (sonst tools/gen_bauteile.gd neu laufen lassen)" % oid)
_check(int(((tuer["details"] as Array)[0] as Dictionary)["typ"]) == BuildOps.DETAIL_DOOR, _check(str(e.get("kategorie", "")) == "bauteile", "%s: Kategorie bauteile" % oid)
"Öffnung ist eine Tür") var col: Dictionary = e.get("collision", {})
_check(BuildOps.make_piece("gibts_nicht", Vector3.ZERO, 0.0).is_empty(), _check(str(col.get("shape", "")) == "trimesh", "%s: Trimesh-Kollision" % oid)
"Unbekannte ID → leeres Teil (kein Crash)") var path := "res://library/%s" % str(col.get("quelle", ""))
_check(FileAccess.file_exists(path), "%s: Kollisionsdatei vorhanden" % oid)
var kj: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
var verts: Array = (kj as Dictionary).get("vertices", []) if kj is Dictionary else []
_check(verts.size() == int(e.get("poly", -1)) * 3, "%s: Kollisions-Dreiecke = poly (%d)" % [oid, int(e.get("poly", -1))])
_check(int(e.get("poly", 0)) <= 260, "%s: Quest-Budget (≤ 260 Dreiecke)" % oid)
func _test_piece_meshes() -> void: func _test_meshes() -> void:
print("Meshes") print("Meshes / Fußpunkt-Normalisierung")
for kind_id in BuildOps.KINDS.keys(): for oid in BauKatalog.oids():
var mesh := BuildOps.piece_mesh(BuildOps.make_piece(str(kind_id), Vector3.ZERO, 0.0)) var m := BauKatalog.mesh(oid)
_check(mesh != null and mesh.get_surface_count() > 0, "Mesh für %s" % str(kind_id)) _check(m != null and m.get_faces().size() >= 36, "%s: Mesh vorhanden" % oid)
var wand := BuildOps.piece_mesh(BuildOps.make_piece("wand", Vector3.ZERO, 0.0)) var aabb := m.get_aabb()
var tuer := BuildOps.piece_mesh(BuildOps.make_piece("tuer", Vector3.ZERO, 0.0)) _check(absf(aabb.position.y) < 0.001, "%s: Unterkante y = 0" % oid)
var wand_tris := int(wand.get_faces().size() / 3) _check(absf(aabb.position.x + aabb.size.x * 0.5) < 0.001 and absf(aabb.position.z + aabb.size.z * 0.5) < 0.001,
var tuer_tris := int(tuer.get_faces().size() / 3) "%s: XZ zentriert" % oid)
_check(wand_tris > 0 and tuer_tris > 0, "Wand/Tür-Meshes haben Dreiecke") var arr := m.surface_get_arrays(0)
_check(wand_tris != tuer_tris, "Tür-Öffnung verändert das Wand-Mesh (%d vs %d Tris)" % [wand_tris, tuer_tris]) var verts: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
# Fundament: 2×2×0,2 m — AABB prüfen (lokale Geometrie, Ursprung Fußpunkt) var nrms: PackedVector3Array = arr[Mesh.ARRAY_NORMAL]
var f := BuildOps.piece_mesh(BuildOps.make_piece("fundament", Vector3.ZERO, 0.0)) var falsch := 0
var size := f.get_aabb().size for t in range(0, verts.size() - 2, 3):
_check(absf(size.x - 2.0) < 0.01 and absf(size.z - 2.0) < 0.01 and absf(size.y - 0.2) < 0.01, if (verts[t + 2] - verts[t]).cross(verts[t + 1] - verts[t]).dot(nrms[t]) < 0.0:
"Fundament-AABB 2×0,2×2 m (ist %.2f×%.2f×%.2f)" % [size.x, size.y, size.z]) falsch += 1
_check(falsch == 0, "%s: Wicklung passt zur Normale (kein Culling-Fehler)" % oid)
var mat := m.surface_get_material(0) as StandardMaterial3D
_check(mat != null and mat.albedo_color.a > 0.99 and mat.albedo_color != Color.WHITE, "%s: Grundfarbe im Material" % oid)
var w := BauKatalog.groesse("bau_wand")
_check(absf(w.x - 2.0) < 0.01 and absf(w.y - 3.0) < 0.01 and absf(w.z - 0.2) < 0.01, "Wand 2×3×0,2 m")
var f := BauKatalog.groesse("bau_fundament")
_check(absf(f.x - 2.0) < 0.01 and absf(f.y - 0.2) < 0.01 and absf(f.z - 2.0) < 0.01, "Fundament 2×0,2×2 m")
var b := BauKatalog.groesse("bau_bruestung")
_check(absf(b.y - 1.1) < 0.01, "Brüstung 1,1 m hoch")
func _test_snap() -> void: ## Strahl durch das Kollisions-Mesh: true = trifft (Wand), false = frei (Öffnung).
print("Raster-Snap") func _trifft(oid: String, origin: Vector3, dir: Vector3) -> bool:
# "ecke" (Wand) → gerade Meter; "mitte" (Fundament) → ungerade Meter. var faces := BauKatalog.mesh(oid).get_faces()
var w := BuildOps.snapped_pos("wand", Vector3(2.7, 0.11, -3.4)) for i in int(faces.size() / 3):
_check(w.x == 2.0 and w.z == -4.0, "Wand rastet auf Zellen-Ecken (2, −4)") if SelectionMath.ray_tri(origin, dir, faces[i * 3], faces[i * 3 + 1], faces[i * 3 + 2]) >= 0.0:
_check(absf(w.y - 0.10) < 0.001, "Y rastet auf 5 cm") return true
var f := BuildOps.snapped_pos("fundament", Vector3(2.7, 0.0, 3.2)) return false
_check(f.x == 3.0 and f.z == 3.0, "Fundament rastet auf Zellen-Mitte (3, 3)")
# Verbund: Fundament Mitte (3,3) hat Ecken bei 2 und 4 → Wand auf (2, y, 4)
# grenzt bündig. (Konvention, dokumentiert in build_ops.gd.)
func _test_bake_and_ray_pick() -> void: func _test_openings() -> void:
print("Baken + Ray-Pick") print("Türen / Fenster / Scharte sind echte Löcher")
var doc := SvmapDocument.create_new("Bautest", 256.0, "mittag") var quer := Vector3(0.0, 0.0, 1.0)
# Kleines Haus: Fundament → 3 Wände + Tür-Wand → 2 Giebelwände → Dach + Treppe # Tür: Mitte (x=0) frei von Boden bis 2,2 m, daneben (x=0.8) massiv, über der Tür (y=2.6) massiv.
var pieces: Array = BuildOps.ensure_block(doc) _check(not _trifft("bau_wand_tuer", Vector3(0.0, 0.5, -1.0), quer), "Tür: Durchgang knapp über dem Boden frei")
pieces.append(BuildOps.make_piece("fundament", Vector3(3.0, 0.0, 3.0), 0.0)) _check(not _trifft("bau_wand_tuer", Vector3(0.0, 2.0, -1.0), quer), "Tür: Durchgang auf 2,0 m frei")
pieces.append(BuildOps.make_piece("wand", Vector3(2.0, 0.2, 2.0), 0.0)) # Süd _check(_trifft("bau_wand_tuer", Vector3(0.0, 2.6, -1.0), quer), "Tür: über der Tür ist Wand")
pieces.append(BuildOps.make_piece("wand", Vector3(2.0, 0.2, 4.0), 0.0)) # Nord _check(_trifft("bau_wand_tuer", Vector3(0.8, 1.0, -1.0), quer), "Tür: neben dem Rahmen ist Wand")
pieces.append(BuildOps.make_piece("wand", Vector3(2.0, 0.2, 4.0), 90.0)) # West # Fenster: 1,1–2,3 m offen.
pieces.append(BuildOps.make_piece("tuer", Vector3(4.0, 0.2, 4.0), 90.0)) # Ost + Tür _check(not _trifft("bau_wand_fenster", Vector3(0.0, 1.7, -1.0), quer), "Fenster: Öffnung auf 1,7 m frei")
pieces.append(BuildOps.make_piece("giebelwand", Vector3(2.0, 2.2, 2.0), 0.0)) _check(_trifft("bau_wand_fenster", Vector3(0.0, 0.5, -1.0), quer), "Fenster: unter der Brüstung ist Wand")
pieces.append(BuildOps.make_piece("giebelwand", Vector3(2.0, 2.2, 4.0), 0.0)) _check(_trifft("bau_wand_fenster", Vector3(0.0, 2.6, -1.0), quer), "Fenster: darüber ist Wand")
pieces.append(BuildOps.make_piece("dach_giebel", Vector3(3.0, 2.2, 3.0), 0.0)) # Schießscharte: Schlitz auf 1,3–1,7 m.
pieces.append(BuildOps.make_piece("treppe", Vector3(10.0, 0.0, 9.0), 0.0)) _check(not _trifft("bau_wand_scharte", Vector3(0.0, 1.5, -1.0), quer), "Scharte: Schlitz auf 1,5 m frei")
var baked := BuildOps.bake_faces(doc) _check(_trifft("bau_wand_scharte", Vector3(0.0, 1.0, -1.0), quer), "Scharte: darunter Wand")
var tris := int((baked["faces"] as PackedVector3Array).size() / 3) _check(_trifft("bau_wand", Vector3(0.0, 1.5, -1.0), quer), "Volle Wand ist dicht")
_check(tris > 100, "Haus gebacken: %d Dreiecke" % tris)
_check((baked["offsets"] as Array).size() == 9, "Offsets je Teil (9)")
_check(int(baked["offsets"][0]) == 0, "erstes Teil startet bei Dreieck 0")
_check(int(baked["offsets"][8]) < tris, "letztes Teil liegt im Bereich")
# Ray von oben → trifft Dach (Teil-Index 7)
var pick := BuildOps.ray_pick(baked, Vector3(3.0, 50.0, 3.0), Vector3(0.0, -1.0, 0.0))
_check(not pick.is_empty(), "Ray von oben trifft das Haus")
_check(int(pick["piece"]) >= 0 and int(pick["piece"]) < 9, "Treffer gehört zu einem Teil (Index %d)" % int(pick["piece"]))
# Ray von der Seite auf Wandhöhe, daneben der Türöffnung → Teil 4 (Tür-Wand)
var side := BuildOps.ray_pick(baked, Vector3(50.0, 1.0, 3.6), Vector3(-1.0, 0.0, 0.0))
_check(not side.is_empty() and int(side["piece"]) == 4,
"Seiten-Ray trifft die Tür-Wand (Index %s)" % str(side.get("piece", "?")))
# … und DURCH die Türöffnung hindurch auf die West-Wand (Teil 3)
var door := BuildOps.ray_pick(baked, Vector3(50.0, 1.0, 3.0), Vector3(-1.0, 0.0, 0.0))
_check(not door.is_empty() and int(door["piece"]) == 3,
"Ray durch die Türöffnung trifft die West-Wand (Index %s)" % str(door.get("piece", "?")))
# Leer → kein Treffer
_check(BuildOps.ray_pick({"faces": PackedVector3Array(), "offsets": []},
Vector3.ZERO, Vector3.DOWN).is_empty(), "Leeres Baken → kein Treffer")
# AABB eines Teils liegt an der richtigen Stelle (Dach über 2,2 m)
var aabb := BuildOps.piece_aabb(pieces[7])
_check(aabb.position.y > 2.0, "Dach-AABB liegt über den Wänden (y=%.2f)" % aabb.position.y)
func _test_undo_redo() -> void: func _test_stairs() -> void:
print("Undo/Redo") print("Treppe begehbar")
var doc := SvmapDocument.create_new("Undo", 256.0, "mittag") var faces := BauKatalog.mesh("bau_treppe").get_faces()
var g := BauKatalog.groesse("bau_treppe")
_check(absf(g.x - 4.0) < 0.01 and absf(g.y - 3.0) < 0.01 and absf(g.z - 2.0) < 0.01, "Treppe 4×3×2 m")
# Oberste Höhe unter x entlang der Mittellinie (z=0): steigt monoton, Stufen ≤ 22 cm.
var letzte := 0.0
var max_sprung := 0.0
var steigt := true
for k in range(1, 40):
var x := -2.0 + 4.0 * float(k) / 40.0
var top := -1.0
for i in int(faces.size() / 3):
var t := SelectionMath.ray_tri(Vector3(x, 10.0, 0.0), Vector3.DOWN,
faces[i * 3], faces[i * 3 + 1], faces[i * 3 + 2])
if t >= 0.0:
top = maxf(top, 10.0 - t)
if top < letzte - 0.001:
steigt = false
max_sprung = maxf(max_sprung, top - letzte)
letzte = top
_check(steigt, "Lauffläche steigt monoton")
_check(max_sprung <= 0.22, "Stufenhöhe ≤ 22 cm (größter Sprung %.3f)" % max_sprung)
_check(letzte > 2.7, "Oben ≥ 2,7 m erreicht (%.2f)" % letzte)
func _flach(h: float) -> Callable:
return func(_x: float, _z: float) -> float: return h
func _test_snap_cells_and_edges() -> void:
print("Snap: Zelle / Kante / Ecke / Treppe")
var kein: Array = []
var s := BuildSnap.berechne("bau_fundament", Vector3(0.3, 0.0, 0.4), 0.0, kein, _flach(0.0))
_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.0) and float(s["rot"]) == 0.0, "Wand: Kante entlang X, Drehung 0")
s = BuildSnap.berechne("bau_wand", Vector3(0.1, 0.0, 1.0), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(0.0, 0.0, 1.0) and float(s["rot"]) == 90.0, "Wand: Kante entlang Z, Drehung 90")
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.0), "R dreht die Wand um (180°), bleibt auf der Kante")
s = BuildSnap.berechne("bau_wand", Vector3(0.1, 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")
s = BuildSnap.berechne("bau_treppe", Vector3(3.3, 0.0, 1.2), 0.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(4.0, 0.0, 1.0) and float(s["rot"]) == 0.0, "Treppe: 4×2-Fläche entlang X")
s = BuildSnap.berechne("bau_treppe", Vector3(1.2, 0.0, 3.3), 90.0, kein, _flach(0.0))
_check(s["pos"] == Vector3(1.0, 0.0, 4.0) and float(s["rot"]) == 90.0, "Treppe: gedreht 90° → entlang Z")
# Alle Fälle: gerasterte Mitten (Wände auf Kanten = ein gerader + ein ungerader Meter)
for k in range(60):
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 gerade_x := int(roundf(wp.x)) % 2 == 0
var gerade_z := int(roundf(wp.z)) % 2 == 0
if not (gerade_x != gerade_z):
_check(false, "Wand-Mitte liegt auf Kantenmitte (%s)" % str(wp))
return
_check(true, "60 Zufallsklicks: Wandmitte liegt immer auf einer Kantenmitte")
# 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)")
func _teil(i: int, oid: String, pos: Vector3, rot: float) -> Dictionary:
return {"i": i, "oid": oid, "pos": pos, "rot": rot}
func _test_snap_levels() -> void:
print("Snap: Ebenen statt Klickhöhe")
var teile: Array = [
_teil(0, "bau_fundament", Vector3(1, 0, 1), 0.0),
_teil(1, "bau_wand", Vector3(1, 0.2, 0), 0.0),
_teil(2, "bau_wand", Vector3(2, 0.2, 1), 90.0),
]
# Klick mitten auf die Seite der Wand (y=1,5): Boden darf NICHT in der Luft landen
var s := BuildSnap.berechne("bau_boden", Vector3(0.9, 1.5, 0.6), 0.0, teile, _flach(0.0))
_check(absf(float(s["ebene"]) - 0.2) < 0.001, "Boden nach Klick auf Wandseite liegt auf dem Fundament (0,2), nicht bei 1,5")
# Klick auf die Oberkante der Wand: nächste Geschoss-Ebene
s = BuildSnap.berechne("bau_boden", Vector3(1.0, 3.2, 0.1), 0.0, teile, _flach(0.0), true, Vector3(0, -0.5, 1))
_check(absf(float(s["ebene"]) - 3.2) < 0.001, "Boden nach Klick auf Wandoberkante = Decke auf 3,2")
_check(s["pos"] == Vector3(1.0, 3.2, -1.0), "…und in der Zelle auf der Kameraseite (1, −1)")
# Wand auf Wand (Stapeln)
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 3.2, 0.0), 0.0, teile, _flach(0.0))
_check(absf(float(s["ebene"]) - 3.2) < 0.001 and int(s["ersetze"]) == -1, "Wand stapelt sich auf die Oberkante")
# Ein Dach ist kein Träger: nichts darf auf der Dachspitze landen
teile.append(_teil(3, "bau_dach_giebel", Vector3(1, 3.2, 1), 0.0))
s = BuildSnap.berechne("bau_boden", Vector3(1.0, 4.2, 1.0), 0.0, teile, _flach(0.0))
_check(float(s["ebene"]) <= 3.2 + 0.001, "Dachspitze (4,2) ist keine Ebene (Ebene %.2f)" % float(s["ebene"]))
func _test_replace_and_stack() -> void:
print("Ersetzen / doppelt")
var teile: Array = [
_teil(0, "bau_fundament", Vector3(1, 0, 1), 0.0),
_teil(1, "bau_wand", Vector3(1, 0.2, 0), 0.0),
]
var s := BuildSnap.berechne("bau_wand_tuer", Vector3(1.0, 1.5, 0.1), 0.0, teile, _flach(0.0))
_check(bool(s["ok"]) and int(s["ersetze"]) == 1, "Tür in vorhandene Wand → ersetzt Teil 1")
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 1.5, 0.1), 0.0, teile, _flach(0.0))
_check(not bool(s["ok"]) and str(s["grund"]).contains("schon"), "gleiche Wand nochmal → abgelehnt mit Erklärung")
s = BuildSnap.berechne("bau_wand", Vector3(1.0, 1.5, 0.1), 90.0, teile, _flach(0.0))
_check(bool(s["ok"]) and int(s["ersetze"]) == 1, "Wand von der anderen Seite (R) ersetzt = dreht um")
s = BuildSnap.berechne("bau_wand_tuer", Vector3(3.0, 0.0, 0.1), 0.0, teile, _flach(0.0))
_check(bool(s["ok"]) and int(s["ersetze"]) == -1, "Nachbarkante bleibt frei (kein Ersetzen)")
func _neue_doc() -> Dictionary:
return SvmapDocument.create_new("Bautest", 256.0, "mittag")
func _test_ops_group_undo() -> void:
print("BauOps: Gruppe, Undo, Ersetzen")
var doc := _neue_doc()
var hist := ActionHistory.new() var hist := ActionHistory.new()
hist.begin(doc) hist.begin(doc)
# Teil 1: erster Snapshot MUSS den (leeren) Block enthalten, damit Strg+Z var snap := BuildSnap.berechne("bau_fundament", Vector3(1, 0, 1), 0.0, [], _flach(0.0))
# das erste Bauteil komplett entfernen kann. var before := hist.snapshot(["objects", "group_names"])
var before: Dictionary = hist.snapshot(["gebaeude"]) if not before.has("group_names"):
_check(before.has("gebaeude"), "Snapshot enthält den leeren gebaeude-Block") before["group_names"] = null
BuildOps.ensure_block(doc).append(BuildOps.make_piece("fundament", Vector3.ZERO, 0.0)) var gid := BauOps.gruppe_neu(doc)
hist.commit("Fundament (2×2 m) gesetzt", before, hist.snapshot(["gebaeude"])) _check(GroupState.group_name(doc, gid) == "Gebäude 1", "Gebäude-Gruppe heißt „Gebäude 1“")
# Teil 2 + 3 var r := BauOps.setze_teil(doc, "bau_fundament", snap, gid, SymmetryCore.Mode.OFF)
for i in 2: hist.commit("Fundament bauen", before, hist.snapshot(["objects", "group_names"]))
before = hist.snapshot(["gebaeude"]) var objs: Array = doc["objects"]
BuildOps.ensure_block(doc).append(BuildOps.make_piece("wand", Vector3(float(i) * 2.0, 0.2, 0.0), 0.0)) var n0 := objs.size()
hist.commit("Wand (2 m) gesetzt", before, hist.snapshot(["gebaeude"])) _check(n0 >= 1 and int(r["index"]) == n0 - 1, "Teil als Objekt angehängt")
_check(BuildOps.piece_count(doc) == 3, "3 Bauteile gesetzt") _check(GroupOps.group_id(objs[n0 - 1]) == gid, "Teil gehört zum Gebäude (Gruppe %d)" % gid)
_check(BauOps.teile_zahl(doc) == 1, "teile_zahl = 1")
# Wand setzen, dann Tür ersetzt dieselbe Stelle (Index bleibt)
var teile := BuildSnap.sammle_teile(doc)
var ws := BuildSnap.berechne("bau_wand", Vector3(1, 0.2, 0.05), 0.0, teile, _flach(0.0))
var before2 := hist.snapshot(["objects", "group_names"])
var wr := BauOps.setze_teil(doc, "bau_wand", ws, gid, SymmetryCore.Mode.OFF)
hist.commit("Wand bauen", before2, hist.snapshot(["objects", "group_names"]))
var widx := int(wr["index"])
teile = BuildSnap.sammle_teile(doc)
var ts := BuildSnap.berechne("bau_wand_tuer", Vector3(1, 1.5, 0.05), 0.0, teile, _flach(0.0))
_check(int(ts["ersetze"]) == widx, "Tür-Klick findet die Wand")
var before3 := hist.snapshot(["objects", "group_names"])
var tr := BauOps.setze_teil(doc, "bau_wand_tuer", ts, gid, SymmetryCore.Mode.OFF)
hist.commit("Tür einsetzen", before3, hist.snapshot(["objects", "group_names"]))
_check(bool(tr["ersetzt"]) and (doc["objects"] as Array).size() == n0 + 1, "Ersetzen fügt kein zusätzliches Objekt hinzu")
_check(str((doc["objects"] as Array)[widx]["id"]) == "bau_wand_tuer", "Objekt ist jetzt die Tür-Wand")
_check(GroupOps.group_id((doc["objects"] as Array)[widx]) == gid, "Gruppe bleibt beim Ersetzen")
hist.undo() hist.undo()
_check(BuildOps.piece_count(doc) == 2, "Undo → 2 Bauteile") _check(str((doc["objects"] as Array)[widx]["id"]) == "bau_wand", "Undo: wieder normale Wand")
hist.undo() hist.undo()
hist.undo() hist.undo()
_check(BuildOps.piece_count(doc) == 0, "3× Undo → alles weg (inkl. erstes Teil)") _check((doc["objects"] as Array).is_empty(), "Undo ×3: alles weg")
_check(not doc.has("group_names") or (doc["group_names"] as Dictionary).is_empty(), "Undo entfernt auch den Gebäude-Namen")
hist.redo() hist.redo()
hist.redo() hist.redo()
_check(BuildOps.piece_count(doc) == 2, "2× Redo → 2 Bauteile wieder da") hist.redo()
# Löschen + Undo (wie build_tool.delete_selected) _check(BauOps.teile_zahl(doc) == 2, "Redo ×3 stellt Fundament + Tür-Wand wieder her")
before = hist.snapshot(["gebaeude"]) # Gebäude-Info
(BuildOps.pieces(doc) as Array).remove_at(0) var geb := BauOps.gebaeude_gruppen(doc)
hist.commit("Bauteil gelöscht", before, hist.snapshot(["gebaeude"])) _check(geb.size() == 1 and (geb[0]["indizes"] as Array).size() == 2, "gebaeude_gruppen: 1 Gebäude mit 2 Teilen")
_check(BuildOps.piece_count(doc) == 1, "Löschen → 1 Teil") _check(BauOps.gebaeude_von(doc, 0).size() == 2, "gebaeude_von liefert alle Mitglieder")
hist.undo() # Haus komplett
_check(BuildOps.piece_count(doc) == 2, "Undo holt gelöschtes Teil zurück") var d2 := _neue_doc()
var gid2 := BauOps.haus_demo(d2, null, Vector3(4.0, 0.0, 4.0))
_check(BauOps.teile_zahl(d2) >= 16, "Demo-Haus: %d Teile über die echte Snap-Logik" % BauOps.teile_zahl(d2))
_check(BauOps.gebaeude_von(d2, 0).size() == BauOps.teile_zahl(d2), "alle Teile des Hauses in einer Gruppe (%d)" % gid2)
var doppelt := {}
var hat_doppelte := false
for t in BuildSnap.sammle_teile(d2):
var key := "%s|%s|%s" % [str((t["pos"] as Vector3).snapped(Vector3(0.01, 0.01, 0.01))), str(roundf(float(t["rot"]))), BauKatalog.snap_art(str(t["oid"]))]
if doppelt.has(key):
hat_doppelte = true
doppelt[key] = true
_check(not hat_doppelte, "Haus hat keine doppelt gesetzten Teile")
func _test_save_load_roundtrip() -> void: func _test_duplicate_building() -> void:
print("Speichern/Laden") print("Gebäude kopieren / löschen")
var doc := SvmapDocument.create_new("Bauen-Save", 256.0, "mittag") var doc := _neue_doc()
var pieces: Array = BuildOps.ensure_block(doc) var gid := BauOps.haus_demo(doc, null, Vector3(4.0, 0.0, 4.0))
pieces.append(BuildOps.make_piece("fundament", Vector3(3.0, 0.0, 3.0), 0.0)) var anzahl := (doc["objects"] as Array).size()
pieces.append(BuildOps.make_piece("tuer", Vector3(2.0, 0.2, 2.0), 90.0)) var alle := BauOps.gebaeude_von(doc, 0)
pieces.append(BuildOps.make_piece("dach_pyramide", Vector3(3.0, 2.2, 3.0), 0.0)) var neue := ObjectOps.duplicate_objects(doc, alle, false, 8.0)
var save := SvmapIO.save_to_file("user://test_bauen.svmap", doc) _check(neue.size() == anzahl, "alle %d Teile kopiert" % anzahl)
_check(bool(save["ok"]), "Map mit Bauteilen speichert (ohne Fehler)") var gid_kopie := GroupOps.group_id((doc["objects"] as Array)[int(neue[0])])
var load := SvmapIO.load_from_file("user://test_bauen.svmap") _check(gid_kopie != gid and gid_kopie != GroupOps.UNGROUPED, "Kopie bekommt eine EIGENE Gruppe (%d ≠ %d)" % [gid_kopie, gid])
_check(bool(load["ok"]), "Map mit Bauteilen lädt (V1–V15 + Prüfsumme grün)") _check(GroupOps.members_of(doc, gid).size() == anzahl and GroupOps.members_of(doc, gid_kopie).size() == anzahl,
var loaded: Dictionary = load["data"] "Original und Kopie haben je %d Teile" % anzahl)
_check(BuildOps.piece_count(loaded) == 3, "3 Bauteile überleben .svmap") _check(GroupState.display_name(doc, gid_kopie).contains("Kopie"), "Kopie heißt „… (Kopie)“")
var src: Dictionary = pieces[1] _check(BauOps.gebaeude_gruppen(doc).size() == 2, "zwei Gebäude in der Map")
var dst: Dictionary = (BuildOps.pieces(loaded) as Array)[1] var o0: Dictionary = (doc["objects"] as Array)[0]
_check(str(dst["id"]) == "tuer" and float(dst["rot"]) == 90.0, "Bauteil-Felder identisch") var k0: Dictionary = (doc["objects"] as Array)[int(neue[0])]
_check((dst["punkte"] as Array) == (src["punkte"] as Array), "Punkte byte-gleich über den Roundtrip") _check(absf(float(k0["pos"][0]) - float(o0["pos"][0]) - 8.0) < 0.001, "Kopie um 8 m versetzt")
_check(((dst["details"] as Array)[0] as Dictionary)["w"] == 1.0, "Tür-Öffnung überlebt den Roundtrip") # Löschen: Gebäude ohne Teile verliert den Namen
# Gebackenes Mesh aus GELADENEN Teilen = wie vor dem Speichern (deterministisch) var objects: Array = doc["objects"]
var before_faces: PackedVector3Array = BuildOps.bake_faces(doc)["faces"] var del := GroupOps.members_of(doc, gid_kopie)
var after_faces: PackedVector3Array = BuildOps.bake_faces(loaded)["faces"] del.sort()
_check(before_faces == after_faces, "Baken ist deterministisch (vor/nach Speichern identisch)") del.reverse()
for i in del:
objects.remove_at(i)
GroupState.prune_orphans(doc)
_check(BauOps.gebaeude_gruppen(doc).size() == 1, "nach Löschen: 1 Gebäude")
_check(not GroupState.names_dict(doc).has(str(gid_kopie)), "Gruppenname der gelöschten Kopie ist weg")
func _test_legacy_map_without_gebaeude() -> void: func _test_save_load() -> void:
print("Legacy-Maps") print("Speichern / Laden")
var doc := SvmapDocument.create_new("Legacy", 256.0, "mittag") var doc := _neue_doc()
doc.erase("gebaeude") # alte Map ohne die Sektion BauOps.haus_demo(doc, null, Vector3(4.0, 0.0, 4.0))
var save := SvmapIO.save_to_file("user://test_bauen_legacy.svmap", doc) var n := (doc["objects"] as Array).size()
_check(bool(save["ok"]), "Map OHNE gebaeude speichert") var res := SvmapIO.save_to_file("user://test_bauen.svmap", doc)
var load := SvmapIO.load_from_file("user://test_bauen_legacy.svmap") _check(bool(res["ok"]), "Map mit Haus speichern (Validierung V1–V17 grün)")
_check(bool(load["ok"]), "Map OHNE gebaeude lädt") var back := SvmapIO.load_from_file("user://test_bauen.svmap")
_check(BuildOps.piece_count(load["data"]) == 0, "Legacy-Map: 0 Bauteile, kein Crash") _check(bool(back["ok"]), "…und wieder laden")
if FileAccess.file_exists("user://test_bauen_legacy.svmap"): if bool(back["ok"]):
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_bauen_legacy.svmap")) var d: Dictionary = back["data"]
_check((d["objects"] as Array).size() == n, "alle %d Teile nach dem Laden da" % n)
_check(BauOps.gebaeude_gruppen(d).size() == 1, "Gebäude-Gruppe erhalten")
_check(GroupState.display_name(d, 0).begins_with("Gebäude"), "Gebäude-Name erhalten")
_check(not d.has("gebaeude"), "keine alte gebaeude-Sektion mehr")
# Poly-Rechnung: Bauteile zählen im Budget wie jedes Objekt
var manifest: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://library/objects.json"))
var poly := SvmapDocument.poly_usage(doc, manifest as Dictionary)
_check(absf(poly - float(BauOps.dreiecke_gesamt(doc))) < 0.5, "Poly-Budget = Summe der Bauteil-Dreiecke (%d)" % int(poly))
func _test_migration() -> void:
print("Migration alte gebaeude-Sektion")
var doc := _neue_doc()
doc["gebaeude"] = [
{"id": "fundament", "pos": [3.0, 0.0, 3.0], "rot": 0.0, "punkte": []},
{"id": "wand", "pos": [2.0, 0.2, 2.0], "rot": 0.0, "punkte": []},
{"id": "tuer", "pos": [4.0, 0.2, 4.0], "rot": 90.0, "punkte": []},
{"id": "unbekannt", "pos": [0.0, 0.0, 0.0], "rot": 0.0},
]
var n := BauOps.migriere_gebaeude(doc)
_check(n == 3, "3 bekannte Teile übernommen (unbekannte ignoriert)")
_check(not doc.has("gebaeude"), "alte Sektion entfernt")
var objs: Array = doc["objects"]
var wand: Dictionary = {}
for o in objs:
if str(o["id"]) == "bau_wand":
wand = o
_check(not wand.is_empty() and absf(float(wand["pos"][0]) - 3.0) < 0.001 and absf(float(wand["pos"][2]) - 2.0) < 0.001,
"Wand-Ursprung wandert in die Wandmitte (2,2 → 3,2)")
var tuer: Dictionary = {}
for o in objs:
if str(o["id"]) == "bau_wand_tuer":
tuer = o
_check(not tuer.is_empty() and absf(float(tuer["pos"][0]) - 4.0) < 0.001 and absf(float(tuer["pos"][2]) - 3.0) < 0.001,
"gedrehte Tür-Wand (90°): Mitte (4, 3)")
_check(BauOps.gebaeude_gruppen(doc).size() == 1, "alle Teile in einem Gebäude")
_check(BauOps.migriere_gebaeude(doc) == 0, "zweiter Lauf tut nichts")
var leer := _neue_doc()
leer["gebaeude"] = []
_check(BauOps.migriere_gebaeude(leer) == 0 and not leer.has("gebaeude"), "leere Sektion wird still entfernt")

View file

@ -1,9 +1,9 @@
extends SceneTree extends SceneTree
## Editor-Tests (headless): Karte t_ffa207b1 — M4 Bau-Teile + Spawnpunkte ## Editor-Tests (headless): Karte t_ffa207b1 — M4 Bauteile (jetzt normale Objekte) + Spawnpunkte
## spiegeln (Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md, Mathe aus M1 ## spiegeln (Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md, Mathe aus M1
## symmetry_core.gd). Ausführen: ## symmetry_core.gd). Ausführen:
## godot --headless --path . --script res://tests/test_build_spawn_mirror.gd ## godot --headless --path . --script res://tests/test_build_spawn_mirror.gd
## Exit 0 = alles grün. Läuft OHNE UI (BuildOps/SpawnOps-Statics + ## Exit 0 = alles grün. Läuft OHNE UI (BauOps/SpawnOps-Statics +
## ActionHistory) — GUI-Feinheiten (Statuszeile, Ghost) verifiziert der ## ActionHistory) — GUI-Feinheiten (Statuszeile, Ghost) verifiziert der
## Desktop-Smoke, nicht hier. ## Desktop-Smoke, nicht hier.
## ##
@ -50,10 +50,7 @@ func _check(cond: bool, label: String) -> void:
func _run_tests() -> void: func _run_tests() -> void:
_test_build_place_point() _test_build_place_point()
_test_build_place_axes() _test_build_place_axes()
_test_build_snap_parity() _test_build_replace_follows()
_test_build_remove()
_test_build_update_follows()
_test_build_v9_bounds()
_test_build_off_gegenprobe() _test_build_off_gegenprobe()
_test_build_one_undo_step() _test_build_one_undo_step()
_test_spawn_add_mirror() _test_spawn_add_mirror()
@ -68,13 +65,6 @@ func _new_doc() -> Dictionary:
return SvmapDocument.create_new("Sym-M4-Test", 256.0, "mittag") return SvmapDocument.create_new("Sym-M4-Test", 256.0, "mittag")
func _piece_at(doc: Dictionary, index: int) -> Dictionary:
var list := BuildOps.pieces(doc)
if index < 0 or index >= list.size() or not (list[index] is Dictionary):
return {}
return list[index] as Dictionary
func _pos3(p: Dictionary) -> Vector3: func _pos3(p: Dictionary) -> Vector3:
var pos: Array = p.get("pos", [0.0, 0.0, 0.0]) var pos: Array = p.get("pos", [0.0, 0.0, 0.0])
return Vector3(float(pos[0]), float(pos[1]), float(pos[2])) return Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
@ -84,217 +74,89 @@ func _near(a: float, b: float) -> bool:
return absf(a - b) < 0.001 return absf(a - b) < 0.001
# ------------------------------------------------------- Bau-Teile: setzen --- # ---------------------------------------- Bauteile (normale Objekte) --------
func _flach() -> Callable:
return func(_x: float, _z: float) -> float: return 0.0
## Wand per echter Snap-Logik setzen (Hit knapp neben der Kantenmitte).
func _setze(doc: Dictionary, oid: String, hit: Vector3, mode: int, gid: int, spiegel := -1) -> Dictionary:
var snap := BuildSnap.berechne(oid, hit, 0.0, BuildSnap.sammle_teile(doc), _flach())
return BauOps.setze_teil(doc, oid, snap, gid, mode, spiegel)
func _test_build_place_point() -> void: func _test_build_place_point() -> void:
print("Testgruppe: Bau-Teil setzen → Zwilling (Punktspiegelung)") print("Testgruppe: Bauteil setzen → Zwilling (Punktspiegelung)")
var doc := _new_doc() var doc := _new_doc()
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.2, 20.0), 90.0) var gid := BauOps.gruppe_neu(doc)
BuildOps.ensure_block(doc).append(teil) var r := _setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), POINT, gid, gid + 1)
var msg: String = BuildOps.place_mirrored(doc, teil, POINT) var objs: Array = doc["objects"]
_check(msg == "", "Punktspiegelung: still gespiegelt (keine Meldung)") _check(objs.size() == 2 and int(r["twin"]) == 1, "Original + Zwilling im Objekt-Block")
_check(BuildOps.piece_count(doc) == 2, "2 Bauteile nach dem Setzen") var orig: Dictionary = objs[0]
var twin := _piece_at(doc, 1) var twin: Dictionary = objs[1]
_check(str(twin.get("id", "")) == "wand", "Zwilling hat gleiche kind_id") _check(str(twin["id"]) == "bau_wand", "Zwilling hat gleiche ID")
_check(_near(_pos3(twin).x, -10.0) and _near(_pos3(twin).y, 0.2) and _near(_pos3(twin).z, -20.0), var op := _pos3(orig)
"Zwilling-Position gespiegelt (−10, 0.2, −20)") var tp := _pos3(twin)
_check(_near(float(twin.get("rot", 0.0)), 270.0), "Zwilling-Drehung 90° + 180° = 270°") _check(_near(tp.x, -op.x) and _near(tp.z, -op.z) and _near(tp.y, op.y), "Zwilling-Position punktgespiegelt")
var key := SymmetryCore.sym_of(teil) _check(_near(fposmod(float(twin["rot_y_deg"]) - float(orig["rot_y_deg"]), 360.0), 180.0), "Zwilling um 180° gedreht")
_check(not key.is_empty() and SymmetryCore.sym_of(twin) == key, var key := SymmetryCore.sym_of(orig)
"beide Teile tragen dieselbe sym-Kennung") _check(not key.is_empty() and SymmetryCore.sym_of(twin) == key, "gleiche sym-Kennung")
# V16-Realität: GENAU 2 Einträge pro Kennung. _check(GroupOps.group_id(orig) == gid and GroupOps.group_id(twin) == gid + 1, "Zwilling bekommt das Spiegel-Gebäude")
var n := 0
for t in BuildOps.pieces(doc):
if SymmetryCore.sym_of(t as Dictionary) == key:
n += 1
_check(n == 2, "sym-Kennung verbindet genau 2 Teile")
# Zwilling ist eine tiefe Kopie (keine Alias-Falle bei punkte).
var first_x: float = (teil["punkte"] as Array)[0][0]
(teil["punkte"] as Array)[0][0] = 123.0
_check(_near(float((twin["punkte"] as Array)[0][0]), first_x),
"Zwilling-Punkte sind Kopien (kein Aliasing)")
func _test_build_place_axes() -> void: func _test_build_place_axes() -> void:
print("Testgruppe: Bau-Teil setzen → Zwilling (Achse X / Achse Z)") print("Testgruppe: Bauteil setzen → Zwilling (Achsen X / Z)")
for mode in [AXIS_X, AXIS_Z]:
var doc := _new_doc() var doc := _new_doc()
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0) var gid := BauOps.gruppe_neu(doc)
BuildOps.ensure_block(doc).append(teil) _setze(doc, "bau_fundament", Vector3(11.0, 0.0, 21.0), mode, gid, gid + 1)
BuildOps.place_mirrored(doc, teil, AXIS_X) var objs: Array = doc["objects"]
var twin := _piece_at(doc, 1) _check(objs.size() == 2, "Modus %d: Zwilling da" % mode)
_check(_near(_pos3(twin).x, -10.0) and _near(_pos3(twin).z, 20.0), var op := _pos3(objs[0])
"Achse X: x gespiegelt, z bleibt") var tp := _pos3(objs[1])
_check(_near(float(twin.get("rot", 0.0)), 270.0), "Achse X: Drehung −90° = 270°") if mode == AXIS_X:
var doc2 := _new_doc() _check(_near(tp.x, -op.x) and _near(tp.z, op.z), "Achse X: x gespiegelt, z gleich")
var teil2 := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0) else:
BuildOps.ensure_block(doc2).append(teil2) _check(_near(tp.z, -op.z) and _near(tp.x, op.x), "Achse Z: z gespiegelt, x gleich")
BuildOps.place_mirrored(doc2, teil2, AXIS_Z)
var twin2 := _piece_at(doc2, 1)
_check(_near(_pos3(twin2).x, 10.0) and _near(_pos3(twin2).z, -20.0),
"Achse Z: z gespiegelt, x bleibt")
_check(_near(float(twin2.get("rot", 0.0)), 270.0), "Achse Z: Drehung −90° = 270°")
# Auf der Mitte: nicht gedoppelt (Spec vc27).
var doc3 := _new_doc()
var mitte := BuildOps.make_piece("wand", Vector3(0.0, 0.0, 0.0), 0.0)
BuildOps.ensure_block(doc3).append(mitte)
var msg: String = BuildOps.place_mirrored(doc3, mitte, POINT)
_check(msg.contains("nicht gedoppelt"), "Bauteil auf der Mitte: Meldung statt Zwilling")
_check(BuildOps.piece_count(doc3) == 1, "Auf der Mitte: nur 1 Bauteil")
_check(SymmetryCore.sym_of(mitte).is_empty(), "Auf der Mitte: kein sym-Link")
## Gleiche Snap-Regeln: Spiegelung erhält die Raster-Parität func _test_build_replace_follows() -> void:
## (Raster „ecke" = gerade Meter, „mitte" = ungerade Meter) bei zentrierter Karte. print("Testgruppe: Tür einsetzen → Zwilling bekommt auch die Tür")
func _test_build_snap_parity() -> void:
print("Testgruppe: Snap-Parität bleibt erhalten")
var doc := _new_doc() var doc := _new_doc()
var ecke := BuildOps.make_piece("wand", Vector3(2.0, 0.0, 4.0), 0.0) var gid := BauOps.gruppe_neu(doc)
BuildOps.ensure_block(doc).append(ecke) _setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), POINT, gid, gid + 1)
BuildOps.place_mirrored(doc, ecke, POINT) var r := _setze(doc, "bau_wand_tuer", Vector3(11.0, 1.5, 20.1), POINT, gid, gid + 1)
_check(_near(_pos3(_piece_at(doc, 1)).x, -2.0) and _near(_pos3(_piece_at(doc, 1)).z, -4.0), var objs: Array = doc["objects"]
"ecke-Teil: gerade Meter bleiben gerade (−2, −4)") _check(bool(r["ersetzt"]) and objs.size() == 2, "ersetzt statt anhängen")
var doc2 := _new_doc() _check(str(objs[0]["id"]) == "bau_wand_tuer" and str(objs[1]["id"]) == "bau_wand_tuer", "Original UND Zwilling haben die Tür")
var mitte := BuildOps.make_piece("fundament", Vector3(3.0, 0.0, 3.0), 0.0)
BuildOps.ensure_block(doc2).append(mitte)
BuildOps.place_mirrored(doc2, mitte, POINT)
_check(_near(_pos3(_piece_at(doc2, 1)).x, -3.0) and _near(_pos3(_piece_at(doc2, 1)).z, -3.0),
"mitte-Teil: ungerade Meter bleiben ungerade (−3, −3)")
# ----------------------------------------------------- Bau-Teile: entfernen ---
func _test_build_remove() -> void:
print("Testgruppe: Bau-Teil entfernen → beide weg")
var doc := _new_doc()
var teil := BuildOps.make_piece("boden", Vector3(11.0, 0.0, 21.0), 0.0)
BuildOps.ensure_block(doc).append(teil)
BuildOps.place_mirrored(doc, teil, POINT)
_check(BuildOps.piece_count(doc) == 2, "Vorbereitung: 2 Bauteile")
var msg: String = BuildOps.remove_mirrored(doc, 0, POINT)
_check(BuildOps.piece_count(doc) == 0, "Original löschen → Zwilling auch weg")
_check(msg.contains("Zwilling"), "Status meldet den mitgelöschten Zwilling")
# Umgekehrt: Zwilling löschen (Index 1) → Original mit weg.
var doc2 := _new_doc()
var teil2 := BuildOps.make_piece("boden", Vector3(11.0, 0.0, 21.0), 0.0)
BuildOps.ensure_block(doc2).append(teil2)
BuildOps.place_mirrored(doc2, teil2, POINT)
BuildOps.remove_mirrored(doc2, 1, POINT)
_check(BuildOps.piece_count(doc2) == 0, "Zwilling löschen → Original auch weg")
# Paar + Fremdteil: Löschung des Fremdteils lässt das Paar unangetastet.
var doc3 := _new_doc()
var a := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 0.0), 0.0)
BuildOps.ensure_block(doc3).append(a)
BuildOps.place_mirrored(doc3, a, POINT)
var fremd := BuildOps.make_piece("boden", Vector3(30.0, 0.0, 0.0), 0.0)
BuildOps.ensure_block(doc3).append(fremd)
BuildOps.remove_mirrored(doc3, 2, POINT)
_check(BuildOps.piece_count(doc3) == 2, "Fremdteil weg, Paar bleibt (2 Teile)")
_check(not _piece_at(doc3, 0).is_empty() and not _piece_at(doc3, 1).is_empty(),
"Paar-Indizes unverändert verschoben")
# ------------------------------------- Bau-Teile: verschieben/drehen/anheben ---
func _test_build_update_follows() -> void:
print("Testgruppe: Verschieben/Drehen/Anheben — Zwilling folgt")
var doc := _new_doc()
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0)
BuildOps.ensure_block(doc).append(teil)
BuildOps.place_mirrored(doc, teil, POINT)
# Verschieben (Pfeiltaste-Muster): Original auf neue Position, dann sync.
teil["pos"] = [14.0, 0.0, 20.0]
BuildOps.update_mirrored(doc, 0, POINT)
var twin := _piece_at(doc, 1)
_check(_near(_pos3(twin).x, -14.0) and _near(_pos3(twin).z, -20.0),
"Zwilling folgt der verschobenen Position gespiegelt")
# Drehen: +90° am Original → Zwilling = gespiegelte NEUE Drehung.
teil["rot"] = 180.0
BuildOps.update_mirrored(doc, 0, POINT)
_check(_near(float((_piece_at(doc, 1) as Dictionary).get("rot", 0.0)), 0.0),
"Zwilling-Drehung: 180° + 180° = 0° (modulo 360)")
# Anheben: y wandert 1:1 mit (Höhe ist ein Skalar).
teil["pos"] = [14.0, 2.4, 20.0]
BuildOps.update_mirrored(doc, 0, POINT)
_check(_near(_pos3(_piece_at(doc, 1)).y, 2.4), "Zwilling-y folgt 1:1 (Skalar)")
# Teil OHNE sym-Partner (außerhalb des Modus gebaut) bleibt unberührt.
var doc2 := _new_doc()
var solo := BuildOps.make_piece("wand", Vector3(6.0, 0.0, 0.0), 0.0)
BuildOps.ensure_block(doc2).append(solo)
_check(BuildOps.update_mirrored(doc2, 0, POINT) == "", "Teil ohne sym: kein Zwilling, keine Meldung")
_check(BuildOps.piece_count(doc2) == 1, "Teil ohne sym: nichts wurde gedoppelt")
# ------------------------------------------------------- Bau-Teile: V9 -------
## V9-Realität (deshalb so getestet): bei zentrierten bounds bildet die
## Spiegelung gültiger Elemente bildet IMMER auf gültige Stellen ab — der V9-Fall
## entsteht, wenn das ORIGINAL bereits außerhalb liegt (Editor erzwingt die
## Grenzen beim Nudge/Baken nicht) oder Grenzen asymmetrisch sind. Dann
## verweigert der Zwilling die Speicherung mit Klartext-Meldung.
func _test_build_v9_bounds() -> void:
print("Testgruppe: Bau-Teil — V9-Grenzen scharf")
var doc := _new_doc()
# Original außerhalb (z = 200 > 128 + 1 Toleranz): auch der Zwilling läge
# außerhalb → Meldung statt still zu speichern.
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 200.0), 0.0)
BuildOps.ensure_block(doc).append(teil)
var msg: String = BuildOps.place_mirrored(doc, teil, POINT)
_check(msg.contains("V9"), "V9-Meldung statt still zu speichern")
_check(msg.contains("Match-Grenzen"), "Meldung nennt die Match-Grenzen")
_check(BuildOps.piece_count(doc) == 1, "V9-Fall: NUR das Original gespeichert")
_check(SymmetryCore.sym_of(teil).is_empty(), "V9-Fall: kein halber sym-Link zurückbleibt")
# Grenz-Nähe INNERHALB (Toleranz ±1 m): spiegeln bleibt erlaubt.
var doc2 := _new_doc()
var nah := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 127.5), 0.0)
BuildOps.ensure_block(doc2).append(nah)
_check(BuildOps.place_mirrored(doc2, nah, POINT) == "",
"nahe der Grenze (|z| ≤ 128 + 1): gespiegelt ok")
_check(BuildOps.piece_count(doc2) == 2, "Grenz-Nähe: Zwilling vorhanden")
# update_mirrored: Original aus den Grenzen verschoben → Zwilling bleibt unverändert.
var doc3 := _new_doc()
var t3 := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 30.0), 0.0)
BuildOps.ensure_block(doc3).append(t3)
BuildOps.place_mirrored(doc3, t3, POINT)
t3["pos"] = [10.0, 85.0, 30.0] # y = 85 > 80 + 1 → gespiegelt ebenfalls draußen
var msg3: String = BuildOps.update_mirrored(doc3, 0, POINT)
_check(msg3.contains("V9"), "update: V9-Meldung statt Zwilling verschieben")
_check(_near(_pos3(_piece_at(doc3, 1)).z, -30.0) and _near(_pos3(_piece_at(doc3, 1)).y, 0.0),
"update-V9: Zwilling blieb UNVERÄNDERT (−30 / y 0)")
# ------------------------------------------------- Bau-Teile: Modus AUS -----
func _test_build_off_gegenprobe() -> void: func _test_build_off_gegenprobe() -> void:
print("Testgruppe: Modus AUS = Verhalten wie bisher (Bau-Teile)") print("Testgruppe: Symmetrie AUS = ein Teil, kein Zwilling")
var doc := _new_doc() var doc := _new_doc()
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0) var gid := BauOps.gruppe_neu(doc)
BuildOps.ensure_block(doc).append(teil) var r := _setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), OFF, gid)
_check(BuildOps.place_mirrored(doc, teil, OFF) == "", "OFF: keine Meldung, kein Zwilling") _check((doc["objects"] as Array).size() == 1 and int(r["twin"]) == -1, "kein Zwilling im Modus AUS")
_check(BuildOps.piece_count(doc) == 1, "OFF: nur 1 Bauteil") _check(SymmetryCore.sym_of((doc["objects"] as Array)[0]).is_empty(), "keine sym-Kennung")
_check(SymmetryCore.sym_of(teil).is_empty(), "OFF: kein sym-Feld gesetzt")
teil["pos"] = [12.0, 0.0, 20.0]
BuildOps.update_mirrored(doc, 0, OFF)
_check(BuildOps.piece_count(doc) == 1, "OFF: Verschieben dupliziert nicht")
var msg: String = BuildOps.remove_mirrored(doc, 0, OFF)
_check(BuildOps.piece_count(doc) == 0 and msg == "", "OFF: Löschen entfernt genau 1 Teil")
## EIN Undo-Schritt: Original + Zwilling springen zusammen zurück.
func _test_build_one_undo_step() -> void: func _test_build_one_undo_step() -> void:
print("Testgruppe: Bau-Teile — EIN Undo-Schritt für beide Seiten") print("Testgruppe: EIN Undo-Schritt nimmt Original + Zwilling zurück")
var doc := _new_doc() var doc := _new_doc()
var hist := ActionHistory.new() var hist := ActionHistory.new()
hist.begin(doc) hist.begin(doc)
var before: Dictionary = hist.snapshot(["gebaeude"]) var before := hist.snapshot(["objects", "group_names"])
var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0) if not before.has("group_names"):
BuildOps.ensure_block(doc).append(teil) before["group_names"] = null
BuildOps.place_mirrored(doc, teil, POINT) var gid := BauOps.gruppe_neu(doc)
hist.commit("Wand gesetzt", before, hist.snapshot(["gebaeude"])) _setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), POINT, gid, gid + 1)
_check(BuildOps.piece_count(doc) == 2, "commit: beide Teile drin") hist.commit("Wand bauen", before, hist.snapshot(["objects", "group_names"]))
_check((doc["objects"] as Array).size() == 2, "nach dem Setzen: 2 Teile")
hist.undo() hist.undo()
_check(BuildOps.piece_count(doc) == 0, "EIN undo: Original UND Zwilling weg") _check((doc["objects"] as Array).is_empty(), "EIN Undo: beide weg")
hist.redo() hist.redo()
_check(BuildOps.piece_count(doc) == 2, "EIN redo: beide wieder da") _check((doc["objects"] as Array).size() == 2, "Redo: beide wieder da")
# -------------------------------------------------------- Spawns: setzen ----- # -------------------------------------------------------- Spawns: setzen -----

View file

@ -224,8 +224,7 @@ func _test_shortcuts_karte3() -> void:
main._unhandled_key_input(_press_key(KEY_F2, false)) main._unhandled_key_input(_press_key(KEY_F2, false))
_check(str(main.get("tool_mode")) == "objects", "F2: zurück zum Objekt-Werkzeug") _check(str(main.get("tool_mode")) == "objects", "F2: zurück zum Objekt-Werkzeug")
main._unhandled_key_input(_press_key(KEY_F3, false)) main._unhandled_key_input(_press_key(KEY_F3, false))
_check(str(main.get("tool_mode")) == "bauen", "F3: Bauen-Werkzeug aktiv") _check(str(main.get("tool_mode")) == "objects" and (main.ui_right_panel as TabContainer).current_tab == main.bauen_mod.get_index(), "F3: Bauen-Tab geöffnet")
_check(str(main.tool_label.text).contains("Bauen"), "Statuszeile: Werkzeug-Label zeigt Bauen")
main._unhandled_key_input(_press_key(KEY_F4, false)) main._unhandled_key_input(_press_key(KEY_F4, false))
_check(str(main.get("tool_mode")) == "spawns", "F4: Spawn-Werkzeug aktiv") _check(str(main.get("tool_mode")) == "spawns", "F4: Spawn-Werkzeug aktiv")
main._unhandled_key_input(_press_key(KEY_F2, false)) main._unhandled_key_input(_press_key(KEY_F2, false))

View file

@ -452,12 +452,12 @@ func _test_bauteile_treppe_tuer() -> void:
# Erst raus aus dem laufenden Testlauf, dann Map mit Bauteilen neu starten. # Erst raus aus dem laufenden Testlauf, dann Map mit Bauteilen neu starten.
pt.call("handle_escape") pt.call("handle_escape")
await process_frame await process_frame
var build_ops := load("res://src/editor/build_ops.gd")
var doc: Dictionary = SvmapDocument.create_new("Bauteil-Karte", 100.0, "klar") var doc: Dictionary = SvmapDocument.create_new("Bauteil-Karte", 100.0, "klar")
var t0: float = SvmapDocument.terrain_height_at(doc, 0.0, 0.0) var t0: float = SvmapDocument.terrain_height_at(doc, 0.0, 0.0)
var treppe: Dictionary = build_ops.call("make_piece", "treppe", Vector3(4.0, t0, 0.0), 0.0) # Treppe (4 m lang, 3 m hoch, Lauf +x) und Tür-Wand als NORMALE Objekte (bau_*).
var tuer: Dictionary = build_ops.call("make_piece", "tuer", Vector3(0.0, t0, 14.0), 0.0) var treppe := {"id": "bau_treppe", "pos": [6.0, t0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}
doc["gebaeude"] = [treppe, tuer] var tuer := {"id": "bau_wand_tuer", "pos": [1.0, t0, 14.0], "scale": 1.0, "rot_y_deg": 0.0}
doc["objects"] = [treppe, tuer]
main.set("doc", doc) main.set("doc", doc)
main.set("current_path", "") main.set("current_path", "")
main.set("dirty", false) main.set("dirty", false)
@ -475,11 +475,11 @@ func _test_bauteile_treppe_tuer() -> void:
var kb: Node3D = pt2.get("_kollider_root") var kb: Node3D = pt2.get("_kollider_root")
var bauteile := 0 var bauteile := 0
for child in kb.get_children(): for child in kb.get_children():
if str(child.name).begins_with("Bauteil_Kollider"): if str(child.name).contains("_bau_"):
bauteile += 1 bauteile += 1
_check(bauteile == 2, "2 Bauteil-Kollider gebaut (Treppe + Tür, ist %d)" % bauteile) _check(bauteile == 2, "2 Bauteil-Kollider gebaut (Treppe + Tür, ist %d)" % bauteile)
# a) Treppe (2 m lang, 1 m hoch, 10 Stufen à 0.1 m): hochlaufen. # a) Treppe (4 m lang, 3 m hoch, 15 Stufen à 0.2 m): hochlaufen.
# Erst VOR die Treppe stellen (Spawn liegt weit weg — Karte Mitte). # Erst VOR die Treppe stellen (Spawn liegt weit weg — Karte Mitte).
body.set("position", Vector3(0.0, t0 + PlaytestBody.HALF_H + 0.05, 0.0)) body.set("position", Vector3(0.0, t0 + PlaytestBody.HALF_H + 0.05, 0.0))
body.set("velocity", Vector3.ZERO) body.set("velocity", Vector3.ZERO)
@ -495,10 +495,10 @@ func _test_bauteile_treppe_tuer() -> void:
body.set("auto_move_dir", Vector3.ZERO) body.set("auto_move_dir", Vector3.ZERO)
for i in 10: for i in 10:
await physics_frame await physics_frame
_check(feet_max >= 0.9, _check(feet_max >= t0 + 2.5,
"Treppe (1.0 m, Stufen à 0.1) erklommen (max feet %.2f)" % feet_max) "Treppe (3.0 m, Stufen à 0.2) erklommen (max feet %.2f, Start %.2f)" % [feet_max, t0])
# b) Tür (Wand 2 m entlang x, Öffnung 1 × 2 m): durchgehen (Lauf +z). # b) Tür (Wand 2 m entlang x, Öffnung 1,2 × 2,2 m): durchgehen (Lauf +z).
var t0_2: float = body.call("terrain_height", 1.0, 16.0) var t0_2: float = body.call("terrain_height", 1.0, 16.0)
body.set("position", Vector3(1.0, t0_2 + PlaytestBody.HALF_H + 0.05, 16.0)) body.set("position", Vector3(1.0, t0_2 + PlaytestBody.HALF_H + 0.05, 16.0))
body.set("velocity", Vector3.ZERO) body.set("velocity", Vector3.ZERO)

View file

@ -253,53 +253,36 @@ func _test_placement_rmb_keeps_hand() -> void:
_check(not bool(interaction.get("placing")), "Esc wirft das Objekt ab (Platzieren aus)") _check(not bool(interaction.get("placing")), "Esc wirft das Objekt ab (Platzieren aus)")
## Abnahme 4: Bauen-Werkzeug — RMB fliegt, wechselt NICHT das Werkzeug und ## Abnahme 4: Bauen — RMB fliegt, die Bauteil-Serie (Teil in der Hand) bleibt.
## behält die Bauteil-Auswahl.
func _test_build_rmb_flies() -> void: func _test_build_rmb_flies() -> void:
print("Testgruppe: Bauen-Werkzeug + RMB = Flug (Werkzeug/Auswahl bleiben)") print("Testgruppe: Bauen + RMB = Flug (Teil bleibt in der Hand)")
main.call("_set_tool_mode", "bauen") main.call("_set_tool_mode", "objects")
_check(str(main.get("tool_mode")) == "bauen", "Bauen-Werkzeug aktiv") var interaction: Node = main.get("interaction")
var build: Node = main.get("build_tool")
var pos := _vp_center() var pos := _vp_center()
_motion(pos) _motion(pos)
interaction.call("start_placement", "bau_fundament")
# Ein Bauteil setzen, dann per Umschalt+Klick auswählen — Klickpunkt ist _check(bool(interaction.get("placing")) and str(interaction.get("place_oid")) == "bau_fundament", "Bauteil in der Hand")
# die UNPROJIZIERTE Bauteil-Mitte (der gerastete Fußpunkt liegt sonst exakt
# auf der Kante des Center-Strahls).
_motion(pos) _motion(pos)
_mouse(MOUSE_BUTTON_LEFT, true, pos) _mouse(MOUSE_BUTTON_LEFT, true, pos)
_mouse(MOUSE_BUTTON_LEFT, false, pos) _mouse(MOUSE_BUTTON_LEFT, false, pos)
await process_frame await process_frame
_check(_build_piece_count() > 0, "Bauteil per Klick gesetzt (normales Objekt)")
_check(bool(interaction.get("placing")), "Serie: Teil bleibt nach dem Setzen in der Hand")
var doc: Dictionary = main.doc var doc: Dictionary = main.doc
var gebaeude: Array = doc.get("gebaeude", []) _check(GroupOps.group_id((doc["objects"] as Array).back()) != GroupOps.UNGROUPED, "Teil gehört zu einem Gebäude (Gruppe)")
_check(gebaeude.size() > 0, "Bauteil gesetzt")
if gebaeude.is_empty():
_finish()
return
var ppos: Array = (gebaeude[0] as Dictionary).get("pos", [0.0, 0.0, 0.0])
var piece_center := Vector3(float(ppos[0]), float(ppos[1]) + 0.3, float(ppos[2]))
# Regression (t_555e33da): Selektion muss im DEFAULT-Framing funktionieren —
# kein Heranzoomen mehr nötig (früher: ray_pick-Cap 500 m < Kamera-Distanz).
var on_piece := camera.unproject_position(piece_center)
_motion(on_piece)
_mouse(MOUSE_BUTTON_LEFT, true, on_piece, false, true)
_mouse(MOUSE_BUTTON_LEFT, false, on_piece, false, true)
await process_frame
var sel := int(build.get("selected_index"))
_check(sel >= 0, "Bauteil per Umschalt+Klick ausgewählt")
_mouse(MOUSE_BUTTON_RIGHT, true, on_piece) _mouse(MOUSE_BUTTON_RIGHT, true, pos)
await process_frame await process_frame
_check(camera.call("fly_active"), "RMB → Flug aktiv (im Bauen-Werkzeug)") _check(camera.call("fly_active"), "RMB → Flug aktiv")
_check(str(main.get("tool_mode")) == "bauen", "RMB wechselt NICHT mehr das Werkzeug") _check(bool(interaction.get("placing")), "RMB wirft das Teil NICHT ab")
_check(int(build.get("selected_index")) == sel, "RMB wirft die Bauteil-Auswahl NICHT weg")
_mouse(MOUSE_BUTTON_RIGHT, false, pos) _mouse(MOUSE_BUTTON_RIGHT, false, pos)
await process_frame await process_frame
_check(not bool(camera.call("fly_active")), "RMB losgelassen → Flug aus") _check(not bool(camera.call("fly_active")), "RMB losgelassen → Flug aus")
main._unhandled_key_input(_press_key(KEY_ESCAPE))
main.call("_set_tool_mode", "objects") await process_frame
_check(not bool(interaction.get("placing")), "Esc beendet das Bauen")
func _build_piece_count() -> int: func _build_piece_count() -> int:
var doc: Dictionary = main.doc var doc: Dictionary = main.doc
return int((doc.get("gebaeude", []) as Array).size()) return BauOps.teile_zahl(doc)

View file

@ -39,7 +39,6 @@ func _run_all() -> void:
await _test_hover_abstufung() await _test_hover_abstufung()
await _test_zwilling() await _test_zwilling()
await _test_spawn_ring() await _test_spawn_ring()
await _test_gebaeude_kontur()
if failures == 0: if failures == 0:
print("ALL OK: %d checks" % checks) print("ALL OK: %d checks" % checks)
quit(0) quit(0)
@ -262,26 +261,3 @@ func _test_spawn_ring() -> void:
await process_frame await process_frame
_check(not marker.has_meta("spawn_ring"), "Abwahl: Ring-Meta restlos weg") _check(not marker.has_meta("spawn_ring"), "Abwahl: Ring-Meta restlos weg")
builder.queue_free() builder.queue_free()
# ------------------------------------------------------------------ T6 ------
func _test_gebaeude_kontur() -> void:
print("== T6 Bau-Teile: Kontur statt Vollbox ==")
var doc := _flat_doc()
var teil: Dictionary = BuildOps.make_piece("wand", Vector3(2.0, 0.2, 4.0), 0.0)
_check(not teil.is_empty(), "Proben-Bauteil angelegt")
(doc["gebaeude"] as Array).append(teil)
var builder := _build(_manifest(), doc)
builder.call("set_gebaeude_selected", 0)
await process_frame
var sel: Variant = builder.get("_gebaeude_sel")
_check(sel is MeshInstance3D and is_instance_valid(sel), "Bauteil-Auswahl: Kontur-Mesh existiert")
if sel is MeshInstance3D:
_check((sel as MeshInstance3D).mesh != null, "Kontur trägt das Bauteil-Mesh")
_check((sel as MeshInstance3D).material_override == builder.get("_outline_mat_sel"),
"Kontur trägt das Auswahl-Material (Theme)")
builder.call("set_gebaeude_selected", -1)
await process_frame
var sel2: Variant = builder.get("_gebaeude_sel")
_check(sel2 == null, "Bauteil-Abwahl: Kontur restlos weg")
builder.queue_free()

View file

@ -32,7 +32,6 @@ func _init() -> void:
_test_lift_multi() _test_lift_multi()
_test_snap_to_floor() _test_snap_to_floor()
_test_grab() _test_grab()
_test_build_nudge_floor()
_test_brush_paint_mode() _test_brush_paint_mode()
if failures == 0: if failures == 0:
print("ALL OK: %d checks" % checks) print("ALL OK: %d checks" % checks)
@ -101,14 +100,10 @@ class ProbeMain:
class ProbeBuilder extends Node3D: class ProbeBuilder extends Node3D:
var object_markers: Array = [] var object_markers: Array = []
var last_selection: Array = [] var last_selection: Array = []
var last_gebaeude_selection := -99
func set_selected_multi(indices: Array) -> void: func set_selected_multi(indices: Array) -> void:
last_selection = indices.duplicate() last_selection = indices.duplicate()
func set_gebaeude_selected(idx: int) -> void:
last_gebaeude_selection = idx
# ------------------------------------------------------------ InputMap ------- # ------------------------------------------------------------ InputMap -------
@ -387,9 +382,8 @@ func _test_esc_policy() -> void:
print("Testgruppe: Esc-Zustandswechsel (ShortcutPolicy)") print("Testgruppe: Esc-Zustandswechsel (ShortcutPolicy)")
_check(ShortcutPolicy.esc_action("terrain", false, false) == "exit_terrain", "Esc im Terrain-Werkzeug → Werkzeug verlassen") _check(ShortcutPolicy.esc_action("terrain", false, false) == "exit_terrain", "Esc im Terrain-Werkzeug → Werkzeug verlassen")
_check(ShortcutPolicy.esc_action("spawns", false, false) == "exit_spawns", "Esc im Spawn-Werkzeug → Werkzeug verlassen") _check(ShortcutPolicy.esc_action("spawns", false, false) == "exit_spawns", "Esc im Spawn-Werkzeug → Werkzeug verlassen")
_check(ShortcutPolicy.esc_action("bauen", false, false) == "exit_bauen", "Esc im Bauen-Modus → Werkzeug verlassen")
_check(ShortcutPolicy.esc_action("teststart", false, false) == "exit_teststart", "Esc im Test-Start-Werkzeug → Werkzeug verlassen (Punkt bleibt)") _check(ShortcutPolicy.esc_action("teststart", false, false) == "exit_teststart", "Esc im Test-Start-Werkzeug → Werkzeug verlassen (Punkt bleibt)")
_check(ShortcutPolicy.esc_action("bauen", true, true) == "exit_bauen", "Werkzeug hat VORRANG vor Platzieren") _check(ShortcutPolicy.esc_action("terrain", true, true) == "exit_terrain", "Werkzeug hat VORRANG vor Platzieren")
_check(ShortcutPolicy.esc_action("objects", true, true) == "cancel_placement", "Esc beim Platzieren → Ghost-Preview abbrechen") _check(ShortcutPolicy.esc_action("objects", true, true) == "cancel_placement", "Esc beim Platzieren → Ghost-Preview abbrechen")
_check(ShortcutPolicy.esc_action("objects", false, true) == "clear_selection", "Esc mit Auswahl → Auswahl aufheben") _check(ShortcutPolicy.esc_action("objects", false, true) == "clear_selection", "Esc mit Auswahl → Auswahl aufheben")
_check(ShortcutPolicy.esc_action("objects", false, false) == "none", "Esc ohne nichts → kein Zustandswechsel") _check(ShortcutPolicy.esc_action("objects", false, false) == "none", "Esc ohne nichts → kein Zustandswechsel")
@ -698,25 +692,6 @@ func _test_rotate_90() -> void:
fake.history.undo() fake.history.undo()
_check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001, _check(absf(float(objects[0]["rot_y_deg"]) - 30.0) < 0.0001,
"Strg+Z: zweite Ebene — Einzel-Drehung auch zurück (30°)") "Strg+Z: zweite Ebene — Einzel-Drehung auch zurück (30°)")
# Bauen-Modus: Bauteil rotiert um seinen Fußpunkt (Position unverändert).
var build_doc := {"gebaeude": [
{"id": "wand", "pos": [4.0, 0.0, 6.0], "rot": 0.0, "punkte": [[0, 0, 0], [2, 0, 0]]},
]}
var bfake := ProbeMain.new()
bfake.doc = build_doc
bfake.history.begin(bfake.doc)
var bbuilder := ProbeBuilder.new()
root.add_child(bbuilder)
var btool := Node3D.new()
btool.set_script(load("res://src/editor/build_tool.gd"))
root.add_child(btool)
btool.setup(bfake, Camera3D.new(), bbuilder, interaction)
btool.select_index(0)
btool.rotate_input()
var list: Array = BuildOps.pieces(build_doc)
_check(absf(float(list[0]["rot"]) - 90.0) < 0.0001, "Bauen: R dreht Bauteil 90°")
_check(float(list[0]["pos"][0]) == 4.0 and float(list[0]["pos"][2]) == 6.0,
"Bauen: Fußpunkt bleibt (Drehung um den Fußpunkt)")
# ---------------------------------------------------- Pfeiltasten 2-m-Raster -- # ---------------------------------------------------- Pfeiltasten 2-m-Raster --
@ -873,40 +848,6 @@ func _test_grab() -> void:
_check(fake.statuses.size() > 0, "G ohne Auswahl: hilfreiche Meldung statt Stille") _check(fake.statuses.size() > 0, "G ohne Auswahl: hilfreiche Meldung statt Stille")
# ------------------------------------------- Bauen: Pfeile + Strg+Bild↑ -------
func _test_build_nudge_floor() -> void:
print("Testgruppe: Bauen-Modus — Pfeiltasten (2-m-Raster) + Strg+Bild↑")
var doc := {"gebaeude": [
{"id": "wand", "pos": [4.0, 0.0, 6.0], "rot": 0.0, "punkte": [[0, 0, 0], [2, 0, 0]]},
]}
var bfake := ProbeMain.new()
bfake.doc = doc
bfake.history.begin(bfake.doc)
var bbuilder := ProbeBuilder.new()
root.add_child(bbuilder)
var btool := Node3D.new()
btool.set_script(load("res://src/editor/build_tool.gd"))
root.add_child(btool)
btool.setup(bfake, Camera3D.new(), bbuilder, null)
btool.select_index(0)
btool.nudge_selected(Vector3(0, 0, -2))
var list: Array = BuildOps.pieces(doc)
_check(float(list[0]["pos"][2]) == 4.0 and float(list[0]["pos"][0]) == 4.0,
"Bauen-Pfeil: exakt 2 m, Raster-Ausrichtung bleibt")
_check(bfake.history.can_undo(), "Bauen-Pfeil: EIN History-Eintrag")
bfake.history.undo()
_check(float(list[0]["pos"][2]) == 6.0, "Bauen-Pfeil: Strg+Z zurück")
# Strg+Bild↑: Bauteil auf Terrain-Höhe (flaches Terrain → 0 m, 5-cm-Raster).
list[0]["pos"] = [4.0, 2.5, 6.0]
btool.snap_selected_to_floor()
_check(absf(float(list[0]["pos"][1])) < 0.0001, "Bauen Strg+Bild↑: Bauteil auf dem Boden (0 m)")
# Erneut: liegt schon am Boden → Meldung, kein Commit.
var can_undo: bool = bfake.history.can_undo()
btool.snap_selected_to_floor()
_check(bfake.history.can_undo() == can_undo, "Bauen Strg+Bild↑ am Boden: kein leerer Commit")
# ------------------------------------------ Terrain-Ziffer 4 = Malen (Fix) ---- # ------------------------------------------ Terrain-Ziffer 4 = Malen (Fix) ----
func _test_brush_paint_mode() -> void: func _test_brush_paint_mode() -> void:

View file

@ -185,7 +185,7 @@ func _test_partner_link() -> void:
var obj := {"id": "baum", "pos": [1.0, 0.0, 2.0], "rot_y_deg": 0.0} var obj := {"id": "baum", "pos": [1.0, 0.0, 2.0], "rot_y_deg": 0.0}
_check(SymmetryCore.sym_of(obj) == "", "Objekt ohne sym → leere Kennung") _check(SymmetryCore.sym_of(obj) == "", "Objekt ohne sym → leere Kennung")
_check(SymmetryCore.sym_of({"id": "x", "sym": 42}) == "", "Defektes sym (Zahl) → leer, kein Crash") _check(SymmetryCore.sym_of({"id": "x", "sym": 42}) == "", "Defektes sym (Zahl) → leer, kein Crash")
var key := SymmetryCore.next_sym_key({"objects": [], "gebaeude": []}) var key := SymmetryCore.next_sym_key({"objects": []})
_check(key == "sym_1", "Erste freie Kennung = sym_1") _check(key == "sym_1", "Erste freie Kennung = sym_1")
SymmetryCore.set_sym(obj, key) SymmetryCore.set_sym(obj, key)
_check(SymmetryCore.sym_of(obj) == key, "set_sym setzt die Kennung") _check(SymmetryCore.sym_of(obj) == key, "set_sym setzt die Kennung")
@ -196,17 +196,16 @@ func _test_partner_link() -> void:
{"id": "a", "pos": [0.0, 0.0, 0.0], "sym": "sym_1"}, {"id": "a", "pos": [0.0, 0.0, 0.0], "sym": "sym_1"},
{"id": "b", "pos": [0.0, 0.0, 0.0]}, {"id": "b", "pos": [0.0, 0.0, 0.0]},
{"id": "c", "pos": [0.0, 0.0, 0.0], "sym": "sym_2"}, {"id": "c", "pos": [0.0, 0.0, 0.0], "sym": "sym_2"},
], "gebaeude": [ {"id": "bau_wand", "pos": [0.0, 0.0, 0.0], "sym": "sym_3"},
{"id": "wand", "pos": [0.0, 0.0, 0.0], "sym": "sym_3"},
]} ]}
_check(SymmetryCore.next_sym_key(doc) == "sym_4", _check(SymmetryCore.next_sym_key(doc) == "sym_4",
"next_sym_key überspringt belegte Kennungen über BEIDE Blöcke") "next_sym_key überspringt belegte Kennungen (Bauteile sind normale Objekte)")
_check(SymmetryCore.find_partner_index(doc["objects"], "sym_2") == 2, _check(SymmetryCore.find_partner_index(doc["objects"], "sym_2") == 2,
"find_partner_index findet den Partner") "find_partner_index findet den Partner")
_check(SymmetryCore.find_partner_index(doc["objects"], "sym_2", 2) == -1, _check(SymmetryCore.find_partner_index(doc["objects"], "sym_2", 2) == -1,
"find_partner_index schließt den Index selbst aus") "find_partner_index schließt den Index selbst aus")
_check(SymmetryCore.find_partner_index(doc["gebaeude"], "sym_3") == 0, _check(SymmetryCore.find_partner_index(doc["objects"], "sym_3") == 3,
"find_partner_index funktioniert auch im gebaeude-Block") "find_partner_index findet auch Bauteile")
_check(SymmetryCore.find_partner_index(doc["objects"], "") == -1, _check(SymmetryCore.find_partner_index(doc["objects"], "") == -1,
"Leere Kennung → kein Partner") "Leere Kennung → kein Partner")

View file

@ -1027,8 +1027,7 @@ func _test_resize_grow() -> void:
_check((grown["objects"] as Array).size() == 1 _check((grown["objects"] as Array).size() == 1
and str((grown["objects"] as Array)[0]["id"]) == "baum_laub_1", and str((grown["objects"] as Array)[0]["id"]) == "baum_laub_1",
"Objekte unverändert übernommen") "Objekte unverändert übernommen")
_check((grown["gebaeude"] as Array).is_empty() and not grown.has("checksum"), _check(not grown.has("checksum"), "alte Prüfsumme verworfen")
"Bauteile übernommen, alte Prüfsumme verworfen")
_check(absf(float(grown["bounds"]["max"][0]) - 256.0) < 0.0001 _check(absf(float(grown["bounds"]["max"][0]) - 256.0) < 0.0001
and absf(float(grown["bounds"]["min"][2]) + 256.0) < 0.0001 and absf(float(grown["bounds"]["min"][2]) + 256.0) < 0.0001
and absf(float(grown["bounds"]["max"][1]) - 80.0) < 0.0001, and absf(float(grown["bounds"]["max"][1]) - 80.0) < 0.0001,
@ -1093,9 +1092,9 @@ func _test_resize_shrink() -> void:
# Bauteile: Wand mit Fußabdruck ÜBER der Kante (Punkte 127→129 m) = drin, # Bauteile: Wand mit Fußabdruck ÜBER der Kante (Punkte 127→129 m) = drin,
# Fundament komplett draußen, gedrehte Wand über der -Z-Kante = drin. # Fundament komplett draußen, gedrehte Wand über der -Z-Kante = drin.
doc["gebaeude"] = [ doc["gebaeude"] = [
BuildOps.make_piece("wand", Vector3(127.0, 0.0, 10.0), 0.0), {"id": "wand", "pos": [127.0, 0.0, 10.0], "rot": 0.0},
BuildOps.make_piece("fundament", Vector3(150.0, 0.0, -50.0), 0.0), {"id": "fundament", "pos": [150.0, 0.0, -50.0], "rot": 0.0},
BuildOps.make_piece("wand", Vector3(0.0, 0.0, -127.0), 90.0), {"id": "wand", "pos": [0.0, 0.0, -127.0], "rot": 90.0},
] ]
# Spawn-Punkte drin/draußen gemischt (Default-Zonen von 512 m lägen alle # Spawn-Punkte drin/draußen gemischt (Default-Zonen von 512 m lägen alle
# bei ±204,8 m draußen — hier bewusst gemischt, damit nach dem Filter # bei ±204,8 m draußen — hier bewusst gemischt, damit nach dem Filter

View file

@ -32,7 +32,7 @@ func _run() -> void:
_check(main.malen_mod != null and main.bauen_mod != null, "Module malen/bauen gebaut") _check(main.malen_mod != null and main.bauen_mod != null, "Module malen/bauen gebaut")
_check(main.terrain_mod.terrain_mode_paint.get_parent() == main.terrain_mod.terrain_panel and not main.terrain_mod.terrain_mode_paint.visible, _check(main.terrain_mod.terrain_mode_paint.get_parent() == main.terrain_mod.terrain_panel and not main.terrain_mod.terrain_mode_paint.visible,
"Mal-Modus nicht mehr als Knopf im Terrain-Tab") "Mal-Modus nicht mehr als Knopf im Terrain-Tab")
_check(main.build_edit_btn.get_parent() == main.bauen_mod, "Bauen-Knopf wohnt im Bauen-Tab") _check(main.bauen_mod.bau_buttons.size() >= BauKatalog.KINDS.size(), "Bauen-Tab hat einen Knopf je Bauteil (%d)" % main.bauen_mod.bau_buttons.size())
_check(main.malen_mod.mat_buttons.size() == SvmapIO.MATERIAL_IDS.size(), "Malen: eine Kachel je Material") _check(main.malen_mod.mat_buttons.size() == SvmapIO.MATERIAL_IDS.size(), "Malen: eine Kachel je Material")
print("== Tab-Klick aktiviert das Werkzeug direkt ==") print("== Tab-Klick aktiviert das Werkzeug direkt ==")
@ -47,7 +47,7 @@ func _run() -> void:
main.malen_mod.radius_slider.value = 20.0 main.malen_mod.radius_slider.value = 20.0
_check(int(main.terrain_mod.terrain_radius_slider.value) == 20, "Malen-Radius steuert den Pinsel") _check(int(main.terrain_mod.terrain_radius_slider.value) == 20, "Malen-Radius steuert den Pinsel")
tabs.current_tab = main.bauen_mod.get_index() tabs.current_tab = main.bauen_mod.get_index()
_check(str(main.tool_mode) == "bauen" and main.build_panel.visible, "Tab Bauen: Bauen sofort an, Bauteile offen") _check(str(main.tool_mode) == "objects" and tabs.current_tab == main.bauen_mod.get_index() and not main.bauen_mod.bau_buttons.is_empty(), "Tab Bauen: Objekt-Werkzeug aktiv, Bauteile da")
main.call("_set_tool_mode", "objects") main.call("_set_tool_mode", "objects")
tabs.current_tab = 0 tabs.current_tab = 0

54
tools/gen_bauteile.gd Normal file
View file

@ -0,0 +1,54 @@
extends SceneTree
## Erzeugt die Bauteil-Dateien des Bauen-Modus aus src/editor/bau_katalog.gd:
## GLB (Client) · Trimesh-JSON (Server/Client/Editor) · Registry-Fragment (Server)
##
## Aufruf aus dem Editor-Repo (Pfade = Repo-Wurzeln der Geschwister-Klone):
## godot --headless --path . --script res://tools/gen_bauteile.gd -- \
## --client ../scopedvr-client --server ../scopedvr-match-server
## Danach im Match-Server: python3 tools/merge_object_library.py, und die
## erzeugte library/objects.json in Client + Editor kopieren (tools/sync_objects.sh).
## Idempotent: dieselbe Katalog-Version erzeugt byte-gleiche Dateien.
func _init() -> void:
var args := OS.get_cmdline_user_args()
var client := _arg(args, "--client")
var server := _arg(args, "--server")
if client.is_empty() or server.is_empty():
print("FEHLER: --client <Pfad> und --server <Pfad> angeben.")
quit(2)
return
var glb_dir := client.path_join("assets/objekte/military_desert/%s" % BauKatalog.KATEGORIE)
DirAccess.make_dir_recursive_absolute(glb_dir)
var kol_dirs := [
server.path_join("library/kollisions"),
client.path_join("library/kollisions"),
ProjectSettings.globalize_path("res://library/kollisions"),
]
for d in kol_dirs:
DirAccess.make_dir_recursive_absolute(d)
var fragment := {"objects": {}}
for oid in BauKatalog.oids():
var err := BauKatalog.glb_schreiben(oid, glb_dir.path_join(oid + ".glb"))
if err != OK:
print("FEHLER GLB %s: %d" % [oid, err])
quit(1)
return
var json := JSON.stringify(BauKatalog.kollisions_json(oid))
for d in kol_dirs:
var f := FileAccess.open((d as String).path_join(oid + ".json"), FileAccess.WRITE)
f.store_string(json)
f.close()
fragment["objects"][oid] = BauKatalog.registry_eintrag(oid)
print("%s: %d Dreiecke" % [oid, BauKatalog.dreiecke(oid)])
var frag_dir := server.path_join("library/objects.d")
DirAccess.make_dir_recursive_absolute(frag_dir)
var ff := FileAccess.open(frag_dir.path_join("bauteile.json"), FileAccess.WRITE)
ff.store_string(JSON.stringify(fragment, " ", false) + "\n")
ff.close()
print("Fertig: %d Bauteile." % BauKatalog.oids().size())
quit(0)
func _arg(args: PackedStringArray, key: String) -> String:
var i := args.find(key)
return args[i + 1] if i >= 0 and i + 1 < args.size() else ""

View file

@ -0,0 +1 @@
uid://divoncorxchmo

View file

@ -3,7 +3,7 @@
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
GODOT=${GODOT:-/opt/data/home/bin/godot4} GODOT=${GODOT:-/opt/data/home/bin/godot4}
rc=0 rc=0
for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang test_inspector test_pinsel_werkzeuge test_terrain_mirror test_mal_fein test_ui_aufraeumen test_ui_layout test_ui_scale; do for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_build_spawn_mirror test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang test_inspector test_pinsel_werkzeuge test_terrain_mirror test_mal_fein test_ui_aufraeumen test_ui_layout test_ui_scale; do
"$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1 "$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1
code=$? code=$?
line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1) line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)