Compare commits

..

No commits in common. "main" and "feature/builder-export" have entirely different histories.

721 changed files with 7936 additions and 50521 deletions

8
.gitignore vendored
View file

@ -2,13 +2,5 @@
.godot/
*.tmp
# Mitgelieferte Modelle (Karte t_12cf94fe): werden per tools/sync_models.sh
# aus dem Client-Klon gefüllt — NICHT ins Repo (ITHappy-Lizenz erlaubt keine
# Weitergabe, siehe README_INSTALL.md §5).
models/
# Export-Builds der eigenständigen App
export/
# System
.DS_Store

5
.gitmodules vendored
View file

@ -1,5 +0,0 @@
[submodule "core"]
path = core
url = ../scopedvr-map-core.git
branch = main
ignore = untracked

View file

@ -1,16 +0,0 @@
addons/modular_builder — "Modular Builder" v0.2.0-alpha von Michel Savadim
Quelle: Godot Asset Store (https://store.godotengine.org/asset/michel-savadim/modular-buider/)
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
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.

115
README.md
View file

@ -48,18 +48,17 @@ UX-Fundament auf dem App-Skelett (t_5badf371), nach dem Chef-Grundsatz gebaut:
Neue-Map-Dialog mit 4 Pflicht-Spawns pro Team, Orbit-Viewport mit Gitter/
Boden/Wetter-Stimmung — siehe Commit 84bc081 und `svmap_io.gd`.)*
- **Undo/Redo (`core/editor/action_history.gd`):** zentrale ActionHistory über
- **Undo/Redo (`src/editor/action_history.gd`):** zentrale ActionHistory über
die svmap-Ober-Blöcke (`objects`/`terrain`/`spawn_zones` — tiefe Kopien vor
und nach jeder Änderung, merge_tags gegen Mausrad-Tick-Fluten, Max-Stufe 100).
Strg+Z / Strg+Y UND große Buttons „↩ Rückgängig / ↪ Wiederholen" oben.
Spätere Werkzeuge melden sich mit `snapshot(keys)` vor und
`commit(desc, before, after)` nach der Änderung an — Terrain-Strokes und
Spawn-Zonen landen damit automatisch in derselben History.
- **Objekt-Platzierung mit Ghost-Preview (`core/editor/interaction.gd`):**
- **Objekt-Platzierung mit Ghost-Preview (`src/editor/interaction.gd`):**
Bibliothek links (deutsche Namen + Poly-Zahl), Klick startet den
Platzier-Modus — eine transparente Vorschau schwebt am Cursor auf dem Boden
(Höhe kommt aus dem Terrain), Klick setzt, ESC bricht ab (Rechte
Maustaste = Flug, das Objekt bleibt in der Hand — Karte t_2470de89),
(Höhe kommt aus dem Terrain), Klick setzt, Rechtsklick/ESC bricht ab,
Mausrad dreht die Vorschau VOR dem Setzen (15°-Raster). Modus bleibt an
für schnelles Mehrfachsetzen. Poly-Budget wird VOR dem Platzieren geprüft.
- **Auswahl-Anfassen:** Klick wählt ein Objekt (orange + Emission), Ziehen
@ -94,26 +93,16 @@ synchron ziehen (IDs sind der Vertrag, Entscheidung 20).
Auf dem Terrain-Werkzeug (t_9aba04f1) aufgebaut, nach dem Chef-Grundsatz:
- **Bibliothek-Browser mit Gruppen (`core/editor/library_browser.gd`):**
*(Stand 25.10.2026, Military-Pack-Umstellung)* Die Bibliothek ist das
komplette Military-Desert-Pack (418 Modelle + 5 Kombis = 423 Objekte aus
`library/objects.json`), sortiert in 19 deutsche Kategorien aus dem
Manifest-Feld `kategorie` (Panzer, Radfahrzeuge, Flugzeuge & Hubschrauber,
Geschütze & Stellungen, Fahrzeugteile, Gebäude & Hangars, Zelte, Türme &
Masten, Mauern & Wände, Sperren & Zäune, Kisten/Fässer/Container, Technik &
Funk, Ausrüstung, Training, Bäume, Pflanzen/Kakteen/Gras, Felsen & Steine,
Boden/Straßen/Dünen, Boden-Decals). Fahrzeuge sind **fertig zusammengebaut**
(Panzer mit Turm und Kanone, Hummer mit Rädern, Hubschrauber mit Rotoren);
die Einzelteile stehen zusätzlich unter „Fahrzeugteile“. Unbekannte
Kategorien (z. B. spätere Bauteil-Module) bekommen automatisch eine eigene
Gruppe. Jeder Button zeigt ein 128-px-Thumbnail (`core/thumbs/*.png` —
erzeugt von `tools/sync_objects.sh`/`tools/gen_thumbs.gd` und committed;
fehlt ein PNG, fällt der Button aufs Text-Layout zurück). Deutsche Namen
kommen aus `name_de` im Manifest (ohne UI → headless testbar). Kollision:
Gebäude/Zelte/Türme exakter Trimesh (betretbar), Fahrzeuge Verbund aus
Boxen, Bäume nur Stamm, Dekor ohne Kollision — erzeugt vom Match-Server-Repo
(`tools/pack_registry.py`).
- **Snap to Ground (`core/editor/object_ops.gd`):** Ein Klick auf
- **Bibliothek-Browser mit Gruppen (`src/editor/library_browser.gd`):** Die
11 etablierten Objekte aus `library/objects.json`, sortiert in deutsche
Klapp-Gruppen (Kisten & Barrieren / Bauwerke / Natur + automatisch
„Sonstiges" für neue, noch ungruppierte IDs). Jeder Button zeigt ein
128-px-Thumbnail (`library/thumbs/*.png` — einmalig mit
`xvfb-run -a godot4 --rendering-driver opengl3 --path . --script
res://tools/gen_thumbs.gd` gerendert und committed; fehlt ein PNG, fällt
der Button sauber aufs Text-Layout zurück). Deutsche Namen wohnen jetzt in
`LibraryBrowser` (ohne UI → headless testbar).
- **Snap to Ground (`src/editor/object_ops.gd`):** Ein Klick auf
„⚡ Alles auf den Boden setzen" holt ALLE schwebenden Objekte auf die
Terrain-Höhe (z. B. nach dem Terrain-Formen) — EIN Undo-Eintrag für die
ganze Aktion. Objekte, die schon auf dem Boden stehen, bleiben unangetastet.
@ -138,14 +127,14 @@ Export-Workflow.
Auf der Objekt-Bibliothek (t_3a998ae4) aufgebaut, nach dem Chef-Grundsatz:
- **Spawn-Zonen malen (`core/editor/spawn_tool.gd`):** Werkzeug
- **Spawn-Zonen malen (`src/editor/spawn_tool.gd`):** Werkzeug
„🚩 Spawn-Zonen malen“ — Team Blau 🔵 / Team Rot 🔴 (TDM-Konvention:
IDs A…/B…, mapformat § spawn_zones), Klick setzt eine farbige flache
Kreis-Zone auf dem Terrain (Vorschau-Kreis folgt der Maus), Radius
2–30 m per Regler. Zone anklicken → auswählen + ziehen (Terrain-Höhe
bleibt), Entf löscht, Strg+Z nimmt JEDE Operation zurück (jede Zone =
EIN History-Eintrag, Radius-Slider-Ticks sind merge-gekoppelt).
- **Live-Validierung (`core/editor/spawn_ops.gd`):** Block „Spawn-Check“
- **Live-Validierung (`src/editor/spawn_ops.gd`):** Block „Spawn-Check“
rechts bleibt STÄNDIG sichtbar und spiegelt exakt die Loader-Regeln
(V11: ≥ 4 Spawn-Punkte pro Team · V7: Objekt-IDs in der Bibliothek) —
z. B. „✅ Spawns Team Blau: 4 · ❌ Spawns Team Rot: 2 (mindestens 4
@ -181,11 +170,9 @@ aufgebaut, nach dem Chef-Grundsatz (Klick statt Konsole, deutsche UI,
Undo für alles):
- **Duplizieren (`⧉ Duplizieren`-Button + Strg+D):** Die Kopie des
ausgewählten Objekts hängt +2 m in X versetzt daneben (Rasterversatz,
Karte t_c46d31c2; Füßpunkt-Höhe unverändert, mit Raster-Snap eingerastet,
wenn an) und ist DIREKT angewählt — sie lässt sich gleich
ausgewählten Objekts hängt diagonal 1 m versetzt daneben (mit Raster-Snap
eingerastet, wenn an) und ist DIREKT angewählt — sie lässt sich gleich
weiterziehen/drehen/löschen. Drehung, Größe und Höhe werden übernommen.
Bei Mehrfachauswahl werden ALLE Objekte kopiert (EIN Undo-Schritt).
Poly-Budget und Objekt-Limit werden VOR dem Kopieren geprüft
(Klartext-Meldung, was zu tun ist). EIN Undo-Schritt.
- **Anheben/Absenken (`▲ Anheben / ▼ Absenken` + Bild↑/Bild↓):** Objekte
@ -196,12 +183,10 @@ Undo für alles):
ist **KEIN Extra-Feld im Dateiformat nötig** und die Loader-Kompatibilität
bleibt unangetastet (bewiesen in `tests/test_ux.gd`: Erhöhtes Objekt
überlebt gespeichern → laden intakt (Höhe bleibt exakt)).
- **Absenken / VERSENKEN (Karte t_e166e656):** Absenken geht FREI unter die
Terrain-Höhe — Steine dürfen halb im Boden stecken (Sly-Wunsch).
Untergrenze ist die Match-Grenze `bounds.min.y`; die Auswahl-Anzeige und
die Statusleiste zeigen die Versenk-Tiefe („1,00 m im Boden versenkt“).
„Auf Boden setzen“ holt ein versenktes Objekt wieder exakt auf das
Terrain.
- **Absenken stoppt an der Terrain-Höhe** am Objektstandort — nichts wird
in Hügel begraben. Steckt ein Objekt im Boden (z. B. nach dem
Terrain-Formen), bewegt Absenken es NICHT, und die Statusleiste erklärt,
dass `▲ Anheben` oder `⚡ Alles auf den Boden setzen` hilft.
- **Anheben** hat als Obergrenze die Match-Grenze `bounds.max.y` — höher
passt der Server nicht (V9).
- **Ziehen behält die Höhe:** Ein angehobenes Objekt schwebt beim
@ -215,11 +200,6 @@ Undo für alles):
(Herleitung im Quellcode: gespiegelter Richtungs-Vektor = gespiegelter
Winkel; jede Spiegelung ist dadurch selbstinvers — zweimal spiegeln =
Ausgangszustand.) Die Rotation wird mitgespiegelt, wie bestellt.
KIPPEN wird mitgespiegelt (Karte t_e166e656): Achsenspiegelung =
Konjugation der Basis `M·B·M` — X-Kippen bleibt bei X-Spiegelung,
Z-Kippen dreht das Vorzeichen (und umgekehrt); Punktspiegelung =
`Ry(180°)·B`. Für reines Y-Drehen ergeben beide Regeln exakt die alten
Zahlenwerte (Doku: docs/map_format.md im Match-Server-Repo).
Bewusst NICHT um die Kartemitte gespiegelt: das würde das Objekt quer
über die Map springen lassen — überraschend für Einsteiger. Klartext
in Buttons + Tooltip.
@ -238,13 +218,13 @@ Undo für alles):
Mehrfachauswahl + Gruppieren + Gruppen-Verschieben, auf dem UX-Fundament
(t_40d855d0) aufgebaut, nach dem Chef-Grundsatz:
- **Mehrfachauswahl (`core/editor/interaction.gd`):** Umschalt+Klick fügt
- **Mehrfachauswahl (`src/editor/interaction.gd`):** Umschalt+Klick fügt
ein Objekt hinzu/entfernt es, Rahmen-Aufziehen (Rubberband) über leeren
Boden wählt ALLE Objekte im Rahmen (Umschalt + Rahmen = zusätzliche zur
Auswahl). Loser Klick auf leere Fläche hebt die Auswahl auf. ALLE
markierten Objekte färben sich orange, das Auswahl-Panel rechts listet
sie namentlich auf (ab 9: „… und N weitere“). Projektions-Mathe liegt
in `core/editor/selection_math.gd` (headless getestet).
in `src/editor/selection_math.gd` (headless getestet).
- **Gruppieren:** Buttons „🔗 Gruppieren / ⛓ Gruppe lösen“ im Auswahl-Panel
+ Kürzel Strg+G / Strg+Umschalt+G. Eine Gruppe braucht ≥ 2 Objekte.
Gruppen-Mitglieder ziehen und drehen GEMEINSAM — ein Griff an ein
@ -287,7 +267,7 @@ auch ungespeicherte! — sofort als Spiel in einem EIGENEN Fenster. Der Editor
bleibt offen. Im Spiel: WASD laufen, Maus umsehen, Shift sprinten, Leertaste
springen, ESC beendet das Test-Fenster sauber (kein Prozess-Kill nötig).
- **Temp-Export (`core/editor/test_run.gd`):** Die Map wird nach
- **Temp-Export (`src/editor/test_run.gd`):** Die Map wird nach
`user://testrun/<name>.svmap` exportiert (deterministischer Name aus dem
Map-Namen, erneuter Testlauf überschreibt) — über dieselbe
`SvmapIO.save_to_file`-Validierung wie das echte Speichern. Ist die Map
@ -310,50 +290,3 @@ springen, ESC beendet das Test-Fenster sauber (kein Prozess-Kill nötig).
ungültige Map blockiert, Client-Suche inkl. Konfig-Vorrang, Godot-Findung,
exakte Argumentliste). Parse-Check umfasst `test_run.gd`.
## Status (M2, Karte t_c46d31c2 — Tastatur-Shortcuts 1/3: Navigation + Auswahl + Löschen FERTIG)
Tastenkürzel-Karte 1 von 3 (Sly-Freigabe 16.10., finale Belegung).
**`docs/tasten.md` ist jetzt die deutsche Source of truth** für ALLE
Shortcut-Karten — Karten 2/3 ergänzen dort.
- **Konventionen (alle Karten):** Tastaturcode-basiert — InputMap-Actions mit
PHYSISCHEN `KEY_*`-Konstanten (`core/editor/shortcut_keys.gd`, registriert in
`main._ready`, idempotent). Physische Codes = deutsche (QWERTZ) und
Windows-Layouts identisch, keine OS-Hardcodes. Jede Objekt-Operation =
EIN ActionHistory-Eintrag (Ausnahme: reine Kamera-Aktionen).
- **Freelook-Flug:** Rechte Maustaste GEDACHT halten = Umsehen + Flug
(W/S/A/D + Leertaste hoch/Shift runter, Strg = ×3, Alt = ×0,3, Mausrad =
Grundgeschwindigkeit DAUERHAFT). Umschalt+F = Flug dauerhaft togglen.
Orbit bleibt Default —
beim Flug-Exit werden Ziel/Winkel/Abstand aus Flugposition/Blickrichtung
zurückgerechnet (kein Kamera-Sprung, getestet). Mathe pur in
`core/editor/fly_math.gd`, Kamera dünn (`core/viewport/orbit_camera.gd`).
Konflikt-Auflösung: RMB hat ABSOLUTEN Vorrang für den Flug — in JEDEM
Werkzeugzustand (Terrain, Bauen, Spawn, Platzieren; Karte t_2470de89,
kein `rmb_filter` mehr), Mausrad im Flug gehört dem Tempo (interaction
blockt).
- **F** = Auswahl ins Bild zentrieren — Orbit-Ziel wandert auf den Auswahl-
Mittelpunkt (Anker + halbe Mesh-Höhe, `SelectionMath.focus_center`); im
Flug dreht sich nur die Blickrichtung.
- **Esc** = Prioritätskette Werkzeug verlassen → Ghost-Preview abbrechen →
Auswahl aufheben, als pure, headless-getestete Funktion
(`core/editor/shortcut_policy.gd`).
- **Entf (DELETE)** = Auswahl SOFORT löschen ohne Rückfrage (Sly-Yolo) —
wirkt auf Szene-Auswahl UND Objektbaum-Markierung (Baum und Szene sind
synchron), Strg+Z deckt es ab. War schon mehrfach-fähig, jetzt
getestet (EIN Undo-Schritt holt ALLE zurück).
- **Strg+D** = Auswahl duplizieren: Kopie(n) +2 m in X (Rasterversatz),
Füßpunkt unverändert; bei Mehrfachauswahl ALLE Objekte mit EINEM
Undo-Schritt, Budget/Limit gesammelt VORHER geprüft
(`ObjectOps.duplicate_objects`).
- **Strg+LMB** = Mehrfachauswahl umschalten (Objektbaum: SELECT_MULTI +
native Strg+Klick-Logik, spiegelt sich in die Viewport-Auswahl und
umgekehrt). Bestandsaktionen (Löschen, Duplizieren, Ziehen, Gruppieren)
wirken auf die GANZE Mehrfachauswahl.
- **Tests:** `tests/test_shortcuts.gd` NEU (70 Checks: InputMap-Actions
inkl. Idempotenz, Freelook-Mathe, Esc-Prioritäten, Duplizieren +2 m X
mit Undo-Roundtrip, Entf-Mehrfachauswahl gegen die ECHTE interaction.gd,
Focus-Mittelpunkt, Kamera Flug-Roundtrip). Alle 8 Suiten grün
(571 Checks), Builder-Export 21/0, Parse-Check aller geänderten Scripts
(`tools/parse_check_shortcuts.sh`).

View file

@ -1,85 +0,0 @@
# ScopedVR Map Builder auf einem anderen PC installieren
Diese Anleitung erklärt Schritt für Schritt, wie du den Map Builder auf einem
beliebigen PC zum Laufen bringst und dort eine Map testen kannst — **ohne**
Godot-Installation, **ohne** ScopedVR-Client, **ohne** Programmierkenntnisse.
---
## 1. Voraussetzungen
- Ein normaler PC (Linux oder Windows). Kein Godot, keine Kommandozeile nötig,
wenn die App einmal exportiert wurde.
- Die exportierte App (Ein Ordner oder eine EXE, siehe Schritt 2).
- Den Ordner `models/` mit den 3D-Modellen (siehe Schritt 3).
## 2. Die App bekommen
Die App ist eine eigenständige Datei (z. B. `MapEditor` unter Linux,
`MapEditor.exe` unter Windows). Sie wurde aus dem Map-Editor-Repo exportiert
(`export_presets.cfg`, Preset „Linux“). Auf dem Ziel-PC brauchst du sie nur
in einen beliebigen Ordner zu kopieren, z. B.:
~/MapBuilder/ (Linux)
C:\MapBuilder\ (Windows)
Doppelklick drauf — die App startet. Das war's. Keine Installation.
## 3. Die Modelle danebenlegen (WICHTIG)
Ohne Modelle zeigt die App nur einfache Platzhalter-Kisten. Damit die echten
3D-Modelle erscheinen, muss der Ordner `models/` **direkt neben** der App
liegen (genau so wie hier):
~/MapBuilder/MapEditor ← die App
~/MapBuilder/models/ ← die Modelle
Wie der `models/`-Ordner gefüllt wird: Auf einem PC, auf dem der
Client-Klon liegt, im Map-Editor-Repo laufen lassen:
tools/sync_models.sh
Das Script kopiert alle Objekte (GLB) und die benötigten Texturen in den
`models/`-Ordner — idempotent, du kannst es also gefahrlos mehrmals laufen
lassen. Mit `--check` prüft es nur (Exit 0 = alles aktuell, Exit 1 = es fehlt
etwas). Die Größe wird am Ende angezeigt.
Falls die Modelle fehlen, sagt die App beim Start deutlich:
> „Für die Anzeige fehlen die Modelle. So bekommst du sie: …“
Terrain und Bauteile (Wände, Dächer usw.) funktionieren **immer** — auch ganz
ohne Modelle (Notfall-Modus).
## 4. Eine Map testen
1. App starten.
2. Map öffnen: Datei → Map öffnen, dann die gewünschte `.svmap`-Datei wählen.
3. Zum Testen: Taste **F5** drücken. Du läufst dann selbst durch die Map
(WASD = gehen, Maus = umsehen, Shift = rennen, Leertaste = springen).
4. **ESC** zurück in den Editor.
## 5. Lizenz-Hinweis (bitte wirklich lesen)
Die 3D-Modelle und Texturen im `models/`-Ordner stammen aus dem
**ITHappy-Pack**. Die Lizenz dieses Packs erlaubt es **nicht**, die
Roh-Modelle und Texturen weiterzugeben. Das heißt konkret:
- Die App mit `models/`-Ordner ist für **Slys eigene Rechner** gedacht.
- Der Ordner **darf nicht** an andere Leute geschickt oder hochgeladen werden.
- Die App selbst (ohne `models/`-Ordner) darf geteilt werden — sie zeigt dann
eben nur Platzhalter, bis der Ordner dazukommt.
## 6. Wenn etwas nicht geht
| Problem | Lösung |
| --- | --- |
| Nur Kisten statt echter Modelle | `models/`-Ordner direkt neben die App legen (Schritt 3) |
| Meldung „Modelle fehlen“ | `tools/sync_models.sh` ausführen (Schritt 3) |
| App startet gar nicht | Auf Linux: Datei per Rechtsklick → „Ausführen“ erlauben, oder im Terminal `chmod +x MapEditor` |
| Map lässt sich nicht öffnen | Ist es wirklich eine `.svmap`-Datei? |
---
*Technischer Hintergrund: die Modelle werden zur Laufzeit per GLTFDocument
aus dem `models/`-Ordner geladen (siehe `core/viewport/glb_mesh_store.gd`,
Quellen-Reihenfolge: Einstellungen → Nachbar-Ordner `../scopedvr-client` →
`models/`). Erster Treffer gewinnt.*

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://8ivgqetbm82q"
path="res://.godot/imported/create_vertex.svg-935b2fd3add0dedfb62239b6f1e7a4db.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/create_vertex.svg"
dest_files=["res://.godot/imported/create_vertex.svg-935b2fd3add0dedfb62239b6f1e7a4db.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -4,9 +4,9 @@ Build modular structures directly in the Godot 4.7 editor. The panel appears at
## Install and try the demo
Open this repository as a Godot project to explore `addons/modular_builder/demo/Demo.tscn`. After Godot finishes importing the assets, enable **Modular Builder (English)** in **Project > Project Settings > Plugins**. The demo and its five textures are contained in `addons/modular_builder/demo/`.
Open this repository as a Godot project to explore `addons/modular_builder_en/demo/Demo.tscn`. After Godot finishes importing the assets, enable **Modular Builder (English)** in **Project > Project Settings > Plugins**. The demo and its five textures are contained in `addons/modular_builder_en/demo/`.
To use the plugin in an existing project, extract the ZIP at the project root so it creates only `addons/modular_builder/`, then enable **Modular Builder (English)** in **Project > Project Settings > Plugins**. Open a 3D scene to use the editor panel. The demo remains inside the addon folder and can be removed if you do not need it.
To use the plugin in an existing project, extract the ZIP at the project root so it creates only `addons/modular_builder_en/`, then enable **Modular Builder (English)** in **Project > Project Settings > Plugins**. Open a 3D scene to use the editor panel. The demo remains inside the addon folder and can be removed if you do not need it.
## Build
@ -28,7 +28,7 @@ Save a selected structure as a reusable `.tscn` module, or insert a saved module
## Files and license
The English plugin lives in `addons/modular_builder/`. Editor code is split into UI, scene actions, detail placement, vertex editing, and viewport interaction. Geometry generation, mesh baking, roof/pillar placement, and vertex simplification have separate scripts.
The English plugin lives in `addons/modular_builder_en/`. Editor code is split into UI, scene actions, detail placement, vertex editing, and viewport interaction. Geometry generation, mesh baking, roof/pillar placement, and vertex simplification have separate scripts.
The original plugin code, icons, and documentation are dedicated to the public domain under [CC0 1.0](LICENSE). The bundled demo textures are example project assets; the CC0 dedication in this repository's license does not automatically cover them. Check their provenance and permission before redistributing the textures separately.

View file

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 315 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://kwgsfdxprj4p"
path="res://.godot/imported/add_building.svg-02348d81d48c2064f5946a668739ab6e.ctex"
uid="uid://dkuvxakhpo11m"
path="res://.godot/imported/add_building.svg-c540099545c836d05aaa84aabb9d42a4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/add_building.svg"
dest_files=["res://.godot/imported/add_building.svg-02348d81d48c2064f5946a668739ab6e.ctex"]
source_file="res://addons/modular_builder_en/icons/add_building.svg"
dest_files=["res://.godot/imported/add_building.svg-c540099545c836d05aaa84aabb9d42a4.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 195 B

After

Width:  |  Height:  |  Size: 195 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://np6bb2n6ohj3"
path="res://.godot/imported/cancel.svg-48b548656752953c76fdbf1fa691b961.ctex"
uid="uid://bdfxl3d88c45p"
path="res://.godot/imported/cancel.svg-47b87f3e857516f74642fe118e831480.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/cancel.svg"
dest_files=["res://.godot/imported/cancel.svg-48b548656752953c76fdbf1fa691b961.ctex"]
source_file="res://addons/modular_builder_en/icons/cancel.svg"
dest_files=["res://.godot/imported/cancel.svg-47b87f3e857516f74642fe118e831480.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 315 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://cjc2d332bqgfy"
path="res://.godot/imported/collision.svg-c665b3652a42642be8f703d82faa449e.ctex"
uid="uid://blcmsiuegfhy"
path="res://.godot/imported/collision.svg-e4cf0320ea5b34c663eb051e13179fb1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/collision.svg"
dest_files=["res://.godot/imported/collision.svg-c665b3652a42642be8f703d82faa449e.ctex"]
source_file="res://addons/modular_builder_en/icons/collision.svg"
dest_files=["res://.godot/imported/collision.svg-e4cf0320ea5b34c663eb051e13179fb1.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 259 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://0jckxct26pqs"
path="res://.godot/imported/gable_wall_right.svg-3336022dcfa05da5960f0dc8e3ecc6ff.ctex"
uid="uid://d4iv2qc3e6k8f"
path="res://.godot/imported/create_vertex.svg-b23af865d7a800b0e078b26b2e99a9f7.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/gable_wall_right.svg"
dest_files=["res://.godot/imported/gable_wall_right.svg-3336022dcfa05da5960f0dc8e3ecc6ff.ctex"]
source_file="res://addons/modular_builder_en/icons/create_vertex.svg"
dest_files=["res://.godot/imported/create_vertex.svg-b23af865d7a800b0e078b26b2e99a9f7.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 265 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://booy2vg0ci6p7"
path="res://.godot/imported/door.svg-5067a7f9805041b1e0019db73b7f247e.ctex"
uid="uid://shrvgd57wqmy"
path="res://.godot/imported/door.svg-abe4c0258238339b0ce092292a14a613.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/door.svg"
dest_files=["res://.godot/imported/door.svg-5067a7f9805041b1e0019db73b7f247e.ctex"]
source_file="res://addons/modular_builder_en/icons/door.svg"
dest_files=["res://.godot/imported/door.svg-abe4c0258238339b0ce092292a14a613.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 404 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://62ci7tsy4yeq"
path="res://.godot/imported/fence.svg-b115d5a88a7e382552216868149e7186.ctex"
uid="uid://bmvaiklprw3ob"
path="res://.godot/imported/fence.svg-4eb777531b4c56b60ad89e9e327e738c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/fence.svg"
dest_files=["res://.godot/imported/fence.svg-b115d5a88a7e382552216868149e7186.ctex"]
source_file="res://addons/modular_builder_en/icons/fence.svg"
dest_files=["res://.godot/imported/fence.svg-4eb777531b4c56b60ad89e9e327e738c.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 298 B

After

Width:  |  Height:  |  Size: 298 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://bey5yf5dc53ms"
path="res://.godot/imported/floor_square.svg-c870ce008800866d2fb4f4c19615fe80.ctex"
uid="uid://b11j8aumv12dr"
path="res://.godot/imported/floor_square.svg-929ec0d7e4700eccd8871b9e7bdc53c8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/floor_square.svg"
dest_files=["res://.godot/imported/floor_square.svg-c870ce008800866d2fb4f4c19615fe80.ctex"]
source_file="res://addons/modular_builder_en/icons/floor_square.svg"
dest_files=["res://.godot/imported/floor_square.svg-929ec0d7e4700eccd8871b9e7bdc53c8.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 291 B

After

Width:  |  Height:  |  Size: 291 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://3df01la6lxht"
path="res://.godot/imported/foundation_square.svg-23bf2f453c993134ea4ea05145d98488.ctex"
uid="uid://blm8vvelfa0ov"
path="res://.godot/imported/floor_triangle.svg-dccf4703c638fbb44617d82da92f981f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/foundation_square.svg"
dest_files=["res://.godot/imported/foundation_square.svg-23bf2f453c993134ea4ea05145d98488.ctex"]
source_file="res://addons/modular_builder_en/icons/floor_triangle.svg"
dest_files=["res://.godot/imported/floor_triangle.svg-dccf4703c638fbb44617d82da92f981f.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 286 B

After

Width:  |  Height:  |  Size: 286 B

View file

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bdg64bvathtkp"
path="res://.godot/imported/foundation_square.svg-dc8be81ca55b382cac6eed9723c4ea7c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder_en/icons/foundation_square.svg"
dest_files=["res://.godot/imported/foundation_square.svg-dc8be81ca55b382cac6eed9723c4ea7c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

Before

Width:  |  Height:  |  Size: 285 B

After

Width:  |  Height:  |  Size: 285 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://ln0jh5ibvhe0"
path="res://.godot/imported/foundation_triangle.svg-4b3e87f2198d6b6a8427029009457134.ctex"
uid="uid://c7sts0wrdier2"
path="res://.godot/imported/foundation_triangle.svg-c2747d1fa5be8206a98f02f2c53db66f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/foundation_triangle.svg"
dest_files=["res://.godot/imported/foundation_triangle.svg-4b3e87f2198d6b6a8427029009457134.ctex"]
source_file="res://addons/modular_builder_en/icons/foundation_triangle.svg"
dest_files=["res://.godot/imported/foundation_triangle.svg-c2747d1fa5be8206a98f02f2c53db66f.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://b16auokjgcjuv"
path="res://.godot/imported/gable_wall.svg-1d82415321f6b6ad59f51ba7640a6c53.ctex"
uid="uid://420a0ay7eep0"
path="res://.godot/imported/gable_wall.svg-c2d60f6ba4255837e31c628621486d99.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/gable_wall.svg"
dest_files=["res://.godot/imported/gable_wall.svg-1d82415321f6b6ad59f51ba7640a6c53.ctex"]
source_file="res://addons/modular_builder_en/icons/gable_wall.svg"
dest_files=["res://.godot/imported/gable_wall.svg-c2d60f6ba4255837e31c628621486d99.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 336 B

After

Width:  |  Height:  |  Size: 336 B

View file

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cbap2sd7o8fwg"
path="res://.godot/imported/gable_wall_right.svg-ad644706ef5497d44f89d17f68ec357c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder_en/icons/gable_wall_right.svg"
dest_files=["res://.godot/imported/gable_wall_right.svg-ad644706ef5497d44f89d17f68ec357c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 276 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://cf2okj2f2eq1h"
path="res://.godot/imported/hatch.svg-f0ee2bc7f824245d3082af2deb5cb59a.ctex"
uid="uid://bhqb8ky2bus1f"
path="res://.godot/imported/hatch.svg-0413a819fca720a8bc1e64dc6ad76826.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/hatch.svg"
dest_files=["res://.godot/imported/hatch.svg-f0ee2bc7f824245d3082af2deb5cb59a.ctex"]
source_file="res://addons/modular_builder_en/icons/hatch.svg"
dest_files=["res://.godot/imported/hatch.svg-0413a819fca720a8bc1e64dc6ad76826.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 293 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://dqhgwj72rp5o1"
path="res://.godot/imported/load_module.svg-308877482b25606c5b7ed43292d640f3.ctex"
uid="uid://yesw2vj8yaah"
path="res://.godot/imported/load_module.svg-63e2b3e2ac8c24bc991fcf4e75ace01b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/load_module.svg"
dest_files=["res://.godot/imported/load_module.svg-308877482b25606c5b7ed43292d640f3.ctex"]
source_file="res://addons/modular_builder_en/icons/load_module.svg"
dest_files=["res://.godot/imported/load_module.svg-63e2b3e2ac8c24bc991fcf4e75ace01b.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 460 B

After

Width:  |  Height:  |  Size: 460 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://dghymeocq75is"
path="res://.godot/imported/floor_triangle.svg-cbffdc894d9762c4cb9806dbfa52405c.ctex"
uid="uid://1amy53ump51l"
path="res://.godot/imported/merge_vertices.svg-5ece4f02ab55d6738ab8836b942201e1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/floor_triangle.svg"
dest_files=["res://.godot/imported/floor_triangle.svg-cbffdc894d9762c4cb9806dbfa52405c.ctex"]
source_file="res://addons/modular_builder_en/icons/merge_vertices.svg"
dest_files=["res://.godot/imported/merge_vertices.svg-5ece4f02ab55d6738ab8836b942201e1.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 351 B

After

Width:  |  Height:  |  Size: 351 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://n7jw3p1fa5eu"
path="res://.godot/imported/merge_vertices.svg-d9204aed762a783069511285341aaecc.ctex"
uid="uid://cfh7b0kxdwl6i"
path="res://.godot/imported/move_vertex.svg-ceb416f7c4a6671d49b72b5d7058c829.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/merge_vertices.svg"
dest_files=["res://.godot/imported/merge_vertices.svg-d9204aed762a783069511285341aaecc.ctex"]
source_file="res://addons/modular_builder_en/icons/move_vertex.svg"
dest_files=["res://.godot/imported/move_vertex.svg-ceb416f7c4a6671d49b72b5d7058c829.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 275 B

After

Width:  |  Height:  |  Size: 275 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://bkxxyh8ec5oed"
path="res://.godot/imported/parapet.svg-f91880fefed49e8dc8e533dcad6bb584.ctex"
uid="uid://dl55t56w8agod"
path="res://.godot/imported/parapet.svg-ed0a5acaa465eefbbe527d0d92ac41e6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/parapet.svg"
dest_files=["res://.godot/imported/parapet.svg-f91880fefed49e8dc8e533dcad6bb584.ctex"]
source_file="res://addons/modular_builder_en/icons/parapet.svg"
dest_files=["res://.godot/imported/parapet.svg-ed0a5acaa465eefbbe527d0d92ac41e6.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 257 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://dukm4mprtq7l"
path="res://.godot/imported/pillar.svg-ccd9ad9da9982abc939a9856fa296417.ctex"
uid="uid://bsvap0ukvvtxl"
path="res://.godot/imported/pillar.svg-0acb51ea7c090f4767757e19389c7697.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/pillar.svg"
dest_files=["res://.godot/imported/pillar.svg-ccd9ad9da9982abc939a9856fa296417.ctex"]
source_file="res://addons/modular_builder_en/icons/pillar.svg"
dest_files=["res://.godot/imported/pillar.svg-0acb51ea7c090f4767757e19389c7697.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 250 B

After

Width:  |  Height:  |  Size: 250 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://bjvg4halxhoy5"
path="res://.godot/imported/remove_vertex.svg-824eacf485182fddca49a06831d65906.ctex"
uid="uid://d3smkmuij3tiu"
path="res://.godot/imported/remove_vertex.svg-a2650c4a5b88e73778470ad394dd8d24.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/remove_vertex.svg"
dest_files=["res://.godot/imported/remove_vertex.svg-824eacf485182fddca49a06831d65906.ctex"]
source_file="res://addons/modular_builder_en/icons/remove_vertex.svg"
dest_files=["res://.godot/imported/remove_vertex.svg-a2650c4a5b88e73778470ad394dd8d24.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 257 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://dvtqw0h5dd5yl"
path="res://.godot/imported/roof_gable.svg-8b4da12734ae02486f4e916ee6f2b16d.ctex"
uid="uid://7mg7tjvygg82"
path="res://.godot/imported/roof_gable.svg-19c7e85e7be7616ba9781f8a0b3ca1c3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/roof_gable.svg"
dest_files=["res://.godot/imported/roof_gable.svg-8b4da12734ae02486f4e916ee6f2b16d.ctex"]
source_file="res://addons/modular_builder_en/icons/roof_gable.svg"
dest_files=["res://.godot/imported/roof_gable.svg-19c7e85e7be7616ba9781f8a0b3ca1c3.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 306 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://ccew6wsdsj0kn"
path="res://.godot/imported/roof_pyramid.svg-97d144eff93367622d2cd47f9133b57d.ctex"
uid="uid://55uqhamplshs"
path="res://.godot/imported/roof_pyramid.svg-9e1f692744fe6843f12da4ad40b1e7fd.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/roof_pyramid.svg"
dest_files=["res://.godot/imported/roof_pyramid.svg-97d144eff93367622d2cd47f9133b57d.ctex"]
source_file="res://addons/modular_builder_en/icons/roof_pyramid.svg"
dest_files=["res://.godot/imported/roof_pyramid.svg-9e1f692744fe6843f12da4ad40b1e7fd.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 236 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://wyg331tl3rdt"
path="res://.godot/imported/roof_shed.svg-47c9ee22596d5277674eb88f312c9478.ctex"
uid="uid://dl0xf605xhwdl"
path="res://.godot/imported/roof_shed.svg-6a66b1c82d6d6e005d37c43a5b9009df.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/roof_shed.svg"
dest_files=["res://.godot/imported/roof_shed.svg-47c9ee22596d5277674eb88f312c9478.ctex"]
source_file="res://addons/modular_builder_en/icons/roof_shed.svg"
dest_files=["res://.godot/imported/roof_shed.svg-6a66b1c82d6d6e005d37c43a5b9009df.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 329 B

After

Width:  |  Height:  |  Size: 329 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://cxcb3kjn18udy"
path="res://.godot/imported/rotate.svg-84e58dd8fc90e4d634e7fbc55f8eda70.ctex"
uid="uid://dhi55x4bo0dmp"
path="res://.godot/imported/rotate.svg-2f2c1519ec865048747f49f7cbeed3c8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/rotate.svg"
dest_files=["res://.godot/imported/rotate.svg-84e58dd8fc90e4d634e7fbc55f8eda70.ctex"]
source_file="res://addons/modular_builder_en/icons/rotate.svg"
dest_files=["res://.godot/imported/rotate.svg-2f2c1519ec865048747f49f7cbeed3c8.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 293 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://pnq0qqniy5hx"
path="res://.godot/imported/save_module.svg-eecd37f83ae3c8dc3d40dabc8e8213e4.ctex"
uid="uid://di37e5sjuktph"
path="res://.godot/imported/save_module.svg-de34a0b11c2e62ba100617a23de20ad2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/save_module.svg"
dest_files=["res://.godot/imported/save_module.svg-eecd37f83ae3c8dc3d40dabc8e8213e4.ctex"]
source_file="res://addons/modular_builder_en/icons/save_module.svg"
dest_files=["res://.godot/imported/save_module.svg-de34a0b11c2e62ba100617a23de20ad2.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 355 B

After

Width:  |  Height:  |  Size: 355 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://b6ibyaujhybao"
path="res://.godot/imported/stairs.svg-9b9203b050bb5cf817168a5866b66b2a.ctex"
uid="uid://bl85bimtdaffm"
path="res://.godot/imported/stairs.svg-8a83477aca2bfaf1ecd92ddcb3d7ad52.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/stairs.svg"
dest_files=["res://.godot/imported/stairs.svg-9b9203b050bb5cf817168a5866b66b2a.ctex"]
source_file="res://addons/modular_builder_en/icons/stairs.svg"
dest_files=["res://.godot/imported/stairs.svg-8a83477aca2bfaf1ecd92ddcb3d7ad52.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 267 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://cmkpq8teurcwt"
path="res://.godot/imported/wall.svg-b57c613ae16e9db4887d98c30036dee9.ctex"
uid="uid://c61wb4mnf0wls"
path="res://.godot/imported/wall.svg-25deeb903c389412115a2f457cdde263.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/wall.svg"
dest_files=["res://.godot/imported/wall.svg-b57c613ae16e9db4887d98c30036dee9.ctex"]
source_file="res://addons/modular_builder_en/icons/wall.svg"
dest_files=["res://.godot/imported/wall.svg-25deeb903c389412115a2f457cdde263.ctex"]
[params]

View file

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 222 B

View file

@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://cu21qr4i7dns1"
path="res://.godot/imported/window.svg-b79f89a5f56cfe779422a43f44ba067f.ctex"
uid="uid://np7qscb7nf3s"
path="res://.godot/imported/window.svg-ac8dc7e4787d27b79bd94a1bd7b1f8f8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/window.svg"
dest_files=["res://.godot/imported/window.svg-b79f89a5f56cfe779422a43f44ba067f.ctex"]
source_file="res://addons/modular_builder_en/icons/window.svg"
dest_files=["res://.godot/imported/window.svg-ac8dc7e4787d27b79bd94a1bd7b1f8f8.ctex"]
[params]

View file

@ -1,10 +1,10 @@
@tool
extends "res://addons/modular_builder/modular_builder_editor_ui.gd"
extends "res://addons/modular_builder_en/modular_builder_editor_ui.gd"
## ScopedVR-Bibliothek-Export (Karte t_82d2a8dd): dritter Export-Modus
## „In ScopedVR-Bibliothek" — gebackenes Mesh + Trimesh-Kollision durch die
## Objekt-Pipeline (tools/objekt im Match-Server-Repo), Name-Dialog inklusive.
const SCOPEDVR_EXPORT_UI := preload("res://addons/modular_builder/scopedvr_export_ui.gd")
const SCOPEDVR_EXPORT_UI := preload("res://addons/modular_builder_en/scopedvr_export_ui.gd")
var _scopedvr_export: RefCounted = null
func _add_piece() -> void:

View file

@ -1,8 +1,8 @@
@tool
extends "res://addons/modular_builder/modular_builder_editor_actions.gd"
extends "res://addons/modular_builder_en/modular_builder_editor_actions.gd"
const PILLAR_SUPPORT = preload("res://addons/modular_builder/modular_pillar_support.gd")
const VERTEX_SIMPLIFIER = preload("res://addons/modular_builder/modular_vertex_simplifier.gd")
const PILLAR_SUPPORT = preload("res://addons/modular_builder_en/modular_pillar_support.gd")
const VERTEX_SIMPLIFIER = preload("res://addons/modular_builder_en/modular_vertex_simplifier.gd")
func _create_detail_from_click(camera: Camera3D, screen_position: Vector2) -> bool:
var placement := _find_detail_placement(camera, screen_position)

View file

@ -1,9 +1,9 @@
@tool
extends EditorPlugin
const PIECE_SCRIPT = preload("res://addons/modular_builder/modular_piece.gd")
const DETAIL_SCRIPT = preload("res://core/vendor/modular_builder/modular_building_detail.gd")
const MESH_BAKER = preload("res://addons/modular_builder/modular_mesh_baker.gd")
const PIECE_SCRIPT = preload("res://addons/modular_builder_en/modular_piece.gd")
const DETAIL_SCRIPT = preload("res://addons/modular_builder_en/modular_building_detail.gd")
const MESH_BAKER = preload("res://addons/modular_builder_en/modular_mesh_baker.gd")
const TOOL_NONE := ""
const TOOL_FOUNDATION := "foundation"
const TOOL_FOUNDATION_SQUARE := "foundation_square"
@ -133,19 +133,19 @@ func _enter_tree() -> void:
size_spin.suffix = " m"
size_spin.custom_minimum_size = Vector2(100, 0)
_creation_row.add_child(size_spin)
var create_button := _make_icon_button("res://addons/modular_builder/icons/add_building.svg", "Add ModularBuilderPiece", Color(0.22, 0.30, 0.38))
var create_button := _make_icon_button("res://addons/modular_builder_en/icons/add_building.svg", "Add ModularBuilderPiece", Color(0.22, 0.30, 0.38))
create_button.pressed.connect(Callable(self, "_add_piece"))
_creation_row.add_child(create_button)
var rotate_button := _make_icon_button("res://addons/modular_builder/icons/rotate.svg", "Rotate selected piece 90°", Color(0.22, 0.30, 0.38))
var rotate_button := _make_icon_button("res://addons/modular_builder_en/icons/rotate.svg", "Rotate selected piece 90°", Color(0.22, 0.30, 0.38))
rotate_button.pressed.connect(Callable(self, "_rotate_selected_piece"))
_creation_row.add_child(rotate_button)
var collision_button := _make_icon_button("res://addons/modular_builder/icons/collision.svg", "Add general collision to selected structure", Color(0.22, 0.30, 0.38))
var collision_button := _make_icon_button("res://addons/modular_builder_en/icons/collision.svg", "Add general collision to selected structure", Color(0.22, 0.30, 0.38))
collision_button.pressed.connect(Callable(self, "_add_general_collision"))
_creation_row.add_child(collision_button)
var save_module_button := _make_icon_button("res://addons/modular_builder/icons/save_module.svg", "Save selected structure as a .tscn module", Color(0.22, 0.30, 0.38))
var save_module_button := _make_icon_button("res://addons/modular_builder_en/icons/save_module.svg", "Save selected structure as a .tscn module", Color(0.22, 0.30, 0.38))
save_module_button.pressed.connect(Callable(self, "_show_save_module_dialog"))
_creation_row.add_child(save_module_button)
var load_module_button := _make_icon_button("res://addons/modular_builder/icons/load_module.svg", "Insert saved module into the scene", Color(0.22, 0.30, 0.38))
var load_module_button := _make_icon_button("res://addons/modular_builder_en/icons/load_module.svg", "Insert saved module into the scene", Color(0.22, 0.30, 0.38))
load_module_button.pressed.connect(Callable(self, "_show_load_module_dialog"))
_creation_row.add_child(load_module_button)
_export_row = HFlowContainer.new()
@ -166,33 +166,33 @@ func _enter_tree() -> void:
dock.add_child(_tool_row)
_tool_button_group = ButtonGroup.new()
_tool_button_group.allow_unpress = true
_add_tool_button("res://addons/modular_builder/icons/foundation_square.svg", "Square foundation", TOOL_FOUNDATION_SQUARE, Color(0.08, 0.48, 0.22))
_add_tool_button("res://addons/modular_builder/icons/foundation_triangle.svg", "Triangular foundation", TOOL_FOUNDATION_TRIANGLE, Color(0.08, 0.48, 0.22))
_add_tool_button("res://addons/modular_builder/icons/wall.svg", "Wall", TOOL_WALL, Color(0.65, 0.12, 0.12))
_add_tool_button("res://addons/modular_builder/icons/gable_wall.svg", "Gable wall", TOOL_GABLE_WALL, Color(0.65, 0.12, 0.12))
_add_tool_button("res://addons/modular_builder/icons/gable_wall_right.svg", "Right triangular gable wall", TOOL_GABLE_WALL_RIGHT, Color(0.65, 0.12, 0.12))
_add_tool_button("res://addons/modular_builder/icons/floor_square.svg", "Square floor", TOOL_FLOOR_SQUARE, Color(0.68, 0.48, 0.06))
_add_tool_button("res://addons/modular_builder/icons/floor_triangle.svg", "Triangular floor", TOOL_FLOOR_TRIANGLE, Color(0.68, 0.48, 0.06))
_add_tool_button("res://addons/modular_builder/icons/roof_gable.svg", "Gable roof", TOOL_ROOF_GABLE, Color(0.10, 0.38, 0.62))
_add_tool_button("res://addons/modular_builder/icons/roof_shed.svg", "Shed roof", TOOL_ROOF_SHED, Color(0.10, 0.38, 0.62))
_add_tool_button("res://addons/modular_builder/icons/roof_pyramid.svg", "Pyramid roof", TOOL_ROOF_PYRAMID, Color(0.10, 0.38, 0.62))
_add_tool_button("res://addons/modular_builder/icons/stairs.svg", "Stairs", TOOL_STAIRS, Color(0.10, 0.38, 0.62))
_add_tool_button("res://addons/modular_builder/icons/fence.svg", "Decorative fence", TOOL_FENCE, Color(0.88, 0.22, 0.55))
_add_tool_button("res://addons/modular_builder/icons/parapet.svg", "Parapet: single rail and posts", TOOL_PARAPET, Color(0.88, 0.22, 0.55))
_add_tool_button("res://addons/modular_builder/icons/window.svg", "Detail: window", TOOL_WINDOW, Color(0.88, 0.22, 0.55))
_add_tool_button("res://addons/modular_builder/icons/door.svg", "Detail: door", TOOL_DOOR, Color(0.88, 0.22, 0.55))
_add_tool_button("res://addons/modular_builder/icons/hatch.svg", "Detail: hatch", TOOL_HATCH, Color(0.88, 0.22, 0.55))
_add_tool_button("res://addons/modular_builder/icons/pillar.svg", "Plain pillar: two vertices or Alt+click for a vertical pillar", TOOL_PILLAR, Color(0.88, 0.22, 0.55))
var cancel_button := _make_icon_button("res://addons/modular_builder/icons/cancel.svg", "Deactivate tool", Color(0.34, 0.20, 0.20))
_add_tool_button("res://addons/modular_builder_en/icons/foundation_square.svg", "Square foundation", TOOL_FOUNDATION_SQUARE, Color(0.08, 0.48, 0.22))
_add_tool_button("res://addons/modular_builder_en/icons/foundation_triangle.svg", "Triangular foundation", TOOL_FOUNDATION_TRIANGLE, Color(0.08, 0.48, 0.22))
_add_tool_button("res://addons/modular_builder_en/icons/wall.svg", "Wall", TOOL_WALL, Color(0.65, 0.12, 0.12))
_add_tool_button("res://addons/modular_builder_en/icons/gable_wall.svg", "Gable wall", TOOL_GABLE_WALL, Color(0.65, 0.12, 0.12))
_add_tool_button("res://addons/modular_builder_en/icons/gable_wall_right.svg", "Right triangular gable wall", TOOL_GABLE_WALL_RIGHT, Color(0.65, 0.12, 0.12))
_add_tool_button("res://addons/modular_builder_en/icons/floor_square.svg", "Square floor", TOOL_FLOOR_SQUARE, Color(0.68, 0.48, 0.06))
_add_tool_button("res://addons/modular_builder_en/icons/floor_triangle.svg", "Triangular floor", TOOL_FLOOR_TRIANGLE, Color(0.68, 0.48, 0.06))
_add_tool_button("res://addons/modular_builder_en/icons/roof_gable.svg", "Gable roof", TOOL_ROOF_GABLE, Color(0.10, 0.38, 0.62))
_add_tool_button("res://addons/modular_builder_en/icons/roof_shed.svg", "Shed roof", TOOL_ROOF_SHED, Color(0.10, 0.38, 0.62))
_add_tool_button("res://addons/modular_builder_en/icons/roof_pyramid.svg", "Pyramid roof", TOOL_ROOF_PYRAMID, Color(0.10, 0.38, 0.62))
_add_tool_button("res://addons/modular_builder_en/icons/stairs.svg", "Stairs", TOOL_STAIRS, Color(0.10, 0.38, 0.62))
_add_tool_button("res://addons/modular_builder_en/icons/fence.svg", "Decorative fence", TOOL_FENCE, Color(0.88, 0.22, 0.55))
_add_tool_button("res://addons/modular_builder_en/icons/parapet.svg", "Parapet: single rail and posts", TOOL_PARAPET, Color(0.88, 0.22, 0.55))
_add_tool_button("res://addons/modular_builder_en/icons/window.svg", "Detail: window", TOOL_WINDOW, Color(0.88, 0.22, 0.55))
_add_tool_button("res://addons/modular_builder_en/icons/door.svg", "Detail: door", TOOL_DOOR, Color(0.88, 0.22, 0.55))
_add_tool_button("res://addons/modular_builder_en/icons/hatch.svg", "Detail: hatch", TOOL_HATCH, Color(0.88, 0.22, 0.55))
_add_tool_button("res://addons/modular_builder_en/icons/pillar.svg", "Plain pillar: two vertices or Alt+click for a vertical pillar", TOOL_PILLAR, Color(0.88, 0.22, 0.55))
var cancel_button := _make_icon_button("res://addons/modular_builder_en/icons/cancel.svg", "Deactivate tool", Color(0.34, 0.20, 0.20))
cancel_button.pressed.connect(Callable(self, "_set_tool").bind(TOOL_NONE))
_tool_row.add_child(cancel_button)
_vertex_edit_row = HFlowContainer.new()
dock.add_child(_vertex_edit_row)
_vertex_edit_row.add_child(_label("Vertices:"))
_add_tool_button("res://addons/modular_builder/icons/merge_vertices.svg", "Merge vertices to reduce markers", TOOL_MERGE_VERTICES, Color(0.38, 0.32, 0.52), _vertex_edit_row)
_add_tool_button("res://addons/modular_builder/icons/create_vertex.svg", "Create a connection vertex on the clicked surface", TOOL_CREATE_VERTEX, Color(0.38, 0.32, 0.52), _vertex_edit_row)
_add_tool_button("res://addons/modular_builder/icons/move_vertex.svg", "Move a manual connection vertex", TOOL_MOVE_VERTEX, Color(0.38, 0.32, 0.52), _vertex_edit_row)
_add_tool_button("res://addons/modular_builder/icons/remove_vertex.svg", "Remove a manual connection vertex", TOOL_REMOVE_VERTEX, Color(0.38, 0.32, 0.52), _vertex_edit_row)
_add_tool_button("res://addons/modular_builder_en/icons/merge_vertices.svg", "Merge vertices to reduce markers", TOOL_MERGE_VERTICES, Color(0.38, 0.32, 0.52), _vertex_edit_row)
_add_tool_button("res://addons/modular_builder_en/icons/create_vertex.svg", "Create a connection vertex on the clicked surface", TOOL_CREATE_VERTEX, Color(0.38, 0.32, 0.52), _vertex_edit_row)
_add_tool_button("res://addons/modular_builder_en/icons/move_vertex.svg", "Move a manual connection vertex", TOOL_MOVE_VERTEX, Color(0.38, 0.32, 0.52), _vertex_edit_row)
_add_tool_button("res://addons/modular_builder_en/icons/remove_vertex.svg", "Remove a manual connection vertex", TOOL_REMOVE_VERTEX, Color(0.38, 0.32, 0.52), _vertex_edit_row)
var marker_options_row := HFlowContainer.new()
dock.add_child(marker_options_row)
_marker_scope_check = CheckBox.new()

View file

@ -1,7 +1,7 @@
@tool
extends "res://addons/modular_builder/modular_builder_editor_details.gd"
extends "res://addons/modular_builder_en/modular_builder_editor_details.gd"
const MESH_PICKER = preload("res://addons/modular_builder/modular_mesh_picker.gd")
const MESH_PICKER = preload("res://addons/modular_builder_en/modular_mesh_picker.gd")
func _sync_vertex_markers() -> void:
var root := _get_selected_piece()

View file

@ -1,7 +1,7 @@
@tool
extends "res://addons/modular_builder/modular_builder_editor_vertices.gd"
extends "res://addons/modular_builder_en/modular_builder_editor_vertices.gd"
const ROOF_SUPPORT = preload("res://addons/modular_builder/modular_roof_support.gd")
const ROOF_SUPPORT = preload("res://addons/modular_builder_en/modular_roof_support.gd")
func _forward_3d_gui_input(camera: Camera3D, event: InputEvent) -> int:
if active_tool != TOOL_NONE and event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:

View file

@ -0,0 +1,284 @@
@tool
class_name ModularBuilderDetail
extends Node3D
enum DetailType { WINDOW, DOOR, HATCH, PILLAR, FENCE, PARAPET }
enum DetailMode { OPENING, SEPARATE_OBJECT }
enum WindowStyle { SINGLE_PANE, CROSS_PANE }
enum DoorStyle { FLAT, PANELED }
@export var detail_type: DetailType = DetailType.WINDOW:
set(value):
detail_type = value
notify_property_list_changed()
_rebuild()
@export var detail_mode: DetailMode = DetailMode.OPENING:
set(value):
detail_mode = value
notify_property_list_changed()
_rebuild()
@export var center_align: bool = false:
set(value):
center_align = value
_apply_pillar_alignment()
notify_property_list_changed()
@export_storage var pillar_direction: Vector3 = Vector3.UP
@export_storage var pillar_alignment_basis: Basis = Basis.IDENTITY
@export var window_style: WindowStyle = WindowStyle.SINGLE_PANE:
set(value):
window_style = value
_rebuild()
@export var door_style: DoorStyle = DoorStyle.FLAT:
set(value):
door_style = value
_rebuild()
@export_range(0.01, 5.0, 0.001, "suffix:m") var width: float = 1.0:
set(value):
width = value
_rebuild()
@export_range(0.2, 5.0, 0.001, "suffix:m") var height: float = 1.0:
set(value):
height = value
_rebuild()
@export_range(0.02, 0.5, 0.001, "suffix:m") var frame_depth: float = 0.08:
set(value):
frame_depth = value
_rebuild()
@export_range(0.1, 8.0, 0.001, "suffix:m") var fence_height: float = 0.7:
set(value):
fence_height = value
_rebuild()
@export_range(0, 100, 1, "suffix: posts") var fence_divisions: int = 1:
set(value):
fence_divisions = value
_rebuild()
@export_storage var path_points: PackedVector3Array = PackedVector3Array()
@export_range(0.01, 0.5, 0.001, "suffix:m") var frame_width: float = 0.08:
set(value):
frame_width = value
_rebuild()
@export var follow_host_thickness: bool = true:
set(value):
follow_host_thickness = value
_rebuild()
@export var surface_material: Material:
set(value):
surface_material = value
_rebuild()
@export var frame_material: Material:
set(value):
frame_material = value
_rebuild()
@export var panel_material: Material:
set(value):
panel_material = value
_rebuild()
func _validate_property(property: Dictionary) -> void:
var separate_object := detail_mode == DetailMode.SEPARATE_OBJECT
var is_window := detail_type == DetailType.WINDOW
var is_door := detail_type == DetailType.DOOR
var has_frame_and_panel := separate_object and detail_type != DetailType.PILLAR and detail_type != DetailType.FENCE and detail_type != DetailType.PARAPET
var show_property := true
match StringName(property.name):
&"detail_mode":
show_property = detail_type != DetailType.PILLAR and detail_type != DetailType.FENCE and detail_type != DetailType.PARAPET
&"center_align":
show_property = detail_type == DetailType.PILLAR
&"fence_height", &"fence_divisions":
show_property = detail_type == DetailType.FENCE or detail_type == DetailType.PARAPET
&"path_points":
show_property = false
&"width":
show_property = detail_type != DetailType.FENCE and detail_type != DetailType.PARAPET
if detail_type == DetailType.PILLAR:
property.hint = PROPERTY_HINT_RANGE
property.hint_string = "0.01,5.0,0.001,suffix:m"
&"height":
show_property = detail_type != DetailType.FENCE and detail_type != DetailType.PARAPET
&"window_style":
show_property = is_window and separate_object
&"door_style":
show_property = is_door and separate_object
&"frame_width", &"frame_material", &"panel_material":
show_property = has_frame_and_panel
&"follow_host_thickness":
show_property = has_frame_and_panel
&"frame_depth":
show_property = has_frame_and_panel and not follow_host_thickness
&"surface_material":
show_property = separate_object
if not show_property:
property.usage = PROPERTY_USAGE_NO_EDITOR
func _ready() -> void:
set_notify_transform(true)
_rebuild()
func _notification(what: int) -> void:
if what == NOTIFICATION_TRANSFORM_CHANGED and is_inside_tree():
var host := get_parent()
if host != null and host.has_method("_rebuild"):
host.call("_rebuild")
func _on_host_thickness_changed() -> void:
_rebuild(false)
func _apply_pillar_alignment() -> void:
if detail_type != DetailType.PILLAR:
return
var direction := pillar_direction.normalized()
if direction.is_zero_approx():
direction = Vector3.UP
if center_align:
var object_axes := pillar_alignment_basis.orthonormalized()
var best_axis := direction
var best_dot := -1.0
for axis in [object_axes.x, object_axes.y, object_axes.z]:
var normalized_axis: Vector3 = axis.normalized()
var alignment := absf(direction.dot(normalized_axis))
if alignment > best_dot:
best_dot = alignment
best_axis = normalized_axis if direction.dot(normalized_axis) >= 0.0 else -normalized_axis
direction = best_axis
var reference_axis := Vector3.FORWARD if absf(direction.dot(Vector3.UP)) > 0.99 else Vector3.UP
var x_axis := reference_axis.cross(direction).normalized()
var z_axis := x_axis.cross(direction).normalized()
transform.basis = Basis(x_axis, direction, z_axis).orthonormalized()
if center_align and pillar_direction.length() > 0.0:
height = pillar_direction.length()
func _rebuild(notify_host: bool = true) -> void:
if not is_inside_tree():
return
for child in get_children():
if child.name == "DetailGeometry":
remove_child(child)
child.queue_free()
var host := get_parent()
if notify_host and host != null and host.has_method("_rebuild"):
host.call("_rebuild")
if detail_mode == DetailMode.OPENING and detail_type != DetailType.FENCE and detail_type != DetailType.PARAPET:
return
var holder := Node3D.new()
holder.name = "DetailGeometry"
add_child(holder)
var scene_root: Node = null
if Engine.is_editor_hint() and not has_meta("modular_builder_preview"):
scene_root = get_tree().edited_scene_root
if scene_root:
holder.owner = scene_root
var effective_depth := _effective_frame_depth()
var frame := minf(maxf(frame_width, 0.01), minf(width, height) * 0.45)
if detail_type == DetailType.FENCE or detail_type == DetailType.PARAPET:
_build_fence_geometry(holder)
return
if detail_type == DetailType.PILLAR:
_add_box(holder, "PillarBody", Vector3(width, height, width), Vector3.ZERO, surface_material)
return
if detail_type == DetailType.HATCH:
_add_box(holder, "LeftFrame", Vector3(frame, effective_depth, height), Vector3(-width * 0.5 + frame * 0.5, 0.0, 0.0), _frame_material())
_add_box(holder, "RightFrame", Vector3(frame, effective_depth, height), Vector3(width * 0.5 - frame * 0.5, 0.0, 0.0), _frame_material())
_add_box(holder, "NearFrame", Vector3(width, effective_depth, frame), Vector3(0.0, 0.0, -height * 0.5 + frame * 0.5), _frame_material())
_add_box(holder, "FarFrame", Vector3(width, effective_depth, frame), Vector3(0.0, 0.0, height * 0.5 - frame * 0.5), _frame_material())
_add_box(holder, "HatchPanel", Vector3(width - frame * 2.0, effective_depth * 0.35, height - frame * 2.0), Vector3(0.0, effective_depth * 0.4, 0.0), _panel_material())
return
_add_box(holder, "LeftFrame", Vector3(frame, height, effective_depth), Vector3(-width * 0.5 + frame * 0.5, 0.0, 0.0), _frame_material())
_add_box(holder, "RightFrame", Vector3(frame, height, effective_depth), Vector3(width * 0.5 - frame * 0.5, 0.0, 0.0), _frame_material())
_add_box(holder, "TopFrame", Vector3(width, frame, effective_depth), Vector3(0.0, height * 0.5 - frame * 0.5, 0.0), _frame_material())
if detail_type != DetailType.DOOR:
_add_box(holder, "BottomFrame", Vector3(width, frame, effective_depth), Vector3(0.0, -height * 0.5 + frame * 0.5, 0.0), _frame_material())
if detail_type == DetailType.WINDOW:
_add_box(holder, "WindowPanel", Vector3(maxf(width - frame * 2.0, 0.001), maxf(height - frame * 2.0, 0.001), effective_depth * 0.35), Vector3.ZERO, _panel_material())
if window_style == WindowStyle.CROSS_PANE:
_add_box(holder, "VerticalMullion", Vector3(frame * 0.55, height - frame * 2.0, effective_depth * 0.4), Vector3.ZERO, _frame_material())
_add_box(holder, "HorizontalMullion", Vector3(width - frame * 2.0, frame * 0.55, effective_depth * 0.4), Vector3.ZERO, _frame_material())
elif detail_type == DetailType.DOOR:
_add_box(holder, "DoorPanel", Vector3(width - frame * 2.0, height - frame, effective_depth * 0.45), Vector3(0.0, -frame * 0.5, 0.0), _panel_material())
if door_style == DoorStyle.PANELED:
var inset_depth := maxf(effective_depth * 0.04, 0.005)
var panel_width := (width - frame * 2.0) * 0.82
var panel_height := maxf((height - frame) * 0.28, 0.05)
for panel_index in range(2):
var panel_y := -frame * 0.5 + (panel_index - 0.5) * (panel_height + frame * 0.25)
_add_box(holder, "DoorInset_%d" % panel_index, Vector3(panel_width, panel_height, inset_depth), Vector3(0.0, panel_y, effective_depth * 0.24), _frame_material())
func _effective_frame_depth() -> float:
if follow_host_thickness:
var host := get_parent()
if host != null:
var host_thickness = host.get("thickness")
if host_thickness is float or host_thickness is int:
var depth := float(host_thickness)
if detail_type == DetailType.WINDOW or detail_type == DetailType.DOOR:
depth *= 1.25
return maxf(depth, 0.02)
return frame_depth
func _build_fence_geometry(holder: Node3D) -> void:
if path_points.size() < 2 or fence_height <= 0.0:
return
var start := path_points[0]
var finish := path_points[1]
var path := finish - start
var path_length := path.length()
if path_length < 0.05:
return
var path_axis := path / path_length
var up_axis := Vector3.UP - path_axis * path_axis.dot(Vector3.UP)
if up_axis.length_squared() < 0.001:
up_axis = Vector3.FORWARD - path_axis * path_axis.dot(Vector3.FORWARD)
up_axis = up_axis.normalized()
var side_axis := path_axis.cross(up_axis).normalized()
var post_width := 0.16 if detail_type == DetailType.PARAPET else 0.08
var post_count := fence_divisions + 2
var posts: Array[Vector3] = []
for i in range(post_count):
var base := start.lerp(finish, float(i) / float(post_count - 1))
posts.append(base)
_add_oriented_box(holder, "Post_%d" % i, base + up_axis * fence_height * 0.5, path_axis, up_axis, side_axis, Vector3(post_width, fence_height, post_width))
for i in range(posts.size() - 1):
var a := posts[i]
var b := posts[i + 1]
var segment := b - a
var segment_length := segment.length()
if detail_type == DetailType.PARAPET:
_add_oriented_box(holder, "Rail_%d" % i, (a + b) * 0.5 + up_axis * fence_height * 0.78, segment.normalized(), up_axis, side_axis, Vector3(segment_length, post_width * 0.8, post_width * 0.8))
else:
for height_ratio in [0.28, 0.78]:
_add_oriented_box(holder, "Rail_%d_%d" % [i, int(height_ratio * 100.0)], (a + b) * 0.5 + up_axis * fence_height * height_ratio, segment.normalized(), up_axis, side_axis, Vector3(segment_length, post_width, post_width))
func _add_oriented_box(holder: Node3D, node_name: String, center: Vector3, axis_x: Vector3, axis_y: Vector3, axis_z: Vector3, dimensions: Vector3) -> void:
var mesh_instance := MeshInstance3D.new()
mesh_instance.name = node_name
var box := BoxMesh.new()
box.size = dimensions
mesh_instance.mesh = box
var basis := Basis(axis_x.normalized(), axis_y.normalized(), axis_z.normalized()).orthonormalized()
mesh_instance.transform = Transform3D(basis, center)
mesh_instance.material_override = surface_material
holder.add_child(mesh_instance)
if Engine.is_editor_hint() and not has_meta("modular_builder_preview"):
var scene_root := get_tree().edited_scene_root
if scene_root:
mesh_instance.owner = scene_root
func _frame_material() -> Material:
return frame_material if frame_material != null else surface_material
func _panel_material() -> Material:
return panel_material if panel_material != null else surface_material
func _add_box(holder: Node3D, node_name: String, box_size: Vector3, box_position: Vector3, material: Material = null) -> void:
var mesh_instance := MeshInstance3D.new()
mesh_instance.name = node_name
var box := BoxMesh.new()
box.size = box_size
mesh_instance.mesh = box
mesh_instance.position = box_position
mesh_instance.material_override = material if material != null else surface_material
holder.add_child(mesh_instance)
if Engine.is_editor_hint() and not has_meta("modular_builder_preview"):
var scene_root := get_tree().edited_scene_root
if scene_root:
mesh_instance.owner = scene_root

View file

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

View file

@ -1,6 +1,6 @@
@tool
class_name ModularBuilderPiece
extends "res://core/vendor/modular_builder/modular_piece_geometry.gd"
extends "res://addons/modular_builder_en/modular_piece_geometry.gd"
func _ready() -> void:
_rebuild()

View file

@ -0,0 +1,748 @@
@tool
class_name ModularBuilderPieceGeometry
extends Node3D
const DETAIL_SCRIPT_PATH := "res://addons/modular_builder_en/modular_building_detail.gd"
const DETAIL_TYPE_HATCH := 2
const DETAIL_TYPE_PILLAR := 3
enum PieceType { FOUNDATION_SQUARE, FOUNDATION_TRIANGLE, FLOOR, WALL, BUILDING, GABLE_WALL, ROOF_GABLE, ROOF_SHED, GABLE_WALL_RIGHT, ROOF_PYRAMID, STAIRS, FENCE }
@export var piece_type: PieceType = PieceType.FOUNDATION_SQUARE:
set(value):
piece_type = value
notify_property_list_changed()
_rebuild()
@export_range(0.5, 8.0, 0.001, "suffix:m") var unit_size: float = 2.0:
set(value):
unit_size = value
_rebuild()
@export_range(0.01, 1.0, 0.001, "suffix:m") var thickness: float = 0.2:
set(value):
thickness = value
_rebuild()
for child in get_children():
if child.has_method("_on_host_thickness_changed"):
child.call("_on_host_thickness_changed")
@export_range(0.1, 8.0, 0.001, "suffix:m") var rise: float = 1.0:
set(value):
rise = value
_rebuild()
@export_range(0.01, 32.0, 0.001, "suffix:m") var roof_width: float = 2.0:
set(value):
roof_width = value
_rebuild()
@export_range(1, 100, 1, "suffix: steps") var stair_count: int = 10:
set(value):
stair_count = value
_rebuild()
@export_range(0.01, 8.0, 0.001, "suffix:m") var stair_width: float = 1.0:
set(value):
stair_width = value
_rebuild()
@export_range(0, 100, 1, "suffix: posts") var fence_divisions: int = 1:
set(value):
fence_divisions = value
_rebuild()
@export_range(0.1, 8.0, 0.001, "suffix:m") var fence_height: float = 1.0:
set(value):
fence_height = value
_rebuild()
@export var symmetric_dimensions: bool = true:
set(value):
if symmetric_dimensions == value:
return
if not value:
_capture_mesh_dimensions()
symmetric_dimensions = value
notify_property_list_changed()
if value:
scale = Vector3.ONE
else:
_apply_dimension_scale()
@export_range(0.01, 100.0, 0.001, "suffix:m") var dimension_width: float = 2.0:
set(value):
dimension_width = value
_apply_dimension_scale()
@export_range(0.01, 100.0, 0.001, "suffix:m") var dimension_height: float = 2.0:
set(value):
dimension_height = value
_apply_dimension_scale()
@export_range(0.01, 100.0, 0.001, "suffix:m") var dimension_depth: float = 2.0:
set(value):
dimension_depth = value
_apply_dimension_scale()
@export_storage var _dimension_reference: Vector3 = Vector3.ZERO
@export var center_align: bool = false:
set(value):
center_align = value
_rebuild()
@export var surface_material: Material:
set(value):
surface_material = value
_rebuild()
@export var custom_points: PackedVector3Array = PackedVector3Array():
set(value):
custom_points = value
_rebuild()
@export var centered_origin: bool = false:
set(value):
centered_origin = value
_rebuild()
func _rebuild() -> void:
pass
func _capture_mesh_dimensions() -> void:
var geometry := get_node_or_null("Geometry") as MeshInstance3D
if geometry == null or geometry.mesh == null:
return
var dimensions := geometry.mesh.get_aabb().size
if dimensions.x > 0.001:
dimension_width = dimensions.x
if dimensions.y > 0.001:
dimension_height = dimensions.y
if dimensions.z > 0.001:
dimension_depth = dimensions.z
_dimension_reference = dimensions
func _apply_dimension_scale() -> void:
if symmetric_dimensions or _dimension_reference.x <= 0.001 or _dimension_reference.y <= 0.001 or _dimension_reference.z <= 0.001:
return
var target := Vector3(dimension_width, dimension_height, dimension_depth)
if piece_type == PieceType.FOUNDATION_SQUARE or piece_type == PieceType.FOUNDATION_TRIANGLE or piece_type == PieceType.FLOOR:
target.y = _dimension_reference.y
if piece_type == PieceType.WALL:
target.z = _dimension_reference.z
scale = Vector3(target.x / _dimension_reference.x, target.y / _dimension_reference.y, target.z / _dimension_reference.z)
func _make_mesh() -> ArrayMesh:
if piece_type == PieceType.GABLE_WALL and custom_points.size() >= 2:
return _make_gable_wall_mesh()
if piece_type == PieceType.GABLE_WALL_RIGHT and custom_points.size() >= 2:
return _make_right_gable_wall_mesh()
if (piece_type == PieceType.ROOF_GABLE or piece_type == PieceType.ROOF_SHED or piece_type == PieceType.ROOF_PYRAMID) and custom_points.size() >= 2:
return _make_roof_mesh()
if piece_type == PieceType.STAIRS and custom_points.size() >= 2:
return _make_stair_mesh()
if piece_type == PieceType.FENCE and custom_points.size() >= 2:
return _make_fence_mesh()
if custom_points.size() >= 2 and piece_type == PieceType.WALL:
return _make_custom_wall_mesh()
if custom_points.size() >= 3 and piece_type == PieceType.FLOOR:
return _make_custom_floor_mesh()
if custom_points.size() >= 3 and (piece_type == PieceType.FOUNDATION_SQUARE or piece_type == PieceType.FOUNDATION_TRIANGLE):
return _make_custom_foundation_mesh()
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var s := unit_size
var polygon: Array[Vector2]
match piece_type:
PieceType.FOUNDATION_TRIANGLE:
polygon = [Vector2(-s * 0.5, -s * 0.5), Vector2(s * 0.5, -s * 0.5), Vector2(-s * 0.5, s * 0.5)]
_:
polygon = [Vector2(-s * 0.5, -s * 0.5), Vector2(s * 0.5, -s * 0.5), Vector2(s * 0.5, s * 0.5), Vector2(-s * 0.5, s * 0.5)]
var y0 := -thickness * 0.5 if centered_origin else 0.0
var y1 := thickness * 0.5 if centered_origin else thickness
if piece_type == PieceType.WALL:
polygon = [Vector2(-s * 0.5, -thickness * 0.5), Vector2(s * 0.5, -thickness * 0.5), Vector2(s * 0.5, thickness * 0.5), Vector2(-s * 0.5, thickness * 0.5)]
y0 = -s * 0.5 if centered_origin else 0.0
y1 = s * 0.5 if centered_origin else s
if piece_type == PieceType.FOUNDATION_SQUARE or piece_type == PieceType.FOUNDATION_TRIANGLE:
var hatch_mesh := _make_hatch_surface_mesh(polygon, y1, y0)
if hatch_mesh != null:
return hatch_mesh
for i in range(1, polygon.size() - 1):
_add_tri(vertices, normals, Vector3(polygon[0].x, y1, polygon[0].y), Vector3(polygon[i + 1].x, y1, polygon[i + 1].y), Vector3(polygon[i].x, y1, polygon[i].y))
_add_tri(vertices, normals, Vector3(polygon[0].x, y0, polygon[0].y), Vector3(polygon[i].x, y0, polygon[i].y), Vector3(polygon[i + 1].x, y0, polygon[i + 1].y))
for i in range(polygon.size()):
var j := (i + 1) % polygon.size()
var a := polygon[i]
var b := polygon[j]
_add_tri(vertices, normals, Vector3(a.x, y0, a.y), Vector3(b.x, y0, b.y), Vector3(b.x, y1, b.y))
_add_tri(vertices, normals, Vector3(a.x, y0, a.y), Vector3(b.x, y1, b.y), Vector3(a.x, y1, a.y))
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_NORMAL] = normals
var result := ArrayMesh.new()
result.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return result
func _make_gable_wall_mesh() -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var base_points := _get_centered_gable_base() if center_align else PackedVector3Array([custom_points[0], custom_points[1]])
var a: Vector3 = base_points[0]
var b: Vector3 = base_points[1]
var direction := (b - a).normalized()
var side := Vector3(-direction.z, 0.0, direction.x).normalized() * thickness * 0.5
var peak := (a + b) * 0.5 + Vector3.UP * rise
var front_a := a + side
var front_b := b + side
var front_peak := peak + side
var back_a := a - side
var back_b := b - side
var back_peak := peak - side
_add_tri(vertices, normals, front_a, front_b, front_peak)
_add_tri(vertices, normals, back_b, back_a, back_peak)
_add_quad(vertices, normals, front_a, back_a, back_b, front_b)
_add_quad(vertices, normals, front_b, back_b, back_peak, front_peak)
_add_quad(vertices, normals, front_peak, back_peak, back_a, front_a)
return _array_mesh(vertices, normals)
func _make_right_gable_wall_mesh() -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var base_points := _get_centered_gable_base() if center_align else PackedVector3Array([custom_points[0], custom_points[1]])
var a: Vector3 = base_points[0]
var b: Vector3 = base_points[1]
var direction := (b - a).normalized()
var side := Vector3(-direction.z, 0.0, direction.x).normalized() * thickness * 0.5
var peak := b + Vector3.UP * rise
var front_a := a + side
var front_b := b + side
var front_peak := peak + side
var back_a := a - side
var back_b := b - side
var back_peak := peak - side
_add_tri(vertices, normals, front_a, front_b, front_peak)
_add_tri(vertices, normals, back_peak, back_b, back_a)
_add_quad(vertices, normals, front_a, back_a, back_b, front_b)
_add_quad(vertices, normals, front_b, back_b, back_peak, front_peak)
_add_quad(vertices, normals, front_peak, back_peak, back_a, front_a)
return _array_mesh(vertices, normals)
func _make_roof_mesh() -> ArrayMesh:
if piece_type == PieceType.ROOF_SHED and custom_points.size() == 2:
return _make_shed_roof_mesh()
if custom_points.size() >= 4:
return _make_oriented_roof_mesh()
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var first: Vector3 = custom_points[0]
var second: Vector3 = custom_points[1]
var min_x := minf(first.x, second.x)
var max_x := maxf(first.x, second.x)
var min_z := minf(first.z, second.z)
var max_z := maxf(first.z, second.z)
var span_x := max_x - min_x
var span_z := max_z - min_z
if span_x < 0.02 or span_z < 0.02:
return _array_mesh(vertices, normals)
var panels: Array = []
if piece_type == PieceType.ROOF_GABLE:
if span_x <= span_z:
var ridge_x := (min_x + max_x) * 0.5
panels.append([Vector3(min_x, 0.0, min_z), Vector3(min_x, 0.0, max_z), Vector3(ridge_x, rise, max_z), Vector3(ridge_x, rise, min_z)])
panels.append([Vector3(ridge_x, rise, min_z), Vector3(ridge_x, rise, max_z), Vector3(max_x, 0.0, max_z), Vector3(max_x, 0.0, min_z)])
else:
var ridge_z := (min_z + max_z) * 0.5
panels.append([Vector3(min_x, 0.0, min_z), Vector3(max_x, 0.0, min_z), Vector3(max_x, rise, ridge_z), Vector3(min_x, rise, ridge_z)])
panels.append([Vector3(min_x, rise, ridge_z), Vector3(max_x, rise, ridge_z), Vector3(max_x, 0.0, max_z), Vector3(min_x, 0.0, max_z)])
else:
if span_x <= span_z:
var low_x := first.x
var high_x := second.x
panels.append([Vector3(low_x, 0.0, min_z), Vector3(low_x, 0.0, max_z), Vector3(high_x, rise, max_z), Vector3(high_x, rise, min_z)])
else:
var low_z := first.z
var high_z := second.z
panels.append([Vector3(min_x, 0.0, low_z), Vector3(max_x, 0.0, low_z), Vector3(max_x, rise, high_z), Vector3(min_x, rise, high_z)])
for panel in panels:
_append_solid_panel(vertices, normals, panel)
return _array_mesh(vertices, normals)
func _make_shed_roof_mesh() -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var low: Vector3 = custom_points[0]
var high: Vector3 = custom_points[1]
var slope := high - low
var horizontal := Vector3(slope.x, 0.0, slope.z)
if horizontal.length_squared() < 0.0001 or roof_width <= 0.0:
return _array_mesh(vertices, normals)
var across := Vector3(horizontal.z, 0.0, -horizontal.x).normalized() * roof_width * 0.5
var panel := [low - across, low + across, high + across, high - across]
_append_solid_panel(vertices, normals, panel)
return _array_mesh(vertices, normals)
func _make_oriented_roof_mesh() -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var p0: Vector3 = custom_points[0]
var p1: Vector3 = custom_points[1]
var p2: Vector3 = custom_points[2]
var p3: Vector3 = custom_points[3]
var edge_01 := Vector2(p1.x - p0.x, p1.z - p0.z).length()
var edge_12 := Vector2(p2.x - p1.x, p2.z - p1.z).length()
var panels: Array = []
if piece_type == PieceType.ROOF_GABLE:
if edge_01 <= edge_12:
var ridge_01 := (p0 + p1) * 0.5 + Vector3.UP * rise
var ridge_23 := (p2 + p3) * 0.5 + Vector3.UP * rise
panels.append([p0, p3, ridge_23, ridge_01])
panels.append([ridge_01, ridge_23, p2, p1])
else:
var ridge_12 := (p1 + p2) * 0.5 + Vector3.UP * rise
var ridge_30 := (p3 + p0) * 0.5 + Vector3.UP * rise
panels.append([p0, p1, ridge_12, ridge_30])
panels.append([ridge_30, ridge_12, p2, p3])
elif piece_type == PieceType.ROOF_PYRAMID:
var peak := (p0 + p1 + p2 + p3) * 0.25 + Vector3.UP * rise
panels.append([p0, p1, peak])
panels.append([p1, p2, peak])
panels.append([p2, p3, peak])
panels.append([p3, p0, peak])
else:
# The four points define the low and high edges. Keep their centers
# fixed when adjusting both edge widths in the Inspector.
var base_width := Vector2(p1.x - p0.x, p1.z - p0.z).length()
if base_width > 0.001 and roof_width > 0.0:
var direction := Vector3(p1.x - p0.x, 0.0, p1.z - p0.z).normalized()
var low_center := (p0 + p1) * 0.5
var high_center := (p2 + p3) * 0.5
var half_width := roof_width * 0.5
panels.append([
low_center - direction * half_width,
low_center + direction * half_width,
high_center + direction * half_width,
high_center - direction * half_width,
])
else:
panels.append([p0, p1, p2, p3])
for panel in panels:
_append_solid_panel(vertices, normals, panel)
return _array_mesh(vertices, normals)
func _make_stair_mesh() -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var start: Vector3 = custom_points[0]
var finish: Vector3 = custom_points[1]
var horizontal := Vector3(finish.x - start.x, 0.0, finish.z - start.z)
var run := horizontal.length()
var rise_amount := finish.y - start.y
if run < 0.05 or rise_amount <= 0.05 or stair_count < 1 or stair_width <= 0.0:
return _array_mesh(vertices, normals)
var forward := horizontal / run
var width_axis := Vector3(-forward.z, 0.0, forward.x) * stair_width * 0.5
var profile := PackedVector2Array([Vector2(0.0, 0.0), Vector2(run, 0.0), Vector2(run, rise_amount)])
for step_index in range(stair_count - 1, -1, -1):
var tread_start := run * float(step_index) / stair_count
var tread_end := run * float(step_index + 1) / stair_count
var tread_height := rise_amount * float(step_index + 1) / stair_count
profile.append(Vector2(tread_start, tread_height))
if step_index > 0:
profile.append(Vector2(tread_start, rise_amount * float(step_index) / stair_count))
var triangulation := Geometry2D.triangulate_polygon(profile)
for side_sign in [-1.0, 1.0]:
for triangle_index in range(0, triangulation.size(), 3):
var a := profile[triangulation[triangle_index]]
var b := profile[triangulation[triangle_index + 1]]
var c := profile[triangulation[triangle_index + 2]]
var point_a: Vector3 = start + forward * a.x + Vector3.UP * a.y + width_axis * side_sign
var point_b: Vector3 = start + forward * b.x + Vector3.UP * b.y + width_axis * side_sign
var point_c: Vector3 = start + forward * c.x + Vector3.UP * c.y + width_axis * side_sign
if side_sign > 0.0:
_add_tri(vertices, normals, point_a, point_b, point_c)
else:
_add_tri(vertices, normals, point_c, point_b, point_a)
for profile_index in range(profile.size()):
var next_index := (profile_index + 1) % profile.size()
var a := profile[profile_index]
var b := profile[next_index]
var point_a_left := start + forward * a.x + Vector3.UP * a.y - width_axis
var point_b_left := start + forward * b.x + Vector3.UP * b.y - width_axis
var point_b_right := start + forward * b.x + Vector3.UP * b.y + width_axis
var point_a_right := start + forward * a.x + Vector3.UP * a.y + width_axis
_add_quad(vertices, normals, point_a_left, point_b_left, point_b_right, point_a_right)
return _array_mesh(vertices, normals)
func _make_fence_mesh() -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var start: Vector3 = custom_points[0]
var finish: Vector3 = custom_points[1]
var path := finish - start
var path_length := path.length()
if path_length < 0.05 or fence_height <= 0.0:
return _array_mesh(vertices, normals)
var path_axis := path / path_length
var post_count := fence_divisions + 2
var post_positions: Array[Vector3] = []
for post_index in range(post_count):
var base_position := start.lerp(finish, float(post_index) / float(post_count - 1))
post_positions.append(base_position)
var side_axis := path_axis.cross(Vector3.UP).normalized()
if side_axis.length_squared() < 0.001:
side_axis = Vector3.FORWARD
_append_oriented_box(vertices, normals, base_position + Vector3.UP * fence_height * 0.5, path_axis, Vector3.UP, side_axis, Vector3(thickness, fence_height, thickness))
for section_index in range(post_positions.size() - 1):
var section_start := post_positions[section_index]
var section_end := post_positions[section_index + 1]
var section_axis := (section_end - section_start).normalized()
var rail_up := Vector3.UP - section_axis * section_axis.dot(Vector3.UP)
if rail_up.length_squared() < 0.001:
rail_up = Vector3.FORWARD - section_axis * section_axis.dot(Vector3.FORWARD)
rail_up = rail_up.normalized()
var rail_side := section_axis.cross(rail_up).normalized()
var section_length := section_start.distance_to(section_end)
for height_ratio in [0.28, 0.78]:
var rail_center: Vector3 = (section_start + section_end) * 0.5 + Vector3.UP * fence_height * height_ratio
_append_oriented_box(vertices, normals, rail_center, section_axis, rail_up, rail_side, Vector3(section_length, thickness, thickness))
return _array_mesh(vertices, normals)
func _append_oriented_box(vertices: PackedVector3Array, normals: PackedVector3Array, center: Vector3, axis_x: Vector3, axis_y: Vector3, axis_z: Vector3, dimensions: Vector3) -> void:
var x := axis_x.normalized() * dimensions.x * 0.5
var y := axis_y.normalized() * dimensions.y * 0.5
var z := axis_z.normalized() * dimensions.z * 0.5
var nnn := center - x - y - z
var pnn := center + x - y - z
var ppn := center + x + y - z
var npn := center - x + y - z
var nnp := center - x - y + z
var pnp := center + x - y + z
var ppp := center + x + y + z
var npp := center - x + y + z
_add_quad(vertices, normals, nnp, pnp, ppp, npp)
_add_quad(vertices, normals, pnn, nnn, npn, ppn)
_add_quad(vertices, normals, npp, ppp, ppn, npn)
_add_quad(vertices, normals, nnn, pnn, pnp, nnp)
_add_quad(vertices, normals, nnn, nnp, npp, npn)
_add_quad(vertices, normals, pnp, pnn, ppn, ppp)
func _get_centered_gable_base() -> PackedVector3Array:
var selected_a := to_global(custom_points[0])
var selected_b := to_global(custom_points[1])
var search_root: Node = get_parent()
while search_root != null and not search_root is ModularBuilderPieceGeometry:
search_root = search_root.get_parent()
if search_root == null:
return PackedVector3Array([custom_points[0], custom_points[1]])
var walls: Array[ModularBuilderPieceGeometry] = []
_collect_centering_walls(search_root, walls)
var centered_a := selected_a
var centered_b := selected_b
var nearest_a := INF
var nearest_b := INF
for wall in walls:
if wall.custom_points.size() < 2:
continue
var wall_a := wall.to_global(wall.custom_points[0] + Vector3.UP * wall.unit_size)
var wall_b := wall.to_global(wall.custom_points[1] + Vector3.UP * wall.unit_size)
var tolerance: float = wall.thickness + 0.04
for endpoint in [wall_a, wall_b]:
var distance_a := selected_a.distance_to(endpoint)
if distance_a < nearest_a and distance_a <= tolerance:
nearest_a = distance_a
centered_a = endpoint
var distance_b := selected_b.distance_to(endpoint)
if distance_b < nearest_b and distance_b <= tolerance:
nearest_b = distance_b
centered_b = endpoint
if nearest_a < INF and nearest_b < INF and centered_a.distance_to(centered_b) >= 0.02:
return PackedVector3Array([to_local(centered_a), to_local(centered_b)])
return PackedVector3Array([custom_points[0], custom_points[1]])
func _collect_centering_walls(node: Node, walls: Array[ModularBuilderPieceGeometry]) -> void:
if node is ModularBuilderPieceGeometry and node.piece_type == PieceType.WALL:
walls.append(node)
for child in node.get_children():
_collect_centering_walls(child, walls)
func _append_solid_panel(vertices: PackedVector3Array, normals: PackedVector3Array, panel: Array) -> void:
if panel.size() < 3:
return
var p0: Vector3 = panel[0]
var p1: Vector3 = panel[1]
var p2: Vector3 = panel[2]
var normal: Vector3 = (p1 - p0).cross(p2 - p0).normalized()
if normal.is_zero_approx():
return
var half_depth: Vector3 = normal * thickness * 0.5
var front: Array[Vector3] = []
var back: Array[Vector3] = []
for point in panel:
front.append(point + half_depth)
back.append(point - half_depth)
for i in range(1, panel.size() - 1):
_add_tri(vertices, normals, front[0], front[i], front[i + 1])
_add_tri(vertices, normals, back[i + 1], back[i], back[0])
for i in range(panel.size()):
var next := (i + 1) % panel.size()
_add_quad(vertices, normals, front[i], back[i], back[next], front[next])
func _make_custom_wall_mesh() -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var wall_points: PackedVector3Array = _get_centered_wall_points() if center_align else custom_points
var a: Vector3 = wall_points[0]
var b: Vector3 = wall_points[1]
var direction := (b - a).normalized()
var length := a.distance_to(b)
var side := Vector3(-direction.z, 0.0, direction.x).normalized() * thickness * 0.5
var height := Vector3.UP * unit_size
var openings: Array[PackedVector2Array] = []
for child in get_children():
if _is_detail_node(child) and int(child.get("detail_type")) != DETAIL_TYPE_HATCH and int(child.get("detail_type")) != DETAIL_TYPE_PILLAR:
var detail := child as Node3D
var center := Vector2((detail.position - a).dot(direction), detail.position.y - a.y)
var axis_x := Vector2(detail.transform.basis.x.dot(direction), detail.transform.basis.x.y).normalized()
var axis_y := Vector2(detail.transform.basis.y.dot(direction), detail.transform.basis.y.y).normalized()
var opening := PackedVector2Array([
center - axis_x * float(detail.get("width")) * 0.5 - axis_y * float(detail.get("height")) * 0.5,
center + axis_x * float(detail.get("width")) * 0.5 - axis_y * float(detail.get("height")) * 0.5,
center + axis_x * float(detail.get("width")) * 0.5 + axis_y * float(detail.get("height")) * 0.5,
center - axis_x * float(detail.get("width")) * 0.5 + axis_y * float(detail.get("height")) * 0.5
])
var wall_rect := PackedVector2Array([Vector2(0.0, 0.0), Vector2(length, 0.0), Vector2(length, unit_size), Vector2(0.0, unit_size)])
for clipped_opening in Geometry2D.intersect_polygons(opening, wall_rect):
if clipped_opening.size() >= 3:
openings.append(clipped_opening)
var xs: Array[float] = [0.0, length]
var ys: Array[float] = [0.0, unit_size]
for opening in openings:
for point in opening:
xs.append(clampf(point.x, 0.0, length))
ys.append(clampf(point.y, 0.0, unit_size))
xs.sort()
ys.sort()
for xi in range(xs.size() - 1):
for yi in range(ys.size() - 1):
var u0: float = xs[xi]
var u1: float = xs[xi + 1]
var v0: float = ys[yi]
var v1: float = ys[yi + 1]
var mid := Vector2((u0 + u1) * 0.5, (v0 + v1) * 0.5)
var cell := PackedVector2Array([Vector2(u0, v0), Vector2(u1, v0), Vector2(u1, v1), Vector2(u0, v1)])
var remaining: Array[PackedVector2Array] = [cell]
for opening in openings:
var next_remaining: Array[PackedVector2Array] = []
for polygon in remaining:
for clipped in Geometry2D.clip_polygons(polygon, opening):
if clipped.size() >= 3:
next_remaining.append(clipped)
remaining = next_remaining
for polygon in remaining:
var indices := Geometry2D.triangulate_polygon(polygon)
for ti in range(0, indices.size(), 3):
var p0: Vector2 = polygon[indices[ti]]
var p1: Vector2 = polygon[indices[ti + 1]]
var p2: Vector2 = polygon[indices[ti + 2]]
var f0 := a + direction * p0.x + Vector3.UP * p0.y
var f1 := a + direction * p1.x + Vector3.UP * p1.y
var f2 := a + direction * p2.x + Vector3.UP * p2.y
_add_tri(vertices, normals, f0 + side, f1 + side, f2 + side)
_add_tri(vertices, normals, f2 - side, f1 - side, f0 - side)
# Close the wall body at its ends, top, and base.
_add_quad(vertices, normals, a - side, a + side, a + side + height, a - side + height)
_add_quad(vertices, normals, b + side, b - side, b - side + height, b + side + height)
_add_quad(vertices, normals, a + side + height, b + side + height, b - side + height, a - side + height)
_add_quad(vertices, normals, a - side, b - side, b + side, a + side)
for opening in openings:
for edge_index in range(opening.size()):
var p0 := opening[edge_index]
var p1 := opening[(edge_index + 1) % opening.size()]
var a0 := a + direction * p0.x + Vector3.UP * p0.y
var a1 := a + direction * p1.x + Vector3.UP * p1.y
_add_quad(vertices, normals, a0 + side, a0 - side, a1 - side, a1 + side)
return _array_mesh(vertices, normals)
func _get_centered_wall_points() -> PackedVector3Array:
if custom_points.size() < 2:
return custom_points
var selected_a := to_global(custom_points[0])
var selected_b := to_global(custom_points[1])
var search_root: Node = get_parent()
while search_root != null and not search_root is ModularBuilderPieceGeometry:
search_root = search_root.get_parent()
if search_root == null:
return custom_points
var walls: Array[ModularBuilderPieceGeometry] = []
_collect_centering_walls(search_root, walls)
var centered_a := selected_a
var centered_b := selected_b
var nearest_a := INF
var nearest_b := INF
for wall in walls:
if wall == self or wall.custom_points.size() < 2:
continue
var direction := (wall.custom_points[1] - wall.custom_points[0]).normalized()
var side := Vector3(-direction.z, 0.0, direction.x).normalized() * wall.thickness * 0.5
for endpoint in wall.custom_points:
var center := wall.to_global(endpoint + Vector3.UP * wall.unit_size)
for surface_corner in [center + side, center - side]:
var distance_a := selected_a.distance_to(surface_corner)
if distance_a < nearest_a and distance_a <= wall.thickness + 0.04:
nearest_a = distance_a
centered_a = center
var distance_b := selected_b.distance_to(surface_corner)
if distance_b < nearest_b and distance_b <= wall.thickness + 0.04:
nearest_b = distance_b
centered_b = center
if nearest_a < INF and nearest_b < INF and centered_a.distance_to(centered_b) >= 0.02:
return PackedVector3Array([to_local(centered_a), to_local(centered_b)])
return custom_points
func _make_custom_floor_mesh() -> ArrayMesh:
var polygon: Array[Vector2] = []
for point in custom_points:
polygon.append(Vector2(point.x, point.z))
var hatch_mesh := _make_hatch_surface_mesh(polygon, custom_points[0].y, custom_points[0].y - thickness)
if hatch_mesh != null:
return hatch_mesh
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var points := custom_points
var bottom: Array[Vector3] = []
for point in points:
bottom.append(point - Vector3.UP * thickness)
for i in range(1, points.size() - 1):
_add_tri(vertices, normals, points[0], points[i + 1], points[i])
_add_tri(vertices, normals, bottom[0], bottom[i], bottom[i + 1])
for i in range(points.size()):
var j := (i + 1) % points.size()
_add_quad(vertices, normals, bottom[i], bottom[j], points[j], points[i])
return _array_mesh(vertices, normals)
func _make_hatch_surface_mesh(polygon: Array[Vector2], top_y: float, bottom_y: float) -> ArrayMesh:
var openings: Array[PackedVector2Array] = []
for child in get_children():
if _is_detail_node(child) and int(child.get("detail_type")) == DETAIL_TYPE_HATCH:
var detail := child as Node3D
var center := Vector2(detail.position.x, detail.position.z)
var axis_x := Vector2(detail.transform.basis.x.x, detail.transform.basis.x.z).normalized()
var axis_y := Vector2(detail.transform.basis.z.x, detail.transform.basis.z.z).normalized()
var opening := PackedVector2Array([
center - axis_x * float(detail.get("width")) * 0.5 - axis_y * float(detail.get("height")) * 0.5,
center + axis_x * float(detail.get("width")) * 0.5 - axis_y * float(detail.get("height")) * 0.5,
center + axis_x * float(detail.get("width")) * 0.5 + axis_y * float(detail.get("height")) * 0.5,
center - axis_x * float(detail.get("width")) * 0.5 + axis_y * float(detail.get("height")) * 0.5
])
openings.append(opening)
if openings.is_empty() or polygon.size() < 3:
return null
var min_x := polygon[0].x
var max_x := polygon[0].x
var min_z := polygon[0].y
var max_z := polygon[0].y
var xs: Array[float] = []
var zs: Array[float] = []
for point in polygon:
min_x = minf(min_x, point.x)
max_x = maxf(max_x, point.x)
min_z = minf(min_z, point.y)
max_z = maxf(max_z, point.y)
xs.append(point.x)
zs.append(point.y)
for opening in openings:
for point in opening:
xs.append(clampf(point.x, min_x, max_x))
zs.append(clampf(point.y, min_z, max_z))
xs.sort()
zs.sort()
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var source_polygon := PackedVector2Array()
for point in polygon:
source_polygon.append(point)
for xi in range(xs.size() - 1):
for zi in range(zs.size() - 1):
var x0: float = xs[xi]
var x1: float = xs[xi + 1]
var z0: float = zs[zi]
var z1: float = zs[zi + 1]
if x1 - x0 < 0.0001 or z1 - z0 < 0.0001:
continue
var cell := PackedVector2Array([Vector2(x0, z0), Vector2(x1, z0), Vector2(x1, z1), Vector2(x0, z1)])
var remaining: Array[PackedVector2Array] = []
for clipped_surface in Geometry2D.intersect_polygons(source_polygon, cell):
remaining.append(clipped_surface)
for opening in openings:
var next_remaining: Array[PackedVector2Array] = []
for surface_polygon in remaining:
for clipped_surface in Geometry2D.clip_polygons(surface_polygon, opening):
if clipped_surface.size() >= 3:
next_remaining.append(clipped_surface)
remaining = next_remaining
for clipped_surface in remaining:
var triangulated := Geometry2D.triangulate_polygon(clipped_surface)
for ti in range(0, triangulated.size(), 3):
var a: Vector2 = clipped_surface[triangulated[ti]]
var b: Vector2 = clipped_surface[triangulated[ti + 1]]
var c: Vector2 = clipped_surface[triangulated[ti + 2]]
_add_tri(vertices, normals, Vector3(a.x, top_y, a.y), Vector3(c.x, top_y, c.y), Vector3(b.x, top_y, b.y))
_add_tri(vertices, normals, Vector3(a.x, bottom_y, a.y), Vector3(b.x, bottom_y, b.y), Vector3(c.x, bottom_y, c.y))
for i in range(polygon.size()):
var j := (i + 1) % polygon.size()
var a := polygon[i]
var b := polygon[j]
_add_quad(vertices, normals, Vector3(a.x, bottom_y, a.y), Vector3(b.x, bottom_y, b.y), Vector3(b.x, top_y, b.y), Vector3(a.x, top_y, a.y))
for opening in openings:
var visible_edges := Geometry2D.intersect_polygons(opening, source_polygon)
for visible_opening in visible_edges:
for edge_index in range(visible_opening.size()):
var p0 := visible_opening[edge_index]
var p1 := visible_opening[(edge_index + 1) % visible_opening.size()]
_add_quad(vertices, normals, Vector3(p0.x, bottom_y, p0.y), Vector3(p0.x, top_y, p0.y), Vector3(p1.x, top_y, p1.y), Vector3(p1.x, bottom_y, p1.y))
return _array_mesh(vertices, normals)
func _is_detail_node(node: Node) -> bool:
if not node is Node3D or node.get_script() == null:
return false
return node.get_script().resource_path == DETAIL_SCRIPT_PATH
func _make_custom_foundation_mesh() -> ArrayMesh:
var polygon: Array[Vector2] = []
for point in custom_points:
polygon.append(Vector2(point.x, point.z))
var bottom_y := -thickness * 0.5 if centered_origin else 0.0
var top_y := thickness * 0.5 if centered_origin else thickness
var hatch_mesh := _make_hatch_surface_mesh(polygon, top_y, bottom_y)
if hatch_mesh != null:
return hatch_mesh
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
for i in range(1, custom_points.size() - 1):
var a := custom_points[0]
var b := custom_points[i]
var c := custom_points[i + 1]
_add_tri(vertices, normals, Vector3(a.x, top_y, a.z), Vector3(c.x, top_y, c.z), Vector3(b.x, top_y, b.z))
_add_tri(vertices, normals, Vector3(a.x, bottom_y, a.z), Vector3(b.x, bottom_y, b.z), Vector3(c.x, bottom_y, c.z))
for i in range(custom_points.size()):
var j := (i + 1) % custom_points.size()
var a := custom_points[i]
var b := custom_points[j]
_add_quad(vertices, normals, Vector3(a.x, bottom_y, a.z), Vector3(b.x, bottom_y, b.z), Vector3(b.x, top_y, b.z), Vector3(a.x, top_y, a.z))
return _array_mesh(vertices, normals)
func _add_quad(v: PackedVector3Array, n: PackedVector3Array, a: Vector3, b: Vector3, c: Vector3, d: Vector3) -> void:
_add_tri(v, n, a, b, c)
_add_tri(v, n, a, c, d)
func _array_mesh(v: PackedVector3Array, n: PackedVector3Array) -> ArrayMesh:
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = v
arrays[Mesh.ARRAY_NORMAL] = n
var result := ArrayMesh.new()
result.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return result
func _add_tri(v: PackedVector3Array, n: PackedVector3Array, a: Vector3, b: Vector3, c: Vector3) -> void:
var normal := (b - a).cross(c - a).normalized()
v.append(a)
v.append(b)
v.append(c)
n.append(normal)
n.append(normal)
n.append(normal)
# Also generate reversed faces to show both sides.
# This keeps floors and foundations visible from below with any material.
v.append(c)
v.append(b)
v.append(a)
n.append(-normal)
n.append(-normal)
n.append(-normal)

View file

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

View file

@ -8,7 +8,7 @@ extends RefCounted
## Skript braucht die Editor-Umgebung (Dialoge, OS.execute, Rendering).
## Deutsch für Sly: alle Status-/Fehlermeldungen auf Deutsch.
const KERN := preload("res://addons/modular_builder/scopedvr_export.gd")
const KERN := preload("res://addons/modular_builder_en/scopedvr_export.gd")
const BUDGET := 20000 # Export-Strukturen (match-server BUILDER_BUDGET)
const THUMB_SIZE := 128
@ -133,8 +133,8 @@ func _export(oid: String, name_roh: String) -> void:
if not editor_dir.is_empty():
var img := await _thumbnail_bilden(mesh)
if img != null:
DirAccess.make_dir_recursive_absolute(editor_dir + "/core/thumbs")
img.save_png(editor_dir + "/core/thumbs/" + oid + ".png")
DirAccess.make_dir_recursive_absolute(editor_dir + "/library/thumbs")
img.save_png(editor_dir + "/library/thumbs/" + oid + ".png")
# 6) Editor-Sync (Registry-Kopie; vorhandene Thumbnails bleiben stehen).
var schritt4 := _lauf(objekt_tool, ["editor", oid])

1
core

@ -1 +0,0 @@
Subproject commit 5e98535a0afc83af4e4017806686a76af4913afd

View file

@ -1,76 +0,0 @@
# 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: `core/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 (klein 2 m) | Giebelwand, Satteldach, Pyramidendach |
| Dach & Giebel (mittel 4 m) | Giebelwand 4 m, Satteldach 4×4, Pyramidendach 4×4 (Spitze 2 m) |
| Dach & Giebel (groß 6 m) | Giebelwand 6 m, Satteldach 6×6, Pyramidendach 6×6 (Spitze 3 m) |
| Dach aus Einzelteilen (45°) | Dachschräge 2×2 m (2 m hoch), Dachecke außen (Grat), Dachecke innen (Kehle), Dachplateau, Dachstirn — massiv, rundum geschlossen; tiefe Kante/Ecke zeigt Richtung Vorschau, R dreht |
| Treppe | Treppe 4 × 2 m, 3 m hoch (15 Stufen) |
Fertige Pack-Objekte (Container, Hangar-Tore, Betonsperren) stehen NICHT im Bauen-Tab,
sondern wie alle Objekte in der Objekt-Bibliothek unten.
## Snap (`core/editor/build_snap.gd`)
* **XZ:** Wände auf die nächste Zellkante — und zwar GANZ in die Zelle, in der die
Maus ist (Außenseite bündig mit der Kante, kein Überhang, Ecken schließen ohne
Lücke). Klick auf eine vorhandene Wand (Seite oder Oberkante) übernimmt deren Lage
exakt (ersetzen bzw. stapeln). Böden/Dächer/Fundamente in die Zellmitte, große
Dächer (4 m / 6 m) je nach Zellzahl auf Zellecke bzw. Zellmitte, Pfeiler auf die
Zellecke, Treppen auf eine 4 × 2-m-Fläche. `R` dreht die Wand um. 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. **Wände und
Pfeiler zählen von der Unterkante einer Decke/eines Fundaments**, die Decke selbst
liegt auf der Wandoberkante: jedes Geschoss liegt auf Vielfachen von 3 m, mit oder
ohne Decke darunter stehen die oberen Wände gleich hoch.
* **Geschoss fest:** Bild↑/Bild↓ (oder Knöpfe im Tab) wählen ein festes Geschoss
(Basis = tiefstes Teil in der Nähe, je 3 m). Der Mausstrahl trifft dann die
Geschoss-Ebene statt Wand/Boden darunter — Decken und Böden lassen sich mitten im
großen Raum setzen. Ganz unten = Automatik.
* **Kein Z-Fighting:** Wand-Außenseite steht 1 cm vor der Plattenkante.
* **Ersetzen:** gleiche Art auf demselben Platz wird ersetzt (Tür in vorhandene
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

@ -1,51 +0,0 @@
# Server-Sektion (Tab „Server“)
Der Editor ist ein Client der Match-Server-Admin-API (`docs/admin-api.md` im
Repo scopedvr-match-server). Damit verwaltet jeder seinen eigenen Server – ohne
Konsole, ohne Tailscale, auf jedem Betriebssystem.
## Einrichten
1. Auf dem Server in der `server.cfg` `[admin] enabled=true` setzen und starten.
Das Token steht danach in der `state_file` des Servers (Standard `admin.cfg`).
2. Editor → Tab **Server** → **➕ Neu**: Name, Adresse, Port (Standard 24580), Token.
„🔌 Verbindung testen“ prüft Erreichbarkeit und Token.
3. **🔌 Verbinden**.
**Sicher erreichen:** Die Admin-API spricht Klartext-HTTP und hört standardmäßig nur
auf localhost. Entweder SSH-Tunnel (`ssh -L 24580:127.0.0.1:24580 benutzer@server`,
im Editor dann Adresse `127.0.0.1`) oder ein Reverse-Proxy mit https (Haken
„Sichere Verbindung“).
## Funktionen
- Mehrere Server speichern; **Token verschlüsselt** (Schlüssel aus Rechner-ID +
Nutzerordner), nie im Klartext in Datei oder Log. Schutz gegen Zufallsleser, nicht
gegen Schadsoftware auf deinem Benutzerkonto.
- **Übersicht:** Name, Map, Modus, Phase, Spieler/Bots, Laufzeit, öffentlich/privat.
- **⬆ Aktuelle Map hochladen:** prüft und signiert die Map im Editor (wie Speichern),
lädt sie hoch, der Server prüft noch einmal. Ungültige Maps werden gar nicht erst
gesendet; 422-Fehler erscheinen als verständliche Liste mit Hilfetexten.
Existiert der Name schon, fragt der Editor, ob ersetzt werden soll. Optional:
direkt in die Rotation aufnehmen.
- **Einstellungen:** Formular direkt aus der Server-Spec; gesendet werden nur
geänderte Werte. Die Antwort zeigt je Wert: sofort / ab nächster Map / nach
Neustart. „↩ Zurücksetzen“ verwirft Änderungen.
- **Spieler:** Kick, Sperren (10 min … dauerhaft), Sperrliste mit „Aufheben“.
- **Match & Maps:** Match neu starten, Map jetzt laden, Rotation per Häkchen,
hochgeladene Maps löschen. Alles Zerstörerische fragt vorher nach.
- **⏻ Server neu starten:** fragt nach, trennt alle Spieler, zeigt „Server startet neu…“
und verbindet sich wieder, sobald der neue Prozess antwortet (bis 120 s). Der Server
beendet sich dafür mit Exit-Code 75; neu starten muss ihn Docker (Restart-Policy),
systemd oder `tools/start_server.*` (`docs/admin-api.md` im Server-Repo).
- **Bot-Anzahl** geht bis 32, die Grenze kommt vom Server. Reicht die Teamgröße nicht
(2 × Teamgröße = Platz für Bots), zeigt das Speichern eine Warnung mit der nötigen Größe.
- **Server-Log** mit Filter.
## Code
`core/server/` (AdminClient, ServerStore, AdminTexte, MapPush),
`core/ui/panels/server_panel.gd`, `SvmapIO.to_signed_text`.
## Tests
`tests/test_server_sektion.gd` (Unit), `tests/run_server_sektion_e2e.sh`
(echter Match-Server + echtes Panel: Verbinden, falsches Token, unerreichbar,
Einstellungen, Push gültig/doppelt/ersetzen/lokal ungültig/422, Rotation,
Map laden/löschen, Restart, Kick, Logs, 20 Bots, Server-Neustart).

View file

@ -1,211 +0,0 @@
# Tastatur & Maus — Map-Editor
Deutsche Tastenbelegung des ScopedVR Map-Editors. **Diese Datei ist die Source
of truth** für alle Shortcut-Karten — FINALE Version nach allen drei Karten:
Karte 1/3 (t_c46d31c2): Navigation, Auswahl, Löschen · Karte 2/3 (t_55257792):
Objektmanipulation + Terrain · Karte 3/3 (t_11b5c25b): Datei & Undo,
Werkzeugwechsel, Hilfe, Test. Das Kurzhilfe-Overlay im Editor (Taste **H**)
zeigt dieselbe Tabelle (Quelle: `core/ui/help_text.gd`).
Konventionen (alle Karten):
- Tastaturcode-basiert: InputMap-Actions mit physischen `KEY_*`-Konstanten
(`core/editor/shortcut_keys.gd`). Die physische Taste liegt unabhängig vom
Tastaturlayout gleich — deutsches (QWERTZ) und Windows-Layout verhalten sich
identisch, keine OS-Hardcodes (PLAN.md Plattform-Regel).
- Jede Objekt-Operation = **EIN** ActionHistory-Eintrag (Strg+Z nimmt die
ganze Operation zurück). Ausnahme: reine Kamera-Aktionen (Orbit/Flug/Zoom)
betreffen die Map nicht und sind nicht in der Historie.
- **Strg halten = temporärer Snap-Zwang** (Karte 2/3): egal was der
Raster-Snap-Schalter sagt — solange Strg unten ist, rastet Bewegen
(Ziehen/Greifen) und Platzieren auf ganze Meter.
## Navigation (Kamera)
| Eingabe | Wirkung |
| --- | --- |
| Mittlere Maustaste ziehen | Orbit: Um die Karte drehen |
| Mittlere Maustaste + Umschalt ziehen | Pan: Karte verschieben |
| Mausrad | Zoom (aus, wenn Mausrad gerade ein Objekt dreht) |
| **Rechte Maustaste GEDACHT halten** | **Freelook-Flug:** Umsehen + W vor / S zurück / A links / D rechts; **Leertaste hoch / Umschalt runter** (Shooter-Konvention wie Half-Life/Counter-Strike — Sly-Wunsch 17.10.). **Funktioniert in JEDEM Werkzeug** (Terrain, Bauen, Spawn-Zonen, Platzieren) — die rechte Maustaste hat ABSOLUTEN Vorrang, kein Werkzeug belegt sie (Karte t_2470de89) |
| Strg halten (im Flug) | schneller fliegen (×3) — temporär, Godot-üblich |
| Alt halten (im Flug) | langsamer fliegen (×0,3) |
| Mausrad (im Flug) | Grundgeschwindigkeit dauerhaft ändern (2–300 m/s) |
| **Umschalt+F** | Freelook-Flug dauerhaft an/aus (Flug bleibt ohne gedrückte rechte Maustaste aktiv) |
| **F** | Auswahl ins Bild zentrieren (Focus Selection) |
Der Orbit-Modus ist der Default: Beim Verlassen des Flugs springt die Kamera
nicht — Ziel/Winkel/Abstand werden aus der Flugposition zurückgerechnet.
## Auswahl
| Eingabe | Wirkung |
| --- | --- |
| Linksklick | Objekt auswählen (ersetzt die Auswahl) |
| **Strg+Klick** (Viewport **und** Objektbaum) | Objekt zur Auswahl hinzufügen/entfernen (Mehrfachauswahl) |
| Umschalt+Klick | wie Strg+Klick (Mehrfachauswahl umschalten) |
| Ziehen über leeren Boden | Rahmen-Aufziehen: alle Objekte im Rahmen wählen |
| Umschalt + Rahmen | Rahmen-Auswahl zur bestehenden Auswahl hinzufügen |
| Esc | Werkzeug abbrechen → **Greifen zurückrollen** → Ghost-Preview abbrechen → Auswahl aufheben (Prioritätskette) |
Alle Bestandsaktionen wirken auf die GANZE Mehrfachauswahl: Entf löscht alle,
Strg+D dupliziert alle, Ziehen verschiebt gemeinsam, Anheben/Absenken hebt
alle, Strg+G gruppieren / Strg+Umschalt+G lösen.
## Objekt-Manipulation (Karte 2/3)
| Eingabe | Wirkung |
| --- | --- |
| **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 |
| **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) |
| **Pfeiltasten** | Auswahl im **2-m-Raster** verschieben — EINE Zelle pro Tastendruck, Richtung = Kamera-Ausrichtung nächste Weltachse (Godot-Editor-Konvention: ↑ = von der Kamera weg). Fußpunkt bleibt am Raster, Höhe wandert mit |
| **Bild↑ / Bild↓** | Auswahl +0,25 m / −0,25 m anheben/absenken (Mehrfachdruck = EIN Undo-Schritt). Absenken UNTER die Terrain-Höhe ist erlaubt (Versenken — z. B. Steine im Boden); Untergrenze ist die Match-Grenze |
| **Strg+Bild↑** | Auswahl direkt auf das Terrain/Boden absetzen (Godot „Snap Object to Floor“-Äquivalent fürs Fußpunkt-Modell) |
| **G** | Auswahl **greifen** (Blender-Semantik): sie hängt an der Maus, Linksklick setzt die neue Position (mit Raster-Snap bzw. Strg-Zwang), **Esc** bricht ab und setzt die exakten alten Positionen zurück. Greifen der ganzen Gruppe/mehreren Objekte wandert gemeinsam |
| **Entf (DELETE)** | Auswahl SOFORT löschen, ohne Rückfrage — Strg+Z holt alles zurück. Wirkt auf Szene-Auswahl UND Objektbaum-Markierung |
| **Strg+D** | Auswahl duplizieren: Kopie +2 m in X versetzt (Rasterversatz), Füßpunkt unverändert; bei Mehrfachauswahl ALLE Objekte mit EINEM Undo-Schritt |
| Strg+G / Strg+Umschalt+G | Auswahl gruppieren / Gruppe lösen |
## Datei & Undo (Karte 3/3)
Vor „Neue Map“ und „Öffnen“ fragt der Editor NACH, wenn die Map noch
ungespeicherte Änderungen hat (deutscher Dialog „Verwerfen / Abbrechen“) —
nichts geht versehentlich verloren.
| Eingabe | Wirkung |
| --- | --- |
| **Strg+Z** | Rückgängig (EIN Eintrag pro Operation) |
| **Strg+Y** UND **Strg+Umschalt+Z** | Wiederherstellen (Redo) — beide Wege sind belegt |
| **Strg+S** | Map speichern (.svmap, bekannter Pfad; noch nie gespeichert → „Speichern unter“) |
| **Strg+Umschalt+S** | Map speichern unter … |
| **Strg+O** | Map öffnen |
| **Strg+N** | Neue Map |
## Updates (Datei-Menü)
Der Editor aktualisiert sich selbst aus seinem Git-Klon (Karte t_d95f087a) —
kein Programmdownload, nur „Versionsstände holen“:
| Weg | Wirkung |
| --- | --- |
| **Beim Start** | Still prüfen. Gibt es eine neuere Version, erscheint eine Bernstei-Leiste unter der Menüleiste: **„🔄 Update verfügbar: `<alt>` → `<neu>`“** |
| **Banner → „🔄 Jetzt updaten“** | Installiert das Update: neue Version laden (git pull), Import-Lauf, dann startet der Editor von selbst neu. Ungespeicherte Änderungen werden vorher abgefragt (derselbe Dialog wie bei „Neue Map“/„Öffnen“ — **Abbrechen** bricht das Update ab) |
| **Datei → „🔄 Updates prüfen“** | Gleicher Check auf Wunsch, mit Ergebnis-Meldung in der Statuszeile: „Update verfügbar“ / „Du bist auf dem neusten Stand (`<sha>`)“ / „Nicht erreichbar“ |
- **Offline / Tunnel weg:** kein Banner, keine Meldung — der Editor bleibt
einfach so, wie er ist. Prüfen kann man trotzdem: die Meldung sagt es dann
in der Statuszeile.
- **Zugangsdaten fehlen** (Karte t_1457e935): die Update-Prüfung läuft
bewusst NIE interaktiv — es öffnet sich **niemals** ein Login-Fenster im
Editor. Stattdessen kommt in der Statuszeile „Updates nicht prüfbar —
Zugangsdaten fehlen“. Die Zugangsdaten liegen im Git-Credential-Store
(`~/.git-credentials`, Host `127.0.0.1:13020`); wenn sie fehlen, füllt sie
ein Eintrag der Form `http://<user>:<token>@127.0.0.1:13020` nach.
- **Fehlgeschlagener Import:** bricht das Update mit Klartext-Meldung ab —
der Editor läuft UNVERÄNDERT weiter (kein Neustart in einen kaputten Stand,
Lektion 17.10. class_name-Cache).
- **Neustart:** Der Editor startet sich selbst neu (dasselbe Godot-Programm,
derselbe Projektordner, wieder im Editor-Modus). Wer den Editor als
systemd-User-Unit betreibt, setzt in `scopedvr_editor.cfg` (Godot-App-Daten)
Abschnitt `[update]` einen `restart_command` — z. B.
`restart_command="systemd-run --user --on-active=3s systemctl --user restart map-editor"`.
Ohne Eintrag wird unter Linux automatisch erkannt, ob die Unit
`map-editor.service` existiert; sonst Selbst-Restart.
- **Konfiguration** (alle optional, `user://scopedvr_editor.cfg`, Abschnitt
`[update]`): `restart_command` (eigener Neustart-Befehl), `git_command`
(eigener git-Pfad). Der Import-Lauf nutzt dieselbe Godot-Binary wie der
Editor selbst (bzw. `[test] godot_path`).
## Kartengröße ändern (Datei-Menü)
„Kartengröße ändern…“ (Datei-Menü) öffnet einen Dialog mit Live-Vorschau:
- **Aktuelle Größe** steht oben, darunter die **neue Breite (X) und Tiefe (Z)
in Metern** — Freifelder plus Preset-Buttons 256 / 512 / 1024 / 2048 m (beide
Achsen gleichzeitig). Erlaubt sind **256–4096 m** (Werte außerhalb werden
beim Anwenden an die Grenzen geklemmt).
- **Anker Mitte:** Die Karte bleibt am Ursprung zentriert. Das bestehende
Terrain behält seine WELTPOSITION (bilinear neu abgetastet — kein Stretch),
die neue Randzone wird flach auf Grundhöhe + Gras. Beim Vergrößern steigt
die Terrain-Auflösung automatisch mit (bis zum Format-Deckel 513 Punkte).
- **Verkleinern — Vorschau:** Der Dialog listet VORHER, was draußen liegen
würde („Beim Verkleinern liegen draußen: 5 Objekte, 2 Bauteile, 1
Spawn-Punkt“). Per Schalter entscheidest du, ob diese beim Anwenden
**entfernt** werden (Standard: ja — Strg+Z holt sie zurück) oder über den
Rand hinaus in der Map bleiben — dann ist die Map **nicht speicherbar**
(Regel: nichts außerhalb der Match-Grenzen), bis du die Einträge verschiebst
oder löschst. Warnung, falls ein Team dadurch unter
**4 Spawn-Punkte** fällt (Maps brauchen mindestens 4 pro Team zum Speichern).
- **Vergrößern über ~1024 m — Warnband:** Ab dieser Größe wird das Terrain
grober aufgelöst (mehr Meter pro Höhenpunkt) — der Dialog weist auf die
geringere Detail-Dichte und das Quest-Perf-Budget (Objektzahl/Draw-Calls) hin.
- **Wann erlaubt?** Jederzeit — es muss nur eine Map existieren.
- **Undo:** Der Resize ist **EINE** Undo-Einheit: Terrain, Objekte, Bauteile
und Spawn-Zonen zusammen. **Strg+Z stellt den kompletten Vorher-Zustand
her** (inkl. gelöschter Objekte), Strg+Y stellt den Resize wieder her.
Nach dem Resize sind ungespeicherte Änderungen vorhanden (Speichern mit
Strg+S signiert die Prüfsumme neu).
## Terrain-Werkzeug (Karte 2/3) — nur aktiv, wenn das Terrain-Werkzeug an ist
| Eingabe | Wirkung |
| --- | --- |
| **[** | Pinselgröße verkleinern — durch die 3 Presets Klein (4 m) / Mittel (12 m) / Groß (32 m) zyklisch; Preset-Buttons, Slider und Label wandern mit |
| **]** | Pinselgröße vergrößern (gleicher Zyklus, andere Richtung) |
| **1** | Pinselmodus Heben |
| **2** | Pinselmodus Senken |
| **3** | Pinselmodus Glätten |
| **4** | Pinselmodus Malen (mit der zuletzt gewählten Materialfarbe) |
| **Linksklick-Ziehen** | Boden formen bzw. malen (je nach Pinselmodus) |
| **Strg + Linksklick-Ziehen** | **Gegenteil:** bei Heben senkt er, bei Senken hebt er (Invert-Konvention wie Blender/Photoshop); im Malen-Modus malt Strg+Klick Gras zurück |
| Rechte Maustaste | **Kein Terrain-Strich mehr** — gehört dem Freelook-Flug (absoluter Vorrang, siehe Navigation) |
| Strg halten | temporärer Snap-Zwang beim Objekt-Bewegen/Platzieren (gilt editorweit, nicht nur hier) |
Konfliktfreiheit: Die Ziffern 1–4 sind NUR im Terrain-Werkzeug belegt — im
Objekt-/Bauen-Modus sind sie unbenutzt, im Flugmodus (rechte Maustaste
gehalten) bleiben Leertaste/Shift/Strg die Kamera. **Leertaste, Umschalt und
Strg triggern im Flug KEINE Werkzeug-Aktion:** Terrain senkt nur bei
Strg+gedrücktem Linksklick (nicht bei Strg allein), Entf/Strg+Z & Co. bleiben
unangetastet, und der Flug-Einstieg gibt den GUI-Fokus frei, damit Leertaste
keinen fokussierten Button auslöst.
## Werkzeuge (Karte 3/3: F-Tasten wechseln das Werkzeug)
| Eingabe | Wirkung |
| --- | --- |
| **F1** | Werkzeug: Terrain formen |
| **F2** | Werkzeug: Objekte platzieren/auswählen (Standard) |
| **F3** | Tab „Bauen“ öffnen (Bauteile wählen, dann in die Karte klicken) |
| **F4** | Werkzeug: Spawn-Zonen malen |
| Esc (im Terrain-/Spawn-Werkzeug) | Werkzeug beenden — zurück zum Objekt-Werkzeug |
| Esc (beim Greifen) | Greifen abbrechen, alte Positionen zurück |
| Esc (beim Platzieren) | Ghost-Preview abbrechen |
| Esc (Kurzhilfe offen) | Kurzhilfe schließen |
| **L** | **Höhenlinien fixieren** (Karte t_89c0ec95): Linien immer sichtbar/unsichtbar — unabhängig vom Werkzeug. Ohne Fix erscheinen sie NUR im Terrain-Werkzeug (F1) |
F1–F4 und F5/H/L wirken NUR, wenn kein Textfeld/SpinBox fokussiert ist und kein
Dialog offen ist (ShortcutPolicy) — Tippen geht immer vor.
Die Terrain-Ziffern 1–4 und [ / ] bleiben (Karte 2/3) NUR im Terrain-Werkzeug
belegt — der Werkzeugwechsel läuft jetzt über die F-Tasten, nicht über Ziffern.
Höhenlinien-Kopplung (Karte t_89c0ec95): Die Konturen (1 m, alle 5 m kräftiger)
sind Werkzeug-gekoppelt — **F1 (Terrain) blendet sie ein, jeder andere
Werkzeugwechsel blendet sie aus** (hart, ohne Fade). Wer sie dauerhaft will,
fixiert sie mit **L** oder dem Button „≈ Höhenlinien“ oben. Das Relief-Shading
(Hillshade + Höhen-Kurve) bleibt davon unberührt und immer an. Auf flachen Maps
(< 2 m Gesamtrelief) sind die Linien automatisch aus. L ist sonst unbesetzt
(Kollisions-Prüfung t_89c0ec95).
## Hilfe & Test (Karte 3/3)
| Eingabe | Wirkung |
| --- | --- |
| **H** | Kurzhilfe-Overlay an/aus — alle Tasten dieser Datei als Panel über der Karte; Esc schließt ebenfalls. Nicht-blockierend: der Editor bleibt bedienbar |
| **T** | Werkzeug: Test-Startpunkt setzen (Karte t_96e3c4ee): Klick in die Karte = dort startet F5. Höhe kommt automatisch vom Terrain; Klick auf Objekt/Bauteil = Boden darunter; Entf im Werkzeug löscht den Punkt. **KEIN Spiel-Spawnpunkt** — er landet in einer Editor-Sidecar-Datei, nicht in der `.svmap` (Spawn-Checks/Analysen unberührt). Im Testlauf versetzt **T** dich sofort zum Blickpunkt und setzt dort den Test-Start |
| **F5** | ▶ Karte testen (aktuelle Map sofort als Spiel durchlaufen; der Editor bleibt offen). Start-Reihenfolge: gesetzter Test-Start → erster Team-Spawnpunkt → Kartenmitte (je mit Klartext-Hinweis) |
Die Statuszeile unten zeigt PERMANENT das aktive Werkzeug („Werkzeug: …“) plus
den Hinweis „Umschalt+F = Flugmodus“ — man sieht immer, wo man ist.

Some files were not shown because too many files have changed in this diff Show more