Compare commits

..

No commits in common. "main" and "m2-object-library" have entirely different histories.

701 changed files with 3922 additions and 56740 deletions

8
.gitignore vendored
View file

@ -2,13 +2,5 @@
.godot/ .godot/
*.tmp *.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 # System
.DS_Store .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.

266
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/ Neue-Map-Dialog mit 4 Pflicht-Spawns pro Team, Orbit-Viewport mit Gitter/
Boden/Wetter-Stimmung — siehe Commit 84bc081 und `svmap_io.gd`.)* 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 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). 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. Strg+Z / Strg+Y UND große Buttons „↩ Rückgängig / ↪ Wiederholen" oben.
Spätere Werkzeuge melden sich mit `snapshot(keys)` vor und Spätere Werkzeuge melden sich mit `snapshot(keys)` vor und
`commit(desc, before, after)` nach der Änderung an — Terrain-Strokes und `commit(desc, before, after)` nach der Änderung an — Terrain-Strokes und
Spawn-Zonen landen damit automatisch in derselben History. 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 Bibliothek links (deutsche Namen + Poly-Zahl), Klick startet den
Platzier-Modus — eine transparente Vorschau schwebt am Cursor auf dem Boden Platzier-Modus — eine transparente Vorschau schwebt am Cursor auf dem Boden
(Höhe kommt aus dem Terrain), Klick setzt, ESC bricht ab (Rechte (Höhe kommt aus dem Terrain), Klick setzt, Rechtsklick/ESC bricht ab,
Maustaste = Flug, das Objekt bleibt in der Hand — Karte t_2470de89),
Mausrad dreht die Vorschau VOR dem Setzen (15°-Raster). Modus bleibt an Mausrad dreht die Vorschau VOR dem Setzen (15°-Raster). Modus bleibt an
für schnelles Mehrfachsetzen. Poly-Budget wird VOR dem Platzieren geprüft. für schnelles Mehrfachsetzen. Poly-Budget wird VOR dem Platzieren geprüft.
- **Auswahl-Anfassen:** Klick wählt ein Objekt (orange + Emission), Ziehen - **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: Auf dem Terrain-Werkzeug (t_9aba04f1) aufgebaut, nach dem Chef-Grundsatz:
- **Bibliothek-Browser mit Gruppen (`core/editor/library_browser.gd`):** - **Bibliothek-Browser mit Gruppen (`src/editor/library_browser.gd`):** Die
*(Stand 25.10.2026, Military-Pack-Umstellung)* Die Bibliothek ist das 11 etablierten Objekte aus `library/objects.json`, sortiert in deutsche
komplette Military-Desert-Pack (418 Modelle + 5 Kombis = 423 Objekte aus Klapp-Gruppen (Kisten & Barrieren / Bauwerke / Natur + automatisch
`library/objects.json`), sortiert in 19 deutsche Kategorien aus dem „Sonstiges" für neue, noch ungruppierte IDs). Jeder Button zeigt ein
Manifest-Feld `kategorie` (Panzer, Radfahrzeuge, Flugzeuge & Hubschrauber, 128-px-Thumbnail (`library/thumbs/*.png` — einmalig mit
Geschütze & Stellungen, Fahrzeugteile, Gebäude & Hangars, Zelte, Türme & `xvfb-run -a godot4 --rendering-driver opengl3 --path . --script
Masten, Mauern & Wände, Sperren & Zäune, Kisten/Fässer/Container, Technik & res://tools/gen_thumbs.gd` gerendert und committed; fehlt ein PNG, fällt
Funk, Ausrüstung, Training, Bäume, Pflanzen/Kakteen/Gras, Felsen & Steine, der Button sauber aufs Text-Layout zurück). Deutsche Namen wohnen jetzt in
Boden/Straßen/Dünen, Boden-Decals). Fahrzeuge sind **fertig zusammengebaut** `LibraryBrowser` (ohne UI → headless testbar).
(Panzer mit Turm und Kanone, Hummer mit Rädern, Hubschrauber mit Rotoren); - **Snap to Ground (`src/editor/object_ops.gd`):** Ein Klick auf
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
„⚡ Alles auf den Boden setzen" holt ALLE schwebenden Objekte auf die „⚡ 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 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. ganze Aktion. Objekte, die schon auf dem Boden stehen, bleiben unangetastet.
@ -131,229 +120,6 @@ Auf dem Terrain-Werkzeug (t_9aba04f1) aufgebaut, nach dem Chef-Grundsatz:
fährt Bibliothek/Platzierung/Schweben/Snap programmatisch durch und fährt Bibliothek/Platzierung/Schweben/Snap programmatisch durch und
speichert Screenshots. speichert Screenshots.
**Noch offen (folgende Karten der MVP-Kette):** Performance-Check-UI, **Noch offen (folgende Karten der MVP-Kette):** Spawn-Zonen-Editor
Export-Workflow. (t_c4162c5d — Spawn-Zonen malen, Live-Validierung, Export-Signierung),
Performance-Check-UI, Export-Workflow.
## Status (M2, Karte t_c4162c5d — Spawn-Zonen FERTIG)
Auf der Objekt-Bibliothek (t_3a998ae4) aufgebaut, nach dem Chef-Grundsatz:
- **Spawn-Zonen malen (`core/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“
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
nötig)“ + Klartext-Hinweis, WIE die Zone ergänzt wird. Fehler zeigt der
Editor VOR dem Export, nicht erst beim Speichern.
- **Map-Fertig-Check:** Button „✅ Karte prüfen“ oben — Klartext-Liste
✅/❌ (Name, Autor, beide Teams, Objekt-IDs, Poly-Budget) in einem
Fenster; fehlt etwas, wackelt (Shake-Tween) der Button und die
Statusleiste sagt „Map ist noch nicht bereit — siehe Warnungen“.
Der harte Gate bleibt das Speichern (`SvmapIO.save_to_file` —
dieselbe `_validate_core`-Funktion, EINE Implementierung, kein Drift).
- **Export-Signierung:** unverändert der eine Code-Pfad — Editor-Speichern
ruft `SvmapIO.sign_map` (SHA-256 über kanonisierte Payload). Abnahme mit
ECHTEM Server-Loader: Editor-Export durch `SvmapLoader.parse_and_validate`
aus scopedvr-match-server gejagt → ok=true inkl. V13-Prüfsummen-Check
(kein Copy-Paste-Drift, Interop bewiesen).
- **Radius-Persistenz:** Der Zonen-Radius ist KEIN Formatfeld (spawn_zones
speichert Punkte) — er wohnt als `design_notes.spawn_radius_m` in der
Map (Loader prüft design_notes nicht → rundtrip-sicher). Zonen sitzen
AUF dem Terrain (`terrain_height_at`), egal wie hügelig die Map ist.
- **Tests:** `tests/test_spawns.gd` (52 Checks: setzen/ändern/löschen +
je Undo/Redo mit Radius, Merge-Tags, Live-Status ✅/❌, ready_report
grün/rot, Export→Re-Import identisch + byte-identischer Roundtrip,
Zone auf Terrain-Höhe). Parse-Check: 23 Scripts, 0 Fehler.
- **Desktop-Smoke:** `godot … -- --spawns-demo [ordner] [--spawns-demo-quit]`
fährt Zonen-Übersicht → Live-Check-Alarm → Undo → „Karte prüfen“
programmatisch durch und speichert Screenshots.
## Status (M2, Karte t_7567a06f — Objekt-Ops: Duplizieren + Hoch/Runter + Spiegeln)
Auf dem UX-Fundament (t_40d855d0) und der Objekt-Bibliothek (t_3a998ae4)
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
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
ändern die Höhe in 0,25-m-Schritten. MEHRERE Klicks in Serie = EIN
Undo-Schritt. Design-Entscheidungen, dokumentiert:
- Die Höhe wohnt direkt in `object.pos.y` — das svmap-Format erlaubt
freie y-Werte innerhalb der Match-Grenzen (Loader-Regel V9), deshalb
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.
- **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
Verschieben in derselben Höhe über dem (neuen) Boden weiter; bodennah
bleibt bodennah.
- Die Auswahl-Anzeige zeigt die Höhe mit („+0,25 m über dem Boden“).
- **Spiegeln (`⇄ Links↔Rechts` / `⇅ Vor↔Zurück`):** Das Objekt wird AN
SEINER STelle gespiegelt — Position bleibt, die Blickrichtung dreht:
- links↔rechts (Welt-X-Spiegelung): `rot_y_deg → −rot`
- vor↔zurück (Welt-Z-Spiegelung): `rot_y_deg → 180° − rot`
(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.
- **Alles über ActionHistory:** Snapshot vor/nach JEDE Aktion
(Duplizieren = EIN Schritt, Anheben-Serie = EIN Schritt via Merge-Tag,
Spiegeln = EIN Schritt). Statusleiste erklärt nach jeder Aktion den
nächsten Schritt und die Grenzen auf Deutsch.
- **Tests:** `tests/test_ux.gd` auf 90 Checks erweitert (42 neu):
Duplizieren (Raster/ohne Raster/Undo-Roundtrip/Index-Schutz), Anheben/
Absenken (Schrittweite, Boden-Stopp, Deckel bounds.max, versteckt-Regel,
Merge-Tags), Spiegeln (X/Z-Mapping, Selbstinvers, Position unverändert),
svmap-Roundtrip mit angehobener Höhe. `godot … --quit-after` smoke:
UI baut alle neuen Buttons fehlerfrei.
## Status (M2, Karte t_f6f29d86 — Auswahl-System FERTIG)
Mehrfachauswahl + Gruppieren + Gruppen-Verschieben, auf dem UX-Fundament
(t_40d855d0) aufgebaut, nach dem Chef-Grundsatz:
- **Mehrfachauswahl (`core/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).
- **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
Gruppen-Mitglied nimmt automatisch die ganze Gruppe (und markiert sie),
die relative Anordnung bleibt exakt erhalten. Drehen geschieht um den
Gruppen-Mittelpunkt. „Gruppe lösen“ befreit ALLE Mitglieder (auch die
nicht mitgewählten), Strg+Z baut sie wieder zusammen.
- **ENTSCHIEDUNG Gruppen-Persistenz:** Gruppen-Zugehörigkeit liegt als
OPTIONALES Objekt-Feld `"group"` (int ≥ 0, abwesend = ungruppiert)
DIREKT im svmap-Format — nicht als Editor-Sitzungsstatus. Damit
überleben Gruppen Speichern/Laden UND Undo/Redo (ActionHistory kopiert
den objects-Block tief, das Feld reist mit). Loader-Kompatibilität
nachgewiesen (scopedvr-match-server `src/maps/svmap_loader.gd`):
V4 prüft nur Pflichtfelder, unbekannte Objekt-Keys werden nicht
abgelehnt, die SHA-256-Prüfsumme läuft über das ganze kanonisierte
Dict (Feld reist automatisch mit). Alte Maps ohne Feld bleiben 1:1
ladbar, `format_version` bleibt 1 — KEIN Version-Bump, KEINE
Server-Loader-Änderung nötig. Format-Doku-Anpassung (map_format.md im
Server-Repo) als Folge-Karte.
- **Gruppen-Verschieben + Undo:** Ziehen einer Mehrfachauswahl oder Gruppe
bewegt ALLE beteiligten Objekte (Terrain-Clamp PRO Objekt: X/Z bleiben
in der Map, Y = Terrain-Höhe an der neuen Stelle) — EINE Aktion =
EIN ActionHistory-Schritt (auch gemeinsames Drehen, Mausrad-Ticks
derselben Auswahl sind merge-gekoppelt). Löschen löscht die ganze
Auswahl in EINEM Schritt.
- **Tests:** `tests/test_ux.gd` erweitert auf 52 Checks (Gruppen-
gruppieren/lösen/IDs, gemeinsames Verschieben mit Terrain-Clamp,
Drehen um die Mitte mit Anordnungs-Erhalt, EIN-Schritt-Undo/Redo,
svmap-Roundtrip inkl. Prüfsumme + alte-Map-Kompatibilität,
Rubberband-Projektion inkl. Kamera-Rückseiten-Filter). Komplette
`run_tests.sh`-Suite grün (206 Checks).
- **Desktop-Smoke:** `godot … -- --groups-demo [ordner] [--groups-demo-quit]`
fährt Mehrfachauswahl → Gruppieren → gemeinsames Ziehen/Drehen →
Undo programmatisch durch und speichert Screenshots.
## Karte testen (M2, Karte t_b84af40f — FERTIG)
Button „▶ Karte testen“ oben (auch im Datei-Menü) startet die AKTUELLE Map —
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
`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
noch nicht lauffähig (z. B. Spawn-Zonen fehlen), kommt der Klartext-Block
mit den fehlenden Punkten, GESTARTET wird nichts.
- **Client-Suche (erste Treffer-Zeile gewinnt):**
1. Konfigurationsdatei `user://scopedvr_editor.cfg`:
```ini
[test]
client_dir="/pfad/zu/scopedvr-client"
godot_path="/pfad/zu/godot" ; nur nötig, wenn der Editor NICHT über Godot läuft
```
2. Klon neben dem Editor: `<Editor-Ordner>/../scopedvr-client`
(erkennbar an `project.godot`).
- **Start:** Der Client läuft als eigener Godot-Prozess
(`godot --path <client> -- --testmap <map.svmap> --mock-master`) — kein
Master, kein Match-Server, alles lokal. Godot-Binary = das, unter dem der
Editor selbst läuft.
- **Tests:** `tests/test_testrun.gd` (14 Checks: Temp-Export mit Roundtrip,
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,19 +0,0 @@
# Changelog
## 0.2.0
- Add gable walls, gable/shed/pyramid roofs, stairs, fences, and parapets.
- Add plain pillars between vertices or vertically from one vertex with `Alt+click`.
- Add manual vertex creation, movement, removal, merging, and simplified marker display.
- Add wall/gable/pillar centering controls and editable asymmetric dimensions.
- Export a finished structure as one textured mesh in a new scene or as a copy beside the editable structure.
- Include the demo scene and its example textures.
- Split the editor and geometry code into focused scripts.
## 0.1.0 — Initial release
- Create square and triangular foundations, walls, and square or triangular floors directly in the Godot editor.
- Snap pieces together using visible vertices and directional foundation placement controls.
- Split walls at intermediate foundation vertices for separate texturing.
- Add windows, doors, and hatches as openings or separate editable objects.
- Generate general concave collision and save reusable building modules.

View file

@ -1,125 +0,0 @@
CC0 1.0 Universal
Copyright (c) 2026 Michel Savadim
This CC0 dedication applies only to the original Modular Builder source code,
icons, and documentation in this directory authored by Michel Savadim. It does
not apply to Godot Engine, user-created project content, trademarks, patents,
or third-party materials distributed separately.
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.
https://creativecommons.org/publicdomain/zero/1.0/legalcode

View file

@ -1,37 +0,0 @@
# Modular Builder 0.2.0
Build modular structures directly in the Godot 4.7 editor. The panel appears at the bottom of the 3D editor. Dimensions are shown in meters; one Godot 3D unit equals one meter.
## 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/`.
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.
## Build
Click **Add ModularBuilderPiece** to create a structure container and its first foundation. Green tools choose square or triangular foundations; a green `+` on a free edge attaches another foundation. Red tools place walls and triangular gables between vertices. Yellow tools place square floors using opposite diagonal vertices or triangular floors using three vertices. Wall segments crossing several foundations are split at intermediate foundation vertices so each segment can be textured separately.
Blue markers show connection vertices; selected points turn yellow. Enable **Only show points on selected pieces** to reduce clutter, or **Simplify vertices** to show points at the middle of a piece's thickness. The separate vertex tools merge markers and create, move, or remove manual connection points. Manual vertices belong to their selected piece or detail and do not deform its mesh.
The blue roof and stairs tools create gable, shed, and pyramid roofs and stairs. Gable roofs use two gable peaks or diagonal top corners of opposite walls. Shed roofs use a low point and a diagonally opposite high point; pyramid roofs use two diagonal points at the same height. The Inspector lets you adjust roof width and rise, stair width, and step count. Walls can be placed on other walls or floors. **Center walls** and **Center** for gables align them with supporting walls.
Pink detail tools place windows, doors, hatches, pillars, fences, and parapets. Windows, doors, and hatches can be openings alone or openings with separately editable and textured objects. Pillars connect two vertices; `Alt+click` one vertex to create a vertical pillar from it. The **Center** option aligns a pillar with the supporting piece. Fences have two rails; parapets have one thicker rail. Their height and intermediate post count are editable.
Building pieces start with **Symmetric dimensions** enabled. Turn it off to edit width, height, and depth independently. **Thickness** continues to control the relevant dimension for walls, floors, and foundations. Select a piece in the Scene tree to edit its material and dimensions, or use the rotation button to rotate it by 90 degrees. The active creation tool remains selected until you click it again or choose another tool. `Esc` clears the current points; `Ctrl+Z` undoes placements and vertex edits.
## Collision, modules, and export
Pieces have no collision by default. Select the `ModularBuilderPiece` container and use **Add general collision** to generate or update one concave static collision shape for the structure. Connection markers and internal contact faces are excluded.
Save a selected structure as a reusable `.tscn` module, or insert a saved module into the open scene. For a finished structure, **Export** combines visible meshes into one `MeshInstance3D`, preserving material surfaces and UVs. Choose **New scene** for an independent `.tscn`, or **Copy in this scene** to add a sibling of the editable `ModularBuilderPiece`. The original remains editable.
## 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 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.
## AI usage disclosure
Generative AI (OpenAI ChatGPT/Codex) assisted with GDScript implementation and revisions, debugging, translation from Portuguese to English, and documentation. Michel Savadim defined the requirements and reviewed and tested the project.

View file

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="m5 17 15-8 15 8-15 8L5 17Z"/><path d="M5 17v16l15 8 15-8V17M20 25v16"/>
<path d="M38 13v16m-8-8h16"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 315 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://kwgsfdxprj4p"
path="res://.godot/imported/add_building.svg-02348d81d48c2064f5946a668739ab6e.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"]
[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

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<path d="m12 12 24 24m0-24L12 36" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 195 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://np6bb2n6ohj3"
path="res://.godot/imported/cancel.svg-48b548656752953c76fdbf1fa691b961.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/cancel.svg"
dest_files=["res://.godot/imported/cancel.svg-48b548656752953c76fdbf1fa691b961.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

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="m5 16 14-8 14 8-14 8L5 16Z"/><path d="M5 16v17l14 8 14-8V16M19 24v17"/>
<path d="M39 23v16m-8-8h16"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 315 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cjc2d332bqgfy"
path="res://.godot/imported/collision.svg-c665b3652a42642be8f703d82faa449e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/collision.svg"
dest_files=["res://.godot/imported/collision.svg-c665b3652a42642be8f703d82faa449e.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

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="19" fill="#9b7bd1" stroke="#222" stroke-width="4"/>
<path d="M32 20v24M20 32h24" stroke="#fff" stroke-width="6" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 259 B

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

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><g fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round"><path d="M12 54V10h40v44"/><path d="M19 54V17l26-4v41"/></g><circle cx="39" cy="35" r="2.8" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 265 B

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M9 24h46M9 42h46" fill="none" stroke="#eee" stroke-width="6" stroke-linecap="round"/>
<path d="M15 13v39M32 13v39M49 13v39" fill="none" stroke="#eee" stroke-width="7" stroke-linecap="round"/>
<path d="M15 13v39M32 13v39M49 13v39" fill="none" stroke="#222" stroke-width="2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 404 B

View file

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="m8 18 16-9 16 9-16 9-16-9Z"/>
<path d="M8 18v5l16 9 16-9v-5M24 27v5" opacity=".82"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 298 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bey5yf5dc53ms"
path="res://.godot/imported/floor_square.svg-c870ce008800866d2fb4f4c19615fe80.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"]
[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

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="m24 9 18 29H6L24 9Z"/>
<path d="M6 38v3l18 4 18-4v-3M24 38v7" opacity=".82"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 291 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dghymeocq75is"
path="res://.godot/imported/floor_triangle.svg-cbffdc894d9762c4cb9806dbfa52405c.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"]
[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

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="M8 16 24 8l16 8-16 8-16-8Z"/>
<path d="M8 16v17l16 8 16-8V16M24 24v17"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 286 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://3df01la6lxht"
path="res://.godot/imported/foundation_square.svg-23bf2f453c993134ea4ea05145d98488.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"]
[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

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="M24 8 42 38H6L24 8Z"/>
<path d="m24 16 10 17H14l10-17Z" opacity=".72"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 285 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ln0jh5ibvhe0"
path="res://.godot/imported/foundation_triangle.svg-4b3e87f2198d6b6a8427029009457134.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"]
[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

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M8 54V29L32 9l24 20v25H8Z" fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round"/>
<path d="M8 29h48M32 9v45" fill="none" stroke="#fff" stroke-width="3" opacity=".7"/>
</svg>

Before

Width:  |  Height:  |  Size: 262 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b16auokjgcjuv"
path="res://.godot/imported/gable_wall.svg-1d82415321f6b6ad59f51ba7640a6c53.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"]
[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

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M11 51V13l42 38z" fill="#eeeeee" stroke="#222222" stroke-width="5" stroke-linejoin="round"/>
<circle cx="11" cy="51" r="4" fill="#c62828"/><circle cx="11" cy="13" r="4" fill="#c62828"/><circle cx="53" cy="51" r="4" fill="#c62828"/>
</svg>

Before

Width:  |  Height:  |  Size: 336 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://0jckxct26pqs"
path="res://.godot/imported/gable_wall_right.svg-3336022dcfa05da5960f0dc8e3ecc6ff.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"]
[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

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><g fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round"><path d="M8 20 32 9l24 11-24 12z"/><path d="M8 20v26l24 11 24-11V20M32 32v25"/><path d="m32 32 13-6v14l-13 6z"/></g></svg>

Before

Width:  |  Height:  |  Size: 276 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round" d="M10 8h38l8 8v40H10zM19 8v17h25V8M19 56V35h26v21"/><path fill="none" stroke="#fff" stroke-width="4" d="M32 51V40m-6 5 6-6 6 6"/></svg>

Before

Width:  |  Height:  |  Size: 293 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dqhgwj72rp5o1"
path="res://.godot/imported/load_module.svg-308877482b25606c5b7ed43292d640f3.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"]
[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

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M15 15L32 32 49 15M32 32v17" fill="none" stroke="#eeeeee" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="15" cy="15" r="7" fill="#9b7bd1" stroke="#222" stroke-width="3"/><circle cx="49" cy="15" r="7" fill="#9b7bd1" stroke="#222" stroke-width="3"/><circle cx="32" cy="49" r="7" fill="#9b7bd1" stroke="#222" stroke-width="3"/>
</svg>

Before

Width:  |  Height:  |  Size: 460 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://n7jw3p1fa5eu"
path="res://.godot/imported/merge_vertices.svg-d9204aed762a783069511285341aaecc.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"]
[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

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M32 6l-8 9h5v12h6V15h5zM58 32l-9-8v5H37v6h12v5zM32 58l8-9h-5V37h-6v12h-5zM6 32l9 8v-5h12v-6H15v-5z" fill="#eee" stroke="#222" stroke-width="2" stroke-linejoin="round"/>
<circle cx="32" cy="32" r="8" fill="#9b7bd1" stroke="#222" stroke-width="3"/>
</svg>

Before

Width:  |  Height:  |  Size: 351 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://buetyw0nroq6k"
path="res://.godot/imported/move_vertex.svg-a43c7dd371bb862b05efa6f900fd0556.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/move_vertex.svg"
dest_files=["res://.godot/imported/move_vertex.svg-a43c7dd371bb862b05efa6f900fd0556.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

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="6">
<path d="M12 50V14M52 50V14M12 25H52"/>
<path d="M8 50H16M48 50H56"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 275 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bkxxyh8ec5oed"
path="res://.godot/imported/parapet.svg-f91880fefed49e8dc8e533dcad6bb584.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/parapet.svg"
dest_files=["res://.godot/imported/parapet.svg-f91880fefed49e8dc8e533dcad6bb584.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

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round">
<path d="M15 8h18v32H15z"/>
<path d="M12 8h18l3 3M12 8v32l3 0M12 40h18l3-3"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 257 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dukm4mprtq7l"
path="res://.godot/imported/pillar.svg-ccd9ad9da9982abc939a9856fa296417.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/pillar.svg"
dest_files=["res://.godot/imported/pillar.svg-ccd9ad9da9982abc939a9856fa296417.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

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="21" fill="#9b7bd1" stroke="#222" stroke-width="4"/>
<path d="M20 32h24" stroke="#fff" stroke-width="7" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 250 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bjvg4halxhoy5"
path="res://.godot/imported/remove_vertex.svg-824eacf485182fddca49a06831d65906.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"]
[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

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M5 30 32 8l27 22-5 6L32 19 10 36l-5-6Z" fill="#fff"/>
<path d="M14 35v17h36V35L32 21 14 35Z" fill="none" stroke="#fff" stroke-width="4" stroke-linejoin="round" opacity=".75"/>
</svg>

Before

Width:  |  Height:  |  Size: 257 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dvtqw0h5dd5yl"
path="res://.godot/imported/roof_gable.svg-8b4da12734ae02486f4e916ee6f2b16d.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"]
[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

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M8 45L32 13 56 45 32 53z" fill="#4d83ae" stroke="#222" stroke-width="4" stroke-linejoin="round"/>
<path d="M32 13v40M8 45l24 8 24-8" fill="none" stroke="#eee" stroke-width="3" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 306 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ccew6wsdsj0kn"
path="res://.godot/imported/roof_pyramid.svg-97d144eff93367622d2cd47f9133b57d.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"]
[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

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="m8 22 48-12v9L8 32v-10Z" fill="#fff"/>
<path d="M13 34v18h38V24L13 34Z" fill="none" stroke="#fff" stroke-width="4" stroke-linejoin="round" opacity=".75"/>
</svg>

Before

Width:  |  Height:  |  Size: 236 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://wyg331tl3rdt"
path="res://.godot/imported/roof_shed.svg-47c9ee22596d5277674eb88f312c9478.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"]
[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

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round" stroke-linecap="round">
<path d="M37 20A15 15 0 0 0 11 14L7 19"/><path d="M7 10v9h9"/>
<path d="M11 28a15 15 0 0 0 26 6l4-5"/><path d="M41 38v-9h-9"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 329 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cxcb3kjn18udy"
path="res://.godot/imported/rotate.svg-84e58dd8fc90e4d634e7fbc55f8eda70.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/rotate.svg"
dest_files=["res://.godot/imported/rotate.svg-84e58dd8fc90e4d634e7fbc55f8eda70.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

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round" d="M10 8h38l8 8v40H10zM19 8v17h25V8M19 56V35h26v21"/><path fill="none" stroke="#fff" stroke-width="4" d="M32 40v11m-6-6 6 6 6-6"/></svg>

Before

Width:  |  Height:  |  Size: 293 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://pnq0qqniy5hx"
path="res://.godot/imported/save_module.svg-eecd37f83ae3c8dc3d40dabc8e8213e4.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"]
[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

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<path d="M8 52h12V40h12V28h12V16h12" fill="none" stroke="#eee" stroke-width="7" stroke-linejoin="round" stroke-linecap="square"/>
<path d="M8 52h12V40h12V28h12V16h12" fill="none" stroke="#222" stroke-width="2" stroke-linejoin="round" stroke-linecap="square"/>
</svg>

Before

Width:  |  Height:  |  Size: 355 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b6ibyaujhybao"
path="res://.godot/imported/stairs.svg-9b9203b050bb5cf817168a5866b66b2a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/stairs.svg"
dest_files=["res://.godot/imported/stairs.svg-9b9203b050bb5cf817168a5866b66b2a.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

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round">
<path d="M8 10h32v28H8zM8 20h32M8 29h32M19 10v10m12 0v9M19 29v9"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 267 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><g fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round"><path d="M10 12h44v40H10z"/><path d="M32 12v40M10 32h44"/></g></svg>

Before

Width:  |  Height:  |  Size: 222 B

View file

@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cu21qr4i7dns1"
path="res://.godot/imported/window.svg-b79f89a5f56cfe779422a43f44ba067f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/modular_builder/icons/window.svg"
dest_files=["res://.godot/imported/window.svg-b79f89a5f56cfe779422a43f44ba067f.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

@ -1,614 +0,0 @@
@tool
extends "res://addons/modular_builder/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")
var _scopedvr_export: RefCounted = null
func _add_piece() -> void:
var root := get_editor_interface().get_edited_scene_root()
if root == null or not root is Node3D:
status_label.text = "Open a 3D scene before adding a piece."
return
var piece := Node3D.new()
piece.name = "ModularBuilderPiece"
piece.set_script(PIECE_SCRIPT)
piece.set("piece_type", ModularBuilderPiece.PieceType.BUILDING)
var module_size_units := _meters_to_godot_units(size_spin.value)
piece.set("unit_size", module_size_units)
var foundation := Node3D.new()
foundation.name = "Foundation"
foundation.set_script(PIECE_SCRIPT)
var chosen_type := _foundation_type_selected
if chosen_type == 1:
foundation.set("piece_type", ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE)
else:
foundation.set("piece_type", ModularBuilderPiece.PieceType.FOUNDATION_SQUARE)
foundation.set("unit_size", module_size_units)
foundation.set("centered_origin", true)
foundation.position.y = foundation.thickness * 0.5
piece.add_child(foundation, true)
foundation.owner = root
var undo := get_undo_redo()
undo.create_action("Add ModularBuilderPiece")
undo.add_do_method(root, "add_child", piece, true)
undo.add_do_property(piece, "owner", root)
undo.add_do_property(foundation, "owner", root)
undo.add_undo_method(root, "remove_child", piece)
undo.add_do_method(get_editor_interface().get_selection(), "clear")
undo.add_do_method(get_editor_interface().get_selection(), "add_node", piece)
undo.commit_action()
_last_created_piece = foundation
_active_button_key = TOOL_FOUNDATION_TRIANGLE if _foundation_type_selected == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE else TOOL_FOUNDATION_SQUARE
_set_tool(TOOL_FOUNDATION)
func _meters_to_godot_units(meters: float) -> float:
return meters * METERS_TO_GODOT_UNITS
func _show_save_module_dialog() -> void:
var building := _get_selected_piece()
if building == null:
status_label.text = "Select a ModularBuilderPiece structure to save as a module."
return
_save_module_dialog.current_file = "%s.tscn" % building.name
_save_module_dialog.popup_centered_ratio(0.7)
func _show_load_module_dialog() -> void:
_load_module_dialog.popup_centered_ratio(0.7)
func _on_export_pressed() -> void:
var building := _get_selected_piece()
if building == null:
status_label.text = "Select a ModularBuilderPiece to export."
return
var baked_mesh := _bake_building_mesh(building)
if baked_mesh == null or baked_mesh.get_surface_count() == 0:
status_label.text = "No visible meshes were found to export."
return
if _export_mode_option.get_selected_id() == EXPORT_LIBRARY:
_export_to_library(building, baked_mesh)
return
if _export_mode_option.get_selected_id() == EXPORT_SCENE_COPY:
_add_baked_copy(building, baked_mesh)
return
_pending_export_mesh = baked_mesh
_pending_export_name = "%s_Baked" % building.name
_pending_export_basis = building.global_transform.basis
_export_scene_dialog.current_file = "%s.tscn" % _pending_export_name
_export_scene_dialog.popup_centered_ratio(0.7)
func _bake_building_mesh(building: ModularBuilderPiece) -> ArrayMesh:
return MESH_BAKER.new().bake(building)
## Export-Modus EXPORT_LIBRARY (Karte t_82d2a8dd): Name-Dialog öffnen, danach
## läuft die Objekt-Pipeline (scopedvr_export_ui.gd); der Status landet im
## Dock-Statusfeld. Die Instanz bleibt gespeichert, damit der Dialog nicht
## vom Garbage Collector gefressen wird, während er offen ist.
func _export_to_library(building: ModularBuilderPiece, baked_mesh: ArrayMesh) -> void:
_scopedvr_export = SCOPEDVR_EXPORT_UI.new()
_scopedvr_export.starten(self, building, baked_mesh, func(text: String) -> void:
status_label.text = str(text))
func _add_baked_copy(building: ModularBuilderPiece, mesh: ArrayMesh) -> void:
var edited_scene_root := get_editor_interface().get_edited_scene_root()
if edited_scene_root == null or building.get_parent() == null:
status_label.text = "Open a scene before adding the exported copy."
return
var sibling_parent := building.get_parent()
var baked_root := _make_baked_scene_root(building.name + "_Baked", mesh)
var undo := get_undo_redo()
undo.create_action("Export structure as a mesh copy")
undo.add_do_method(self, "_attach_baked_copy", sibling_parent, edited_scene_root, baked_root, building.global_transform)
undo.add_undo_method(self, "_detach_baked_copy", sibling_parent, baked_root)
undo.commit_action()
status_label.text = "Copy exported as one mesh with %d material surface(s). Ctrl+Z removes the copy." % mesh.get_surface_count()
func _attach_baked_copy(sibling_parent: Node, edited_scene_root: Node, baked_root: Node3D, target_transform: Transform3D) -> void:
if baked_root.get_parent() != sibling_parent:
if baked_root.get_parent() != null:
baked_root.get_parent().remove_child(baked_root)
sibling_parent.add_child(baked_root, true)
baked_root.global_transform = target_transform
baked_root.owner = edited_scene_root
_set_export_owner_recursive(baked_root, edited_scene_root)
var editor_selection := get_editor_interface().get_selection()
editor_selection.clear()
editor_selection.add_node(baked_root)
func _detach_baked_copy(sibling_parent: Node, baked_root: Node3D) -> void:
if baked_root.get_parent() == sibling_parent:
sibling_parent.remove_child(baked_root)
func _make_baked_scene_root(root_name: String, mesh: ArrayMesh) -> Node3D:
var baked_root := Node3D.new()
baked_root.name = root_name
var mesh_instance := MeshInstance3D.new()
mesh_instance.name = "BakedMesh"
mesh_instance.mesh = mesh
baked_root.add_child(mesh_instance)
return baked_root
func _export_baked_scene_to_path(path: String) -> void:
if _pending_export_mesh == null:
status_label.text = "The exported mesh is no longer available. Bake it again."
return
var baked_root := _make_baked_scene_root(_pending_export_name, _pending_export_mesh)
baked_root.transform = Transform3D(_pending_export_basis, Vector3.ZERO)
_set_export_owner_recursive(baked_root, baked_root)
var packed := PackedScene.new()
var pack_error := packed.pack(baked_root)
baked_root.free()
if pack_error != OK:
status_label.text = "Could not pack the exported mesh (%s)." % error_string(pack_error)
return
var save_error := ResourceSaver.save(packed, path)
if save_error != OK:
status_label.text = "Could not save the exported scene (%s)." % error_string(save_error)
return
get_editor_interface().get_resource_filesystem().scan()
status_label.text = "Exported scene: %s (%d material surface(s))." % [path, _pending_export_mesh.get_surface_count()]
_pending_export_mesh = null
func _set_export_owner_recursive(node: Node, scene_owner: Node) -> void:
for child in node.get_children():
child.owner = scene_owner
_set_export_owner_recursive(child, scene_owner)
func _save_module_to_path(path: String) -> void:
var building := _get_selected_piece()
if building == null:
status_label.text = "Select a ModularBuilderPiece structure to save as a module."
return
var descendants: Array[Node] = []
_collect_module_nodes(building, descendants)
var original_owners: Dictionary = {}
for node in descendants:
original_owners[node] = node.owner
node.owner = building
var packed := PackedScene.new()
var pack_error := packed.pack(building)
for node in descendants:
node.owner = original_owners[node]
if pack_error != OK:
status_label.text = "Could not pack the structure as a scene (%s)." % error_string(pack_error)
return
var save_error := ResourceSaver.save(packed, path)
if save_error != OK:
status_label.text = "Could not save the module (%s)." % error_string(save_error)
return
status_label.text = "Module saved: %s" % path
func _collect_module_nodes(node: Node, result: Array[Node]) -> void:
for child in node.get_children():
if child.name == "ModularBuilderMarkers" or child.has_meta("modular_builder_preview"):
continue
result.append(child)
_collect_module_nodes(child, result)
func _load_module_from_path(path: String) -> void:
var packed := ResourceLoader.load(path) as PackedScene
if packed == null:
status_label.text = "The selected file is not a valid Godot scene."
return
var instance := packed.instantiate()
if not instance is Node3D:
instance.free()
status_label.text = "The module scene must have a Node3D root."
return
var scene_root := get_editor_interface().get_edited_scene_root()
if scene_root == null:
instance.free()
status_label.text = "Open a scene before inserting a module."
return
var anchor := _get_selected_piece()
if anchor != null:
var target_world := anchor.global_position + anchor.global_transform.basis.x * maxf(anchor.unit_size, 1.0)
instance.position = scene_root.to_local(target_world)
var undo := get_undo_redo()
undo.create_action("Insert module %s" % instance.name)
undo.add_do_method(self, "_attach_module", scene_root, instance)
undo.add_undo_method(self, "_detach_module", scene_root, instance)
undo.commit_action()
status_label.text = "Module inserted. Move the piece to the desired location."
func _attach_module(scene_root: Node, instance: Node) -> void:
if instance.get_parent() != scene_root:
scene_root.add_child(instance, true)
instance.owner = scene_root
var editor_selection := get_editor_interface().get_selection()
editor_selection.clear()
editor_selection.add_node(instance)
func _detach_module(scene_root: Node, instance: Node) -> void:
if instance.get_parent() == scene_root:
scene_root.remove_child(instance)
func _rotate_selected_piece() -> void:
var selected_piece := _raw_selected_piece()
var target := selected_piece
if selected_piece == null:
target = _last_created_piece if is_instance_valid(_last_created_piece) and _last_created_piece.is_inside_tree() else null
elif selected_piece.piece_type == ModularBuilderPiece.PieceType.BUILDING:
target = null
if is_instance_valid(_last_created_piece) and _last_created_piece.is_inside_tree() and _is_descendant_of(_last_created_piece, selected_piece):
target = _last_created_piece
if target == null:
status_label.text = "Select or create a piece to rotate."
return
var old_rotation := target.rotation_degrees
var undo := get_undo_redo()
undo.create_action("Rotate piece 90 degrees")
undo.add_do_property(target, "rotation_degrees", old_rotation + Vector3(0.0, 90.0, 0.0))
undo.add_undo_property(target, "rotation_degrees", old_rotation)
undo.commit_action()
_last_created_piece = target
status_label.text = "%s rotated by 90°." % target.name
update_overlays()
func _is_descendant_of(node: Node, ancestor: Node) -> bool:
var current := node.get_parent()
while current != null:
if current == ancestor:
return true
current = current.get_parent()
return false
func _raw_selected_piece() -> ModularBuilderPiece:
var selected := get_editor_interface().get_selection().get_selected_nodes()
if selected.size() == 1 and selected[0] is ModularBuilderPiece:
return selected[0] as ModularBuilderPiece
return null
func _add_general_collision() -> void:
var piece := _get_selected_piece()
if piece == null:
status_label.text = "Select a structure to generate general collision."
return
var scene_root := get_editor_interface().get_edited_scene_root()
var faces := PackedVector3Array()
var structure_pieces: Array[ModularBuilderPiece] = []
_collect_structure_pieces(piece, structure_pieces)
_collect_scene_pieces(scene_root, structure_pieces)
_collect_collision_faces(piece, piece, faces, {}, structure_pieces)
if faces.is_empty():
status_label.text = "The selected structure has no geometry for collision."
return
var concave := ConcavePolygonShape3D.new()
concave.set_faces(faces)
var body := piece.find_child("GeneralCollision", true, false) as StaticBody3D
var undo := get_undo_redo()
if body != null:
var shape_node := body.get_node_or_null("Shape") as CollisionShape3D
if shape_node == null:
shape_node = CollisionShape3D.new()
shape_node.name = "Shape"
shape_node.shape = concave
undo.create_action("Add shape to general collision")
undo.add_do_method(body, "add_child", shape_node, true)
undo.add_do_property(shape_node, "owner", scene_root)
undo.add_undo_method(body, "remove_child", shape_node)
else:
var old_shape: Shape3D = shape_node.shape
undo.create_action("Update general collision")
undo.add_do_property(shape_node, "shape", concave)
undo.add_undo_property(shape_node, "shape", old_shape)
else:
body = StaticBody3D.new()
body.name = "GeneralCollision"
var shape_node := CollisionShape3D.new()
shape_node.name = "Shape"
shape_node.shape = concave
body.add_child(shape_node)
undo.create_action("Add general collision")
undo.add_do_method(piece, "add_child", body, true)
undo.add_do_property(body, "owner", scene_root)
undo.add_do_property(shape_node, "owner", scene_root)
undo.add_undo_method(piece, "remove_child", body)
undo.commit_action()
status_label.text = "General collision added or updated. Snap markers are excluded."
func _collect_structure_pieces(node: Node, pieces: Array[ModularBuilderPiece]) -> void:
if node is ModularBuilderPiece:
pieces.append(node)
for child in node.get_children():
if child.name == "ModularBuilderMarkers" or child.name == "GeneralCollision":
continue
_collect_structure_pieces(child, pieces)
func _collect_scene_pieces(node: Node, pieces: Array[ModularBuilderPiece]) -> void:
if node is ModularBuilderPiece and not pieces.has(node):
pieces.append(node)
for child in node.get_children():
if child.name == "ModularBuilderMarkers" or child.name == "GeneralCollision":
continue
_collect_scene_pieces(child, pieces)
func _collect_collision_faces(node: Node, piece_root: ModularBuilderPiece, faces: PackedVector3Array, seen_faces: Dictionary, structure_pieces: Array[ModularBuilderPiece]) -> void:
if node is MeshInstance3D and node.name == "Geometry" and node.mesh != null:
var relative_transform: Transform3D = piece_root.global_transform.affine_inverse() * node.global_transform
var mesh_faces: PackedVector3Array = node.mesh.get_faces()
var owner_piece := node.get_parent() as ModularBuilderPiece
for i in range(0, mesh_faces.size(), 3):
if i + 2 >= mesh_faces.size():
break
var local_a: Vector3 = mesh_faces[i]
var local_b: Vector3 = mesh_faces[i + 1]
var local_c: Vector3 = mesh_faces[i + 2]
if _is_connection_face(owner_piece, local_a, local_b, local_c, structure_pieces):
continue
# Procedural meshes have both sides. Deduplication may retain
# either orientation; normalize top faces beforehand to
# keep custom foundations navigable.
if _is_piece_top_face(owner_piece, local_a, local_b, local_c):
if (local_b - local_a).cross(local_c - local_a).y < 0.0:
var swap := local_b
local_b = local_c
local_c = swap
var a: Vector3 = relative_transform * local_a
var b: Vector3 = relative_transform * local_b
var c: Vector3 = relative_transform * local_c
var key_parts := [_collision_vertex_key(a), _collision_vertex_key(b), _collision_vertex_key(c)]
key_parts.sort()
var face_key := "%s|%s|%s" % key_parts
if seen_faces.has(face_key):
continue
seen_faces[face_key] = true
faces.append_array(PackedVector3Array([a, b, c]))
for child in node.get_children():
if child.name == "ModularBuilderMarkers" or child.name == "GeneralCollision":
continue
_collect_collision_faces(child, piece_root, faces, seen_faces, structure_pieces)
func _is_piece_top_face(piece: ModularBuilderPiece, a: Vector3, b: Vector3, c: Vector3) -> bool:
if piece == null:
return false
if piece.piece_type != ModularBuilderPiece.PieceType.FLOOR and piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_SQUARE and piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE:
return false
if absf(a.y - b.y) > 0.002 or absf(a.y - c.y) > 0.002:
return false
var top_y := piece.thickness * 0.5 if piece.centered_origin else piece.thickness
if piece.piece_type == ModularBuilderPiece.PieceType.FLOOR and piece.custom_points.size() >= 3:
top_y = piece.custom_points[0].y
return absf(a.y - top_y) < 0.002 and absf(b.y - top_y) < 0.002 and absf(c.y - top_y) < 0.002
func _is_connection_face(piece: ModularBuilderPiece, a: Vector3, b: Vector3, c: Vector3, structure_pieces: Array[ModularBuilderPiece]) -> bool:
if piece == null:
return false
if (piece.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE or piece.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE) and _foundation_edge_is_shared(piece, a, b, c, structure_pieces):
return true
if piece.piece_type == ModularBuilderPiece.PieceType.FLOOR and piece.custom_points.size() >= 3:
var bottom_point := piece.custom_points[0] - Vector3.UP * piece.thickness
return _triangle_on_plane(a, b, c, bottom_point, Vector3.UP)
if piece.piece_type != ModularBuilderPiece.PieceType.WALL or piece.custom_points.size() < 2:
return false
var start: Vector3 = piece.custom_points[0]
var finish: Vector3 = piece.custom_points[1]
var direction := (finish - start).normalized()
var side := Vector3(-direction.z, 0.0, direction.x).normalized() * piece.thickness * 0.5
var base_normal := (finish - start).cross(side).normalized()
if _triangle_on_plane(a, b, c, start, base_normal):
return true
if _wall_has_floor_contact(piece, structure_pieces):
if _triangle_on_plane(a, b, c, start + Vector3.UP * piece.unit_size, base_normal):
return true
for other in structure_pieces:
if other == piece or other.piece_type != ModularBuilderPiece.PieceType.WALL or other.custom_points.size() < 2:
continue
for endpoint in piece.custom_points:
var endpoint_world := piece.to_global(endpoint)
for other_endpoint in other.custom_points:
if endpoint_world.distance_to(other.to_global(other_endpoint)) < 0.02:
var local_endpoint := piece.to_local(endpoint_world)
if _triangle_on_plane(a, b, c, local_endpoint, direction):
return true
return false
func _wall_has_floor_contact(wall: ModularBuilderPiece, structure_pieces: Array[ModularBuilderPiece]) -> bool:
var endpoints: PackedVector3Array = wall.custom_points
var direction := (endpoints[1] - endpoints[0]).normalized()
var side := Vector3(-direction.z, 0.0, direction.x).normalized() * wall.thickness * 0.5
for endpoint in endpoints:
var top := endpoint + Vector3.UP * wall.unit_size
for wall_corner in [wall.to_global(top + side), wall.to_global(top - side)]:
for other in structure_pieces:
if other.piece_type != ModularBuilderPiece.PieceType.FLOOR or other.custom_points.size() < 3:
continue
for floor_point in other.custom_points:
if wall_corner.distance_to(other.to_global(floor_point)) < 0.02:
return true
return false
func _foundation_edge_is_shared(piece: ModularBuilderPiece, a: Vector3, b: Vector3, c: Vector3, structure_pieces: Array[ModularBuilderPiece]) -> bool:
var polygon := _foundation_polygon(piece)
for other in structure_pieces:
if other == piece or (other.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_SQUARE and other.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE):
continue
var other_polygon := _foundation_polygon(other)
for i in range(polygon.size()):
var p0: Vector2 = polygon[i]
var p1: Vector2 = polygon[(i + 1) % polygon.size()]
for j in range(other_polygon.size()):
var q0: Vector2 = other_polygon[j]
var q1: Vector2 = other_polygon[(j + 1) % other_polygon.size()]
var other_base_y := -other.thickness * 0.5 if other.centered_origin else 0.0
var q0_local := piece.to_local(other.to_global(Vector3(q0.x, other_base_y, q0.y)))
var q1_local := piece.to_local(other.to_global(Vector3(q1.x, other_base_y, q1.y)))
var shared := _segments_overlap(p0, p1, Vector2(q0_local.x, q0_local.z), Vector2(q1_local.x, q1_local.z))
var piece_base_y := -piece.thickness * 0.5 if piece.centered_origin else 0.0
if shared and absf(q0_local.y - piece_base_y) < 0.02 and absf(q1_local.y - piece_base_y) < 0.02 and _triangle_on_edge(a, b, c, p0, p1):
return true
return false
func _triangle_on_edge(a: Vector3, b: Vector3, c: Vector3, edge_a: Vector2, edge_b: Vector2) -> bool:
return _point_on_edge(Vector2(a.x, a.z), edge_a, edge_b) and _point_on_edge(Vector2(b.x, b.z), edge_a, edge_b) and _point_on_edge(Vector2(c.x, c.z), edge_a, edge_b)
func _point_on_edge(point: Vector2, edge_a: Vector2, edge_b: Vector2) -> bool:
var edge := edge_b - edge_a
var length_squared := edge.length_squared()
if length_squared < 0.000001:
return false
var t := (point - edge_a).dot(edge) / length_squared
var closest := edge_a + edge * clampf(t, 0.0, 1.0)
return t >= -0.002 and t <= 1.002 and point.distance_to(closest) < 0.002
func _segments_overlap(a: Vector2, b: Vector2, c: Vector2, d: Vector2) -> bool:
var direction := b - a
var length := direction.length()
if length < 0.0001:
return false
var unit := direction / length
if absf(unit.x * (c - a).y - unit.y * (c - a).x) > 0.02 or absf(unit.x * (d - a).y - unit.y * (d - a).x) > 0.02:
return false
var c_proj := (c - a).dot(unit)
var d_proj := (d - a).dot(unit)
var overlap := minf(length, maxf(c_proj, d_proj)) - maxf(0.0, minf(c_proj, d_proj))
return overlap > 0.02
func _triangle_on_plane(a: Vector3, b: Vector3, c: Vector3, plane_point: Vector3, plane_normal: Vector3) -> bool:
if plane_normal.length_squared() < 0.000001:
return false
var normal := plane_normal.normalized()
return absf((a - plane_point).dot(normal)) < 0.002 and absf((b - plane_point).dot(normal)) < 0.002 and absf((c - plane_point).dot(normal)) < 0.002
func _collision_vertex_key(point: Vector3) -> String:
return "%.4f,%.4f,%.4f" % [point.x, point.y, point.z]
func _set_tool(tool: String) -> void:
active_tool = tool
_detail_options_row.visible = _is_detail_tool(tool)
_rise_options_row.visible = tool == TOOL_WALL or tool == TOOL_GABLE_WALL or tool == TOOL_GABLE_WALL_RIGHT or tool == TOOL_ROOF_GABLE or tool == TOOL_ROOF_PYRAMID
_linear_options_row.visible = tool == TOOL_STAIRS or tool == TOOL_FENCE
_stair_count_label.visible = tool == TOOL_STAIRS
stair_count_spin.visible = tool == TOOL_STAIRS
_stair_width_label.visible = tool == TOOL_STAIRS
stair_width_spin.visible = tool == TOOL_STAIRS
_fence_divisions_label.visible = tool == TOOL_FENCE
fence_divisions_spin.visible = tool == TOOL_FENCE
_gable_centralize_check.visible = tool == TOOL_WALL or tool == TOOL_GABLE_WALL or tool == TOOL_GABLE_WALL_RIGHT
_rise_label.text = "Peak rise:"
if not _is_detail_tool(tool):
call("_clear_detail_preview")
if tool == TOOL_NONE:
_active_button_key = ""
pending_points.clear()
for button_tool in _tool_buttons:
_tool_buttons[button_tool].set_pressed_no_signal(button_tool == _active_button_key)
if tool == TOOL_NONE:
status_label.text = "Tool deactivated."
elif _get_selected_piece() == null:
status_label.text = "Select the corresponding piece in the Scene tree."
elif tool == TOOL_FOUNDATION:
status_label.text = "Click a + button beside any foundation. Click the tool again to deactivate it."
elif tool == TOOL_WINDOW or tool == TOOL_DOOR:
status_label.text = "Click a wall face to insert the detail. Edit its mode and dimensions in the Inspector."
elif tool == TOOL_HATCH:
status_label.text = "Click a foundation or floor face to insert the hatch. Edit it in the Inspector."
elif tool == TOOL_GABLE_WALL:
status_label.text = "Click the two base vertices of the gable. Adjust peak height below."
elif tool == TOOL_GABLE_WALL_RIGHT:
status_label.text = "Click the base vertices; the second point sets the high side of the right triangular gable."
elif tool == TOOL_WALL:
status_label.text = "Click two vertices on foundations, floors, or wall tops. Enable Center walls to align the endpoints with supporting walls."
elif tool == TOOL_ROOF_GABLE:
status_label.text = "Click the peaks of two gables or diagonal corners atop opposite walls."
elif tool == TOOL_ROOF_SHED:
status_label.text = "Click a low point, then a diagonally opposite high point. The roof finds the other corners; adjust width in the Inspector."
elif tool == TOOL_ROOF_PYRAMID:
status_label.text = "Click two diagonal vertices at the same height. Adjust pyramid peak height below or in the Inspector."
elif tool == TOOL_STAIRS:
status_label.text = "Click the low point, then the high point to create stairs. Adjust steps and width below."
elif tool == TOOL_FENCE or tool == TOOL_PARAPET:
status_label.text = "Click two vertices to set the path. Adjust height and intermediate posts below."
elif tool == TOOL_MERGE_VERTICES:
status_label.text = "Click two vertices to merge their markers. Ctrl+Z undoes the merge."
elif tool == TOOL_CREATE_VERTEX:
status_label.text = "Click a surface to create a connection point linked to the selected piece or detail."
elif tool == TOOL_MOVE_VERTEX:
status_label.text = "Click a blue manual point, then the surface where you want to move it."
elif tool == TOOL_REMOVE_VERTEX:
status_label.text = "Click a blue manual point to remove it."
elif tool == TOOL_PILLAR:
status_label.text = "Click two vertices to connect them. Alt+click one vertex to create a vertical pillar with the panel length."
else:
status_label.text = "Click the blue structure vertices. Click the tool again to deactivate it."
call("_sync_vertex_markers")
update_overlays()
func _get_selected_piece() -> ModularBuilderPiece:
var selected := get_editor_interface().get_selection().get_selected_nodes()
if selected.is_empty():
return null
var common_root: ModularBuilderPiece
for selected_node in selected:
var selected_piece := selected_node as ModularBuilderPiece
if selected_piece == null and selected_node is ModularBuilderDetail:
var parent := selected_node.get_parent()
while parent != null and not parent is ModularBuilderPiece:
parent = parent.get_parent()
selected_piece = parent as ModularBuilderPiece
if selected_piece == null:
return null
var piece_root := selected_piece
var ancestor := selected_piece.get_parent()
while ancestor != null:
if ancestor is ModularBuilderPiece and ancestor.piece_type == ModularBuilderPiece.PieceType.BUILDING:
piece_root = ancestor
break
ancestor = ancestor.get_parent()
if common_root == null:
common_root = piece_root
elif common_root != piece_root:
return null
return common_root
func _on_editor_selection_changed() -> void:
if _redirecting_selection:
return
var selected := get_editor_interface().get_selection().get_selected_nodes()
var redirected_selection: Array[Node] = []
var needs_redirect := false
for selected_node in selected:
var target_node: Node = selected_node
if selected_node is MeshInstance3D:
var ancestor := selected_node.get_parent()
while ancestor != null and not (ancestor is ModularBuilderPiece or ancestor is ModularBuilderDetail):
ancestor = ancestor.get_parent()
if ancestor != null:
target_node = ancestor
needs_redirect = true
if not redirected_selection.has(target_node):
redirected_selection.append(target_node)
if needs_redirect:
_redirecting_selection = true
var editor_selection := get_editor_interface().get_selection()
editor_selection.clear()
for target_node in redirected_selection:
editor_selection.add_node(target_node)
_redirecting_selection = false
if active_tool != TOOL_NONE:
pending_points.clear()
_sync_pillar_centralize_control()
call("_sync_vertex_markers")
update_overlays()
func _on_marker_scope_toggled(_enabled: bool) -> void:
call("_sync_vertex_markers")
update_overlays()
func _on_simplify_vertices_toggled(_enabled: bool) -> void:
pending_points.clear()
call("_sync_vertex_markers")
update_overlays()
func _foundation_polygon(root: ModularBuilderPiece) -> Array[Vector2]:
if root.custom_points.size() >= 3:
var custom: Array[Vector2] = []
for point in root.custom_points:
custom.append(Vector2(point.x, point.z))
return custom
var half := root.unit_size * 0.5
if root.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE:
return [Vector2(-half, -half), Vector2(half, -half), Vector2(-half, half)]
return [Vector2(-half, -half), Vector2(half, -half), Vector2(half, half), Vector2(-half, half)]

View file

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

View file

@ -1,430 +0,0 @@
@tool
extends "res://addons/modular_builder/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")
func _create_detail_from_click(camera: Camera3D, screen_position: Vector2) -> bool:
var placement := _find_detail_placement(camera, screen_position)
if placement.is_empty():
return false
var detail := _new_detail(placement, false)
var best_piece := placement.host as ModularBuilderPiece
if best_piece == null:
return false
var undo := get_undo_redo()
undo.create_action("Add %s" % detail.name)
undo.add_do_method(self, "_attach_detail", best_piece, detail, get_editor_interface().get_edited_scene_root())
undo.add_undo_method(self, "_detach_detail", best_piece, detail)
undo.commit_action()
get_editor_interface().get_selection().clear()
get_editor_interface().get_selection().add_node(detail)
_set_tool(TOOL_NONE)
status_label.text = "%s created. Edit style, materials, and dimensions in the Inspector." % detail.name
return true
func _create_pillar_from_vertices(root: ModularBuilderPiece, first: Vector3, second: Vector3) -> void:
var placement := _find_pillar_placement(root, first, second)
if placement.is_empty():
pending_points.clear()
status_label.text = "Choose two different structure vertices to connect with the pillar."
return
_commit_pillar_placement(placement, "Plain pillar created between vertices. Edit its section and material in the Inspector.")
func _create_vertical_pillar_from_vertex(root: ModularBuilderPiece, anchor: Vector3) -> void:
pending_points.clear()
var length := maxf(0.2, detail_height_spin.value * METERS_TO_GODOT_UNITS)
var direction := _vertical_pillar_direction(root, anchor)
var placement := _find_pillar_placement(root, anchor, anchor + direction * length)
if placement.is_empty():
status_label.text = "Could not place a vertical pillar at this vertex."
return
_commit_pillar_placement(placement, "Vertical pillar created from the vertex. Edit its length in the Inspector.")
func _commit_pillar_placement(placement: Dictionary, message: String) -> void:
var detail := _new_detail(placement, false)
var host := placement.host as ModularBuilderPiece
detail.set("height", float(placement.length))
var undo := get_undo_redo()
undo.create_action("Add plain pillar")
undo.add_do_method(self, "_attach_detail", host, detail, get_editor_interface().get_edited_scene_root())
undo.add_undo_method(self, "_detach_detail", host, detail)
undo.commit_action()
get_editor_interface().get_selection().clear()
get_editor_interface().get_selection().add_node(detail)
_set_tool(TOOL_NONE)
status_label.text = message
func _vertical_pillar_direction(root: ModularBuilderPiece, anchor: Vector3) -> Vector3:
var selected_pillar := _get_selected_pillar_detail()
if selected_pillar != null:
var pillar_local := selected_pillar.to_local(anchor)
if absf(pillar_local.x) <= selected_pillar.width * 0.5 + 0.05 and absf(pillar_local.z) <= selected_pillar.width * 0.5 + 0.05 and absf(absf(pillar_local.y) - selected_pillar.height * 0.5) <= 0.05:
return PILLAR_SUPPORT.vertical_direction_for_pillar(selected_pillar, anchor)
var source := _get_pillar_vertex_source(root, anchor)
if source != null:
return PILLAR_SUPPORT.vertical_direction_for_piece(source, anchor)
var pillar := _find_pillar_at_vertex(root, anchor)
return PILLAR_SUPPORT.vertical_direction_for_pillar(pillar, anchor) if pillar != null else Vector3.UP
func _find_pillar_at_vertex(node: Node, anchor: Vector3) -> ModularBuilderDetail:
if node is ModularBuilderDetail and node.detail_type == ModularBuilderDetail.DetailType.PILLAR:
var detail := node as ModularBuilderDetail
var local := detail.to_local(anchor)
var half := detail.width * 0.5
var at_corner := absf(absf(local.x) - half) <= 0.05 and absf(absf(local.z) - half) <= 0.05
var at_center := _simplify_vertices_check != null and _simplify_vertices_check.button_pressed and absf(local.x) <= 0.05 and absf(local.z) <= 0.05
if (at_corner or at_center) and absf(absf(local.y) - detail.height * 0.5) <= 0.05:
return detail
for child in node.get_children():
var pillar := _find_pillar_at_vertex(child, anchor)
if pillar != null:
return pillar
return null
func _create_fence_decoration(root: ModularBuilderPiece, first: Vector3, second: Vector3) -> void:
if first.distance_to(second) < 0.05:
pending_points.clear()
status_label.text = "Choose two vertices far enough apart."
return
var is_parapet := active_tool == TOOL_PARAPET
var detail := Node3D.new()
detail.set_script(DETAIL_SCRIPT)
detail.name = "Parapet" if is_parapet else "Fence"
detail.set("detail_type", ModularBuilderDetail.DetailType.PARAPET if is_parapet else ModularBuilderDetail.DetailType.FENCE)
detail.set("detail_mode", ModularBuilderDetail.DetailMode.SEPARATE_OBJECT)
detail.set("path_points", PackedVector3Array([root.to_local(first), root.to_local(second)]))
detail.set("fence_height", fence_height_spin.value * METERS_TO_GODOT_UNITS)
detail.set("fence_divisions", int(fence_divisions_spin.value))
var undo := get_undo_redo()
undo.create_action("Add parapet" if is_parapet else "Add decorative fence")
undo.add_do_method(self, "_attach_detail", root, detail, get_editor_interface().get_edited_scene_root())
undo.add_undo_method(self, "_detach_detail", root, detail)
undo.commit_action()
get_editor_interface().get_selection().clear()
get_editor_interface().get_selection().add_node(detail)
_set_tool(TOOL_NONE)
pending_points.clear()
status_label.text = "%s created with %d intermediate post(s)." % [detail.name, detail.fence_divisions]
func _find_pillar_placement(root: ModularBuilderPiece, first: Vector3, second: Vector3) -> Dictionary:
if first.distance_to(second) < 0.02:
return {}
var local_first := root.to_local(first)
var local_second := root.to_local(second)
var segment := local_second - local_first
var segment_length := segment.length()
if segment_length < 0.02:
return {}
var y_axis := segment / segment_length
var reference_axis := Vector3.FORWARD if absf(y_axis.dot(Vector3.UP)) > 0.99 else Vector3.UP
var x_axis := reference_axis.cross(y_axis).normalized()
var z_axis := x_axis.cross(y_axis).normalized()
var basis := Basis(x_axis, y_axis, z_axis).orthonormalized()
var source := _get_pillar_vertex_source(root, first)
var alignment_basis := Basis.IDENTITY
if source != null:
alignment_basis = (root.global_transform.basis.inverse() * source.global_transform.basis).orthonormalized()
return {
"host": root,
"position": (local_first + local_second) * 0.5,
"rotation": Vector3.ZERO,
"basis": basis,
"direction": segment,
"alignment_basis": alignment_basis,
"length": segment_length
}
func _get_pillar_vertex_source(root: ModularBuilderPiece, point: Vector3) -> ModularBuilderPiece:
var best_piece: ModularBuilderPiece
var best_distance := 0.05
var pieces: Array[ModularBuilderPiece] = []
call("_collect_all_modular_pieces", root, pieces)
for piece in pieces:
if piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_SQUARE and piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE and piece.piece_type != ModularBuilderPiece.PieceType.FLOOR and piece.piece_type != ModularBuilderPiece.PieceType.WALL and piece.piece_type != ModularBuilderPiece.PieceType.GABLE_WALL and piece.piece_type != ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT:
continue
var vertices: Array[Vector3] = []
call("_collect_piece_geometry_vertices", piece, vertices)
for vertex in vertices:
var distance := vertex.distance_to(point)
if _simplify_vertices_check != null and _simplify_vertices_check.button_pressed:
distance = minf(distance, VERTEX_SIMPLIFIER.piece_vertex(piece, vertex).distance_to(point))
if distance < best_distance:
best_distance = distance
best_piece = piece
return best_piece
func _point_inside_surface_or_edge(piece: ModularBuilderPiece, point: Vector2, edge_tolerance: float) -> bool:
if _point_inside_surface(piece, point):
return true
var polygon := _foundation_polygon(piece) if piece.piece_type != ModularBuilderPiece.PieceType.FLOOR else _piece_polygon_xz(piece)
for i in range(polygon.size()):
var a: Vector2 = polygon[i]
var b: Vector2 = polygon[(i + 1) % polygon.size()]
var edge := b - a
var t := clampf((point - a).dot(edge) / maxf(edge.length_squared(), 0.000001), 0.0, 1.0)
if point.distance_to(a + edge * t) <= edge_tolerance + 0.01:
return true
return false
func _find_detail_placement(camera: Camera3D, screen_position: Vector2) -> Dictionary:
var root := _get_selected_piece()
if root == null or not _is_detail_tool(active_tool):
return {}
var ray_origin := camera.project_ray_origin(screen_position)
var ray_direction := camera.project_ray_normal(screen_position)
var candidates: Array[ModularBuilderPiece] = []
if active_tool == TOOL_HATCH or active_tool == TOOL_PILLAR:
_collect_surface_pieces(root, candidates)
else:
_collect_wall_pieces(root, candidates)
var best_distance := INF
var best_piece: ModularBuilderPiece
var best_local := Vector3.ZERO
var best_surface_y := 0.0
for candidate in candidates:
if not is_instance_valid(candidate) or candidate.piece_type == ModularBuilderPiece.PieceType.BUILDING:
continue
var local_origin := candidate.to_local(ray_origin)
var local_direction := candidate.to_local(ray_origin + ray_direction) - local_origin
if active_tool != TOOL_HATCH and candidate.custom_points.size() < 2:
continue
var uses_surface := active_tool == TOOL_HATCH or active_tool == TOOL_PILLAR
var plane_normal := Vector3.UP if uses_surface else Vector3(-candidate.custom_points[1].z + candidate.custom_points[0].z, 0.0, candidate.custom_points[1].x - candidate.custom_points[0].x).normalized()
if uses_surface and candidate.custom_points.size() >= 3:
plane_normal = Vector3.UP
if absf(local_direction.dot(plane_normal)) < 0.0001:
continue
var plane_y := 0.0
var plane_point := Vector3.ZERO
if uses_surface:
plane_y = candidate.thickness * 0.5 if candidate.centered_origin else candidate.thickness
plane_point.y = plane_y
var distance := (plane_point - local_origin).dot(plane_normal) / local_direction.dot(plane_normal)
if distance < 0.0 or distance >= best_distance:
continue
var hit := local_origin + local_direction * distance
if uses_surface:
if candidate.piece_type != ModularBuilderPiece.PieceType.FLOOR and candidate.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_SQUARE and candidate.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE:
continue
if not _point_inside_surface(candidate, Vector2(hit.x, hit.z)):
continue
else:
if candidate.piece_type != ModularBuilderPiece.PieceType.WALL or candidate.custom_points.size() < 2:
continue
var a: Vector3 = candidate.custom_points[0]
var b: Vector3 = candidate.custom_points[1]
var wall_dir := (b - a).normalized()
var along := (hit - a).dot(wall_dir)
var vertical := hit.y - a.y
if along < 0.0 or along > a.distance_to(b) or vertical < 0.0 or vertical > candidate.unit_size:
continue
best_distance = distance
best_piece = candidate
best_local = hit
best_surface_y = plane_y
if best_piece == null:
return {}
# best_local uses the selected piece’s local coordinates. The child detail
# must use the same space, especially for attached foundations.
var host_local := best_local
var detail_position: Vector3
var detail_rotation := Vector3.ZERO
if active_tool == TOOL_HATCH:
detail_position = Vector3(host_local.x, 0.0, host_local.z)
elif active_tool == TOOL_PILLAR:
detail_position = Vector3(host_local.x, best_surface_y, host_local.z)
else:
var wall_a: Vector3 = best_piece.custom_points[0]
var wall_b: Vector3 = best_piece.custom_points[1]
var direction := (wall_b - wall_a).normalized()
var n := Vector3(-direction.z, 0.0, direction.x)
detail_rotation.y = atan2(n.x, n.z)
var opening_center_y := wall_a.y + detail_height_spin.value * 0.5 + detail_height_offset_spin.value
detail_position = Vector3((wall_a.x + wall_b.x) * 0.5, opening_center_y, (wall_a.z + wall_b.z) * 0.5)
return {"host": best_piece, "position": detail_position, "rotation": detail_rotation}
func _new_detail(placement: Dictionary, preview: bool) -> ModularBuilderDetail:
var detail := Node3D.new()
detail.set_script(DETAIL_SCRIPT)
if active_tool == TOOL_WINDOW:
detail.name = "Window"
elif active_tool == TOOL_DOOR:
detail.name = "Door"
elif active_tool == TOOL_HATCH:
detail.name = "Hatch"
elif active_tool == TOOL_PILLAR:
detail.name = "Pillar"
elif active_tool == TOOL_FENCE:
detail.name = "Fence"
else:
detail.name = "Parapet"
if active_tool == TOOL_WINDOW:
detail.set("detail_type", ModularBuilderDetail.DetailType.WINDOW)
elif active_tool == TOOL_DOOR:
detail.set("detail_type", ModularBuilderDetail.DetailType.DOOR)
elif active_tool == TOOL_HATCH:
detail.set("detail_type", ModularBuilderDetail.DetailType.HATCH)
elif active_tool == TOOL_FENCE:
detail.set("detail_type", ModularBuilderDetail.DetailType.FENCE)
elif active_tool == TOOL_PARAPET:
detail.set("detail_type", ModularBuilderDetail.DetailType.PARAPET)
else:
detail.set("detail_type", ModularBuilderDetail.DetailType.PILLAR)
detail.set("width", detail_width_spin.value * METERS_TO_GODOT_UNITS)
detail.set("height", float(placement.length) if active_tool == TOOL_PILLAR and placement.has("length") else detail_height_spin.value * METERS_TO_GODOT_UNITS)
detail.set("detail_mode", ModularBuilderDetail.DetailMode.SEPARATE_OBJECT if preview or active_tool == TOOL_PILLAR else detail_mode_option.get_selected_id())
detail.set("surface_material", _preview_material if preview else null)
if preview:
var host := placement.host as ModularBuilderPiece
var preview_depth := host.thickness if host != null else 0.08
if active_tool == TOOL_WINDOW or active_tool == TOOL_DOOR:
preview_depth *= 1.25
detail.set("follow_host_thickness", false)
detail.set("frame_depth", preview_depth)
if active_tool == TOOL_WINDOW:
detail.set("window_style", _selected_window_style)
elif active_tool == TOOL_DOOR:
detail.set("door_style", _selected_door_style)
if preview:
detail.name = "ModularBuilderPreview"
detail.set_meta("modular_builder_preview", true)
var detail_basis: Basis = placement.basis if placement.has("basis") else Basis.from_euler(placement.rotation)
detail.transform = Transform3D(detail_basis, placement.position)
if active_tool == TOOL_PILLAR:
detail.set("pillar_direction", placement.direction)
detail.set("pillar_alignment_basis", placement.alignment_basis)
detail.set("center_align", _pillar_centralize_check.button_pressed)
if active_tool == TOOL_FENCE or active_tool == TOOL_PARAPET:
detail.set("fence_height", fence_height_spin.value * METERS_TO_GODOT_UNITS)
detail.set("fence_divisions", int(fence_divisions_spin.value))
return detail as ModularBuilderDetail
func _update_detail_preview() -> void:
if not _is_detail_tool(active_tool) or active_tool == TOOL_FENCE or active_tool == TOOL_PARAPET or _preview_camera == null:
return
var placement: Dictionary
if active_tool == TOOL_PILLAR:
var root := _get_selected_piece()
if root == null or pending_points.is_empty():
if is_instance_valid(_detail_preview):
_detail_preview.visible = false
return
var second_vertex := _nearest_vertex(_preview_camera, _preview_screen_position, call("_get_tool_vertices", root))
if second_vertex == null:
if is_instance_valid(_detail_preview):
_detail_preview.visible = false
return
placement = _find_pillar_placement(root, pending_points[0], second_vertex)
else:
placement = _find_detail_placement(_preview_camera, _preview_screen_position)
if placement.is_empty():
if is_instance_valid(_detail_preview):
_detail_preview.visible = false
return
if not is_instance_valid(_detail_preview) or _detail_preview.detail_type != _detail_type_for_tool(active_tool):
_clear_detail_preview()
_detail_preview = _new_detail(placement, true)
var scene_root := get_editor_interface().get_edited_scene_root()
if scene_root == null:
_detail_preview = null
return
var preview_host := placement.host as ModularBuilderPiece
if active_tool == TOOL_PILLAR and preview_host != null:
preview_host.add_child(_detail_preview, true)
else:
scene_root.add_child(_detail_preview, true)
_detail_preview.owner = scene_root
_detail_preview.visible = true
if not is_equal_approx(_detail_preview.width, detail_width_spin.value):
_detail_preview.width = detail_width_spin.value
var preview_height := float(placement.length) if active_tool == TOOL_PILLAR and placement.has("length") else detail_height_spin.value
if not is_equal_approx(_detail_preview.height, preview_height):
_detail_preview.height = preview_height
var host := placement.host as ModularBuilderPiece
if host != null:
var preview_basis: Basis = placement.basis if placement.has("basis") else Basis.from_euler(placement.rotation)
if active_tool == TOOL_PILLAR:
_detail_preview.transform = Transform3D(preview_basis, placement.position)
_detail_preview.pillar_direction = placement.direction
_detail_preview.pillar_alignment_basis = placement.alignment_basis
_detail_preview.center_align = _pillar_centralize_check.button_pressed
else:
_detail_preview.global_transform = host.global_transform * Transform3D(preview_basis, placement.position)
func _detail_type_for_tool(tool: String) -> int:
if tool == TOOL_DOOR:
return ModularBuilderDetail.DetailType.DOOR
if tool == TOOL_HATCH:
return ModularBuilderDetail.DetailType.HATCH
if tool == TOOL_PILLAR:
return ModularBuilderDetail.DetailType.PILLAR
if tool == TOOL_FENCE:
return ModularBuilderDetail.DetailType.FENCE
if tool == TOOL_PARAPET:
return ModularBuilderDetail.DetailType.PARAPET
return ModularBuilderDetail.DetailType.WINDOW
func _refresh_detail_preview(_value: float = 0.0) -> void:
if _preview_camera != null:
_update_detail_preview()
func _clear_detail_preview() -> void:
if is_instance_valid(_detail_preview):
_detail_preview.queue_free()
_detail_preview = null
func _attach_detail(host: ModularBuilderPiece, detail: Node, scene_root: Node) -> void:
if detail.get_parent() != host:
host.add_child(detail, true)
detail.owner = scene_root
host._rebuild()
func _detach_detail(host: ModularBuilderPiece, detail: Node) -> void:
if detail.get_parent() == host:
host.remove_child(detail)
host._rebuild()
func _collect_surface_pieces(node: Node, pieces: Array[ModularBuilderPiece]) -> void:
if node is ModularBuilderPiece and (node.piece_type == ModularBuilderPiece.PieceType.FLOOR or node.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE or node.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE):
pieces.append(node)
for child in node.get_children():
_collect_surface_pieces(child, pieces)
func _collect_wall_pieces(node: Node, pieces: Array[ModularBuilderPiece]) -> void:
if node is ModularBuilderPiece and node.piece_type == ModularBuilderPiece.PieceType.WALL:
pieces.append(node)
for child in node.get_children():
_collect_wall_pieces(child, pieces)
func _point_inside_surface(piece: ModularBuilderPiece, point: Vector2) -> bool:
var polygon := _foundation_polygon(piece) if piece.piece_type != ModularBuilderPiece.PieceType.FLOOR else _piece_polygon_xz(piece)
if polygon.size() < 3:
return false
var inside := false
var j := polygon.size() - 1
for i in range(polygon.size()):
var a: Vector2 = polygon[i]
var b: Vector2 = polygon[j]
if (a.y > point.y) != (b.y > point.y) and point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x:
inside = not inside
j = i
return inside
func _piece_polygon_xz(piece: ModularBuilderPiece) -> Array[Vector2]:
var polygon: Array[Vector2] = []
for point in piece.custom_points:
polygon.append(Vector2(point.x, point.z))
return polygon
func _nearest_vertex(camera: Camera3D, screen_position: Vector2, vertices: Array[Vector3]) -> Variant:
var nearest: Variant = null
var nearest_distance := MARKER_HIT_RADIUS
for world_point in vertices:
if camera.is_position_behind(world_point):
continue
var distance := camera.unproject_position(world_point).distance_to(screen_position)
if distance < nearest_distance:
nearest_distance = distance
nearest = world_point
return nearest

View file

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

View file

@ -1,544 +0,0 @@
@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 TOOL_NONE := ""
const TOOL_FOUNDATION := "foundation"
const TOOL_FOUNDATION_SQUARE := "foundation_square"
const TOOL_FOUNDATION_TRIANGLE := "foundation_triangle"
const TOOL_WALL := "wall"
const TOOL_GABLE_WALL := "gable_wall"
const TOOL_GABLE_WALL_RIGHT := "gable_wall_right"
const TOOL_FLOOR_SQUARE := "floor_square"
const TOOL_FLOOR_TRIANGLE := "floor_triangle"
const TOOL_ROOF_GABLE := "roof_gable"
const TOOL_ROOF_SHED := "roof_shed"
const TOOL_ROOF_PYRAMID := "roof_pyramid"
const TOOL_STAIRS := "stairs"
const TOOL_FENCE := "fence"
const TOOL_PARAPET := "parapet"
const TOOL_MERGE_VERTICES := "merge_vertices"
const TOOL_CREATE_VERTEX := "create_vertex"
const TOOL_MOVE_VERTEX := "move_vertex"
const TOOL_REMOVE_VERTEX := "remove_vertex"
const MERGED_VERTEX_GROUPS_META := &"modular_builder_merged_vertex_groups"
const CUSTOM_VERTICES_META := &"modular_builder_custom_vertices"
const TOOL_WINDOW := "detail_window"
const TOOL_DOOR := "detail_door"
const TOOL_HATCH := "detail_hatch"
const TOOL_PILLAR := "detail_pillar"
const EXPORT_NEW_SCENE := 0
const EXPORT_SCENE_COPY := 1
const EXPORT_LIBRARY := 2
const METERS_TO_GODOT_UNITS := 1.0
const DOOR_DEFAULT_SCALE := 0.8
const DOOR_DEFAULT_WIDTH_METERS := 0.87 * DOOR_DEFAULT_SCALE
const DOOR_DEFAULT_HEIGHT_METERS := 2.15 * DOOR_DEFAULT_SCALE
const MARKER_RADIUS := 8.0
const MARKER_HIT_RADIUS := 18.0
var dock: VBoxContainer
var _creation_row: HFlowContainer
var _export_row: HFlowContainer
var _tool_row: HFlowContainer
var _vertex_edit_row: HFlowContainer
var _marker_scope_check: CheckBox
var _simplify_vertices_check: CheckBox
var _rise_options_row: HFlowContainer
var _linear_options_row: HFlowContainer
var _stair_count_label: Label
var _stair_width_label: Label
var _fence_divisions_label: Label
var _detail_options_row: HFlowContainer
var _foundation_type_selected := ModularBuilderPiece.PieceType.FOUNDATION_SQUARE
var size_spin: SpinBox
var rise_spin: SpinBox
var stair_count_spin: SpinBox
var stair_width_spin: SpinBox
var fence_divisions_spin: SpinBox
var fence_height_spin: SpinBox
var _fence_height_label: Label
var _rise_label: Label
var _gable_centralize_check: CheckBox
var _gable_centralize_default := false
var detail_width_spin: SpinBox
var detail_height_spin: SpinBox
var detail_height_offset_spin: SpinBox
var _detail_height_offset_label: Label
var _detail_width_label: Label
var _detail_height_label: Label
var detail_mode_option: OptionButton
var detail_style_option: OptionButton
var status_label: Label
var active_tool := TOOL_NONE
var pending_points: Array[Vector3] = []
var _redirecting_selection := false
var _marker_container: Node3D
var _marker_root: ModularBuilderPiece
var _marker_point_nodes: Array[Label3D] = []
var _side_button_nodes: Array[Label3D] = []
var _side_material: StandardMaterial3D
var _tool_button_group: ButtonGroup
var _tool_buttons: Dictionary = {}
var _pillar_centralize_check: CheckBox
var _pillar_centralize_default := false
var _active_button_key := ""
var _last_created_piece: ModularBuilderPiece
var _detail_preview: ModularBuilderDetail
var _preview_material: StandardMaterial3D
var _preview_camera: Camera3D
var _preview_screen_position := Vector2.ZERO
var _save_module_dialog: FileDialog
var _load_module_dialog: FileDialog
var _export_scene_dialog: FileDialog
var _export_mode_option: OptionButton
var _pending_export_mesh: ArrayMesh
var _pending_export_name := "BakedBuilding"
var _pending_export_basis := Basis.IDENTITY
var _selected_window_style := ModularBuilderDetail.WindowStyle.SINGLE_PANE
var _selected_door_style := ModularBuilderDetail.DoorStyle.FLAT
var _editor_sync_elapsed := 0.0
func _ready() -> void:
set_process(true)
_side_material = StandardMaterial3D.new()
_side_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_side_material.albedo_color = Color(0.18, 0.95, 0.52)
_side_material.no_depth_test = true
_side_material.cull_mode = BaseMaterial3D.CULL_DISABLED
func _process(delta: float) -> void:
_editor_sync_elapsed += delta
if _editor_sync_elapsed < 1.0 / 15.0:
return
_editor_sync_elapsed = 0.0
call("_sync_vertex_markers")
_sync_pillar_centralize_control()
_sync_gable_centralize_control()
func _enter_tree() -> void:
dock = VBoxContainer.new()
dock.name = "Modular Builder"
dock.custom_minimum_size = Vector2(0, 110)
_creation_row = HFlowContainer.new()
dock.add_child(_creation_row)
_creation_row.add_child(_label("Size:"))
size_spin = SpinBox.new()
size_spin.min_value = 0.5
size_spin.max_value = 8.0
size_spin.step = 0.001
size_spin.value = 2.0
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))
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))
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))
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))
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))
load_module_button.pressed.connect(Callable(self, "_show_load_module_dialog"))
_creation_row.add_child(load_module_button)
_export_row = HFlowContainer.new()
dock.add_child(_export_row)
_export_row.add_child(_label("Export structure as mesh:") )
_export_mode_option = OptionButton.new()
_export_mode_option.add_item("New scene", EXPORT_NEW_SCENE)
_export_mode_option.add_item("Copy in this scene", EXPORT_SCENE_COPY)
_export_mode_option.add_item("In ScopedVR-Bibliothek (Objekt-Pipeline)", EXPORT_LIBRARY)
_export_mode_option.tooltip_text = "Choose whether to save the combined mesh as a new scene, add a copy to the current scene, or export it into the ScopedVR object library (svmap-ready with trimesh collision)."
_export_row.add_child(_export_mode_option)
var export_button := Button.new()
export_button.text = "Export"
export_button.tooltip_text = "Generate one static mesh with materials and UVs from the selected structure."
export_button.pressed.connect(Callable(self, "_on_export_pressed"))
_export_row.add_child(export_button)
_tool_row = HFlowContainer.new()
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))
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)
var marker_options_row := HFlowContainer.new()
dock.add_child(marker_options_row)
_marker_scope_check = CheckBox.new()
_marker_scope_check.text = "Only show points on selected pieces"
_marker_scope_check.tooltip_text = "Limit markers to pieces selected in the Scene tree. Selecting the structure container shows all its points."
_marker_scope_check.toggled.connect(Callable(self, "_on_marker_scope_toggled"))
marker_options_row.add_child(_marker_scope_check)
_simplify_vertices_check = CheckBox.new()
_simplify_vertices_check.text = "Simplify vertices"
_simplify_vertices_check.tooltip_text = "Merge front and back points at the middle of walls and pillars; floors and foundations show their top outline. Vertex editing tools show the exact points."
_simplify_vertices_check.toggled.connect(Callable(self, "_on_simplify_vertices_toggled"))
marker_options_row.add_child(_simplify_vertices_check)
_rise_options_row = HFlowContainer.new()
dock.add_child(_rise_options_row)
_rise_label = _label("Peak rise:")
_rise_options_row.add_child(_rise_label)
rise_spin = _detail_spin(1.0, 0.1, 8.0, 0.001)
_rise_options_row.add_child(rise_spin)
_linear_options_row = HFlowContainer.new()
dock.add_child(_linear_options_row)
_stair_count_label = _label("Steps:")
_linear_options_row.add_child(_stair_count_label)
stair_count_spin = SpinBox.new()
stair_count_spin.min_value = 1
stair_count_spin.max_value = 100
stair_count_spin.step = 1
stair_count_spin.value = 10
stair_count_spin.custom_minimum_size = Vector2(72, 0)
_linear_options_row.add_child(stair_count_spin)
_stair_width_label = _label("Width:")
_linear_options_row.add_child(_stair_width_label)
stair_width_spin = _detail_spin(1.0, 0.01, 8.0, 0.001)
_linear_options_row.add_child(stair_width_spin)
_fence_divisions_label = _label("Intermediate posts:")
_linear_options_row.add_child(_fence_divisions_label)
fence_divisions_spin = SpinBox.new()
fence_divisions_spin.min_value = 0
fence_divisions_spin.max_value = 100
fence_divisions_spin.step = 1
fence_divisions_spin.value = 1
fence_divisions_spin.custom_minimum_size = Vector2(72, 0)
_linear_options_row.add_child(fence_divisions_spin)
_fence_height_label = _label("Height:")
_linear_options_row.add_child(_fence_height_label)
fence_height_spin = _detail_spin(0.7, 0.1, 8.0, 0.001)
_linear_options_row.add_child(fence_height_spin)
_linear_options_row.visible = false
_gable_centralize_check = CheckBox.new()
_gable_centralize_check.text = "Center"
_gable_centralize_check.tooltip_text = "Align the gable with the center of the supporting wall thickness."
_gable_centralize_check.visible = false
_gable_centralize_check.toggled.connect(_on_gable_centralize_toggled)
_rise_options_row.add_child(_gable_centralize_check)
_rise_options_row.visible = false
_detail_options_row = HFlowContainer.new()
dock.add_child(_detail_options_row)
_detail_width_label = _label("Width:")
_detail_options_row.add_child(_detail_width_label)
detail_width_spin = _detail_spin(1.0, 0.01, 5.0, 0.001)
_detail_options_row.add_child(detail_width_spin)
_detail_height_label = _label("Height:")
_detail_options_row.add_child(_detail_height_label)
detail_height_spin = _detail_spin(1.0, 0.2, 5.0, 0.001)
_detail_options_row.add_child(detail_height_spin)
_pillar_centralize_check = CheckBox.new()
_pillar_centralize_check.text = "Center"
_pillar_centralize_check.tooltip_text = "Align the pillar with the nearest axis of the first vertex’s piece while keeping it centered between the points."
_pillar_centralize_check.visible = false
_pillar_centralize_check.toggled.connect(_on_pillar_centralize_toggled)
_detail_options_row.add_child(_pillar_centralize_check)
_detail_height_offset_label = _label("Starting height:")
_detail_options_row.add_child(_detail_height_offset_label)
detail_height_offset_spin = _detail_spin(0.0, -2.0, 2.0, 0.001)
detail_height_offset_spin.suffix = " m"
_detail_options_row.add_child(detail_height_offset_spin)
detail_mode_option = OptionButton.new()
detail_mode_option.add_item("Opening", ModularBuilderDetail.DetailMode.OPENING)
detail_mode_option.add_item("Opening + object", ModularBuilderDetail.DetailMode.SEPARATE_OBJECT)
detail_mode_option.select(0)
detail_mode_option.tooltip_text = "Choose an opening alone or an opening with a separately textured object."
_detail_options_row.add_child(detail_mode_option)
detail_style_option = OptionButton.new()
detail_style_option.tooltip_text = "Window or door style."
detail_style_option.item_selected.connect(_on_detail_style_selected)
_detail_options_row.add_child(detail_style_option)
detail_width_spin.value_changed.connect(Callable(self, "_refresh_detail_preview"))
detail_height_spin.value_changed.connect(Callable(self, "_refresh_detail_preview"))
detail_height_offset_spin.value_changed.connect(Callable(self, "_refresh_detail_preview"))
detail_mode_option.item_selected.connect(_on_detail_mode_selected)
_detail_options_row.visible = false
_preview_material = StandardMaterial3D.new()
_preview_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_preview_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_preview_material.albedo_color = Color(1.0, 0.35, 0.72, 0.48)
_save_module_dialog = FileDialog.new()
_save_module_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
_save_module_dialog.access = FileDialog.ACCESS_RESOURCES
_save_module_dialog.add_filter("*.tscn", "Godot Scene")
_save_module_dialog.file_selected.connect(Callable(self, "_save_module_to_path"))
add_child(_save_module_dialog)
_load_module_dialog = FileDialog.new()
_load_module_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_load_module_dialog.access = FileDialog.ACCESS_RESOURCES
_load_module_dialog.add_filter("*.tscn", "Godot Scene")
_load_module_dialog.file_selected.connect(Callable(self, "_load_module_from_path"))
add_child(_load_module_dialog)
_export_scene_dialog = FileDialog.new()
_export_scene_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
_export_scene_dialog.access = FileDialog.ACCESS_RESOURCES
_export_scene_dialog.add_filter("*.tscn", "Godot Scene")
_export_scene_dialog.file_selected.connect(Callable(self, "_export_baked_scene_to_path"))
add_child(_export_scene_dialog)
status_label = _label("Select a tool to show the vertices.")
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
dock.add_child(status_label)
add_control_to_bottom_panel(dock, "Modular Builder")
set_input_event_forwarding_always_enabled()
get_editor_interface().get_selection().selection_changed.connect(Callable(self, "_on_editor_selection_changed"))
var inspector := get_editor_interface().get_inspector()
if not inspector.property_edited.is_connected(_on_inspector_property_edited):
inspector.property_edited.connect(_on_inspector_property_edited)
func _exit_tree() -> void:
set_process(false)
var editor_selection := get_editor_interface().get_selection()
if editor_selection.selection_changed.is_connected(Callable(self, "_on_editor_selection_changed")):
editor_selection.selection_changed.disconnect(Callable(self, "_on_editor_selection_changed"))
var inspector := get_editor_interface().get_inspector()
if inspector.property_edited.is_connected(_on_inspector_property_edited):
inspector.property_edited.disconnect(_on_inspector_property_edited)
if dock:
remove_control_from_bottom_panel(dock)
dock.queue_free()
call("_clear_marker_container")
call("_clear_detail_preview")
update_overlays()
func _handles(object: Object) -> bool:
return object is ModularBuilderPiece
func _label(text: String) -> Label:
var label := Label.new()
label.text = text
return label
func _detail_spin(value: float, minimum: float, maximum: float, step: float) -> SpinBox:
var spin := SpinBox.new()
spin.min_value = minimum
spin.max_value = maximum
spin.step = step
spin.value = value
spin.suffix = " m"
spin.custom_minimum_size = Vector2(84, 0)
return spin
func _make_icon_button(icon_path: String, tooltip: String, color: Color) -> Button:
var button := Button.new()
button.icon = load(icon_path) as Texture2D
button.tooltip_text = tooltip
button.custom_minimum_size = Vector2(48, 48)
button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
button.expand_icon = true
button.add_theme_constant_override("icon_max_width", 30)
var normal := StyleBoxFlat.new()
normal.bg_color = color
normal.set_corner_radius_all(4)
normal.set_border_width_all(1)
normal.border_color = Color(0.02, 0.02, 0.02)
button.add_theme_stylebox_override("normal", normal)
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = color.lightened(0.16)
button.add_theme_stylebox_override("hover", hover)
var pressed := normal.duplicate() as StyleBoxFlat
pressed.bg_color = color.lightened(0.28)
pressed.border_width_bottom = 3
button.add_theme_stylebox_override("pressed", pressed)
return button
func _add_tool_button(icon_path: String, tooltip: String, tool: String, color: Color, target_row: Container = null) -> void:
var button := _make_icon_button(icon_path, tooltip, color)
button.toggle_mode = true
button.button_group = _tool_button_group
button.toggled.connect(_on_tool_button_toggled.bind(tool))
(_tool_row if target_row == null else target_row).add_child(button)
_tool_buttons[tool] = button
func _on_tool_button_toggled(pressed: bool, tool: String) -> void:
var is_foundation := tool == TOOL_FOUNDATION_SQUARE or tool == TOOL_FOUNDATION_TRIANGLE
if pressed:
_active_button_key = tool
if is_foundation:
_foundation_type_selected = ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE if tool == TOOL_FOUNDATION_TRIANGLE else ModularBuilderPiece.PieceType.FOUNDATION_SQUARE
call("_set_tool", TOOL_FOUNDATION)
else:
call("_set_tool", tool)
_update_detail_options(tool)
else:
call_deferred("_finish_tool_button_toggle_off", tool)
func _finish_tool_button_toggle_off(tool: String) -> void:
var button := _tool_buttons.get(tool) as Button
if button != null and not button.button_pressed and _active_button_key == tool:
call("_set_tool", TOOL_NONE)
func _update_detail_options(tool: String) -> void:
var is_detail := _is_detail_tool(tool)
var is_fence := tool == TOOL_FENCE or tool == TOOL_PARAPET
_detail_options_row.visible = is_detail
detail_width_spin.visible = is_detail and not is_fence
_detail_width_label.visible = is_detail and not is_fence
detail_height_spin.visible = is_detail and not is_fence
_detail_height_label.visible = is_detail and not is_fence
detail_height_offset_spin.visible = tool != TOOL_HATCH and tool != TOOL_PILLAR and not is_fence
_detail_height_offset_label.visible = detail_height_offset_spin.visible
detail_mode_option.visible = tool != TOOL_PILLAR and not is_fence
_detail_width_label.text = "Side:" if tool == TOOL_PILLAR else "Width:"
_detail_height_label.text = "Length:" if tool == TOOL_PILLAR else "Height:"
_linear_options_row.visible = tool == TOOL_STAIRS or is_fence
_stair_count_label.visible = tool == TOOL_STAIRS
stair_count_spin.visible = tool == TOOL_STAIRS
_stair_width_label.visible = tool == TOOL_STAIRS
stair_width_spin.visible = tool == TOOL_STAIRS
_fence_divisions_label.visible = is_fence
fence_divisions_spin.visible = is_fence
_fence_height_label.visible = is_fence
fence_height_spin.visible = is_fence
if not is_detail:
return
var width := 1.0
var height := 1.0
var bottom_offset := 0.5
if tool == TOOL_PILLAR:
width = 0.2
height = 2.0
bottom_offset = 0.0
elif tool == TOOL_DOOR:
width = DOOR_DEFAULT_WIDTH_METERS
height = DOOR_DEFAULT_HEIGHT_METERS
bottom_offset = 0.0
elif tool == TOOL_HATCH:
bottom_offset = 0.0
detail_width_spin.set_value_no_signal(width)
detail_height_spin.set_value_no_signal(height)
detail_height_offset_spin.set_value_no_signal(bottom_offset)
detail_style_option.clear()
detail_style_option.visible = tool == TOOL_WINDOW or tool == TOOL_DOOR
if tool == TOOL_WINDOW:
detail_style_option.add_item("Single pane", ModularBuilderDetail.WindowStyle.SINGLE_PANE)
detail_style_option.add_item("Cross pane", ModularBuilderDetail.WindowStyle.CROSS_PANE)
detail_style_option.select(_selected_window_style)
elif tool == TOOL_DOOR:
detail_style_option.add_item("Flat", ModularBuilderDetail.DoorStyle.FLAT)
detail_style_option.add_item("Paneled", ModularBuilderDetail.DoorStyle.PANELED)
detail_style_option.select(_selected_door_style)
call("_refresh_detail_preview")
func _on_detail_style_selected(index: int) -> void:
if active_tool == TOOL_WINDOW:
_selected_window_style = index
if is_instance_valid(_detail_preview):
_detail_preview.window_style = index
elif active_tool == TOOL_DOOR:
_selected_door_style = index
if is_instance_valid(_detail_preview):
_detail_preview.door_style = index
call("_refresh_detail_preview")
func _on_detail_mode_selected(_index: int) -> void:
call("_refresh_detail_preview")
func _on_pillar_centralize_toggled(enabled: bool) -> void:
_pillar_centralize_default = enabled
var selected_pillar := _get_selected_pillar_detail()
if selected_pillar != null:
selected_pillar.center_align = enabled
if is_instance_valid(_detail_preview) and _detail_preview.detail_type == ModularBuilderDetail.DetailType.PILLAR:
_detail_preview.center_align = enabled
call("_refresh_detail_preview")
func _on_gable_centralize_toggled(enabled: bool) -> void:
_gable_centralize_default = enabled
var selected := _get_selected_gable_wall()
if selected != null:
selected.center_align = enabled
func _sync_gable_centralize_control() -> void:
if _gable_centralize_check == null:
return
var selected := _get_selected_gable_wall()
var show_option := active_tool == TOOL_WALL or active_tool == TOOL_GABLE_WALL or active_tool == TOOL_GABLE_WALL_RIGHT or selected != null
var show_rise := active_tool == TOOL_GABLE_WALL or active_tool == TOOL_GABLE_WALL_RIGHT or active_tool == TOOL_ROOF_GABLE or active_tool == TOOL_ROOF_PYRAMID
_rise_options_row.visible = show_rise or selected != null
_rise_label.visible = show_rise
rise_spin.visible = show_rise
_gable_centralize_check.visible = show_option
_gable_centralize_check.text = "Center walls" if active_tool == TOOL_WALL or (selected != null and selected.piece_type == ModularBuilderPiece.PieceType.WALL) else "Center"
_gable_centralize_check.tooltip_text = "Align wall endpoints with the centerlines of supporting walls." if active_tool == TOOL_WALL or (selected != null and selected.piece_type == ModularBuilderPiece.PieceType.WALL) else "Align the gable with the center of the supporting wall thickness."
if selected != null:
_gable_centralize_default = selected.center_align
_gable_centralize_check.set_pressed_no_signal(selected.center_align)
else:
_gable_centralize_check.set_pressed_no_signal(_gable_centralize_default)
func _get_selected_gable_wall() -> ModularBuilderPiece:
var selected := get_editor_interface().get_selection().get_selected_nodes()
if selected.size() == 1 and selected[0] is ModularBuilderPiece and (selected[0].piece_type == ModularBuilderPiece.PieceType.WALL or selected[0].piece_type == ModularBuilderPiece.PieceType.GABLE_WALL or selected[0].piece_type == ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT):
return selected[0] as ModularBuilderPiece
return null
func _sync_pillar_centralize_control() -> void:
if _pillar_centralize_check == null:
return
var selected_pillar := _get_selected_pillar_detail()
var show_pillar_options := active_tool == TOOL_PILLAR or selected_pillar != null
_detail_options_row.visible = _is_detail_tool(active_tool) or show_pillar_options
_pillar_centralize_check.visible = show_pillar_options
if selected_pillar != null and active_tool != TOOL_PILLAR and not _is_detail_tool(active_tool):
_detail_width_label.visible = false
detail_width_spin.visible = false
_detail_height_label.visible = false
detail_height_spin.visible = false
_detail_height_offset_label.visible = false
detail_height_offset_spin.visible = false
detail_mode_option.visible = false
detail_style_option.visible = false
elif active_tool == TOOL_PILLAR:
_detail_width_label.visible = true
detail_width_spin.visible = true
_detail_height_label.visible = true
detail_height_spin.visible = true
if selected_pillar != null:
_pillar_centralize_default = selected_pillar.center_align
_pillar_centralize_check.set_pressed_no_signal(selected_pillar.center_align)
else:
_pillar_centralize_check.set_pressed_no_signal(_pillar_centralize_default)
func _get_selected_pillar_detail() -> ModularBuilderDetail:
var selected := get_editor_interface().get_selection().get_selected_nodes()
if selected.size() != 1 or not selected[0] is ModularBuilderDetail:
return null
var detail := selected[0] as ModularBuilderDetail
return detail if detail.detail_type == ModularBuilderDetail.DetailType.PILLAR else null
func _on_inspector_property_edited(property: String) -> void:
if property == "center_align":
_sync_pillar_centralize_control()
func _is_detail_tool(tool: String) -> bool:
return tool == TOOL_WINDOW or tool == TOOL_DOOR or tool == TOOL_HATCH or tool == TOOL_PILLAR or tool == TOOL_FENCE or tool == TOOL_PARAPET

View file

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

View file

@ -1,727 +0,0 @@
@tool
extends "res://addons/modular_builder/modular_builder_editor_details.gd"
const MESH_PICKER = preload("res://addons/modular_builder/modular_mesh_picker.gd")
func _sync_vertex_markers() -> void:
var root := _get_selected_piece()
var vertex_points: Array[Vector3] = []
var side_points: Array[Dictionary] = []
if root != null:
if active_tool == TOOL_WALL or active_tool == TOOL_GABLE_WALL or active_tool == TOOL_GABLE_WALL_RIGHT or active_tool == TOOL_FLOOR_SQUARE or active_tool == TOOL_FLOOR_TRIANGLE or active_tool == TOOL_ROOF_GABLE or active_tool == TOOL_ROOF_SHED or active_tool == TOOL_ROOF_PYRAMID or active_tool == TOOL_STAIRS or active_tool == TOOL_FENCE or active_tool == TOOL_PILLAR or active_tool == TOOL_MERGE_VERTICES or active_tool == TOOL_CREATE_VERTEX or active_tool == TOOL_MOVE_VERTEX or active_tool == TOOL_REMOVE_VERTEX:
vertex_points = _get_tool_vertices(root)
elif active_tool == TOOL_FOUNDATION:
side_points = _get_side_button_points(root)
if root != _marker_root or (root == null and _marker_container != null) or vertex_points.size() != _marker_point_nodes.size() or side_points.size() != _side_button_nodes.size():
_clear_marker_container()
_marker_root = root
if root == null:
return
_marker_container = Node3D.new()
_marker_container.name = "ModularBuilderMarkers"
root.add_child(_marker_container)
for _point in vertex_points:
var marker := Label3D.new()
marker.text = "●"
marker.font_size = 56
marker.pixel_size = 0.004
marker.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
marker.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
marker.billboard = BaseMaterial3D.BILLBOARD_ENABLED
marker.no_depth_test = true
marker.modulate = Color(0.05, 0.58, 1.0)
marker.outline_size = 8
marker.outline_modulate = Color.BLACK
_marker_container.add_child(marker)
_marker_point_nodes.append(marker)
for _button in side_points:
var label := Label3D.new()
label.text = "+"
label.font_size = 56
label.outline_size = 8
label.outline_modulate = Color.BLACK
label.pixel_size = 0.01
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.no_depth_test = true
label.modulate = Color(0.2, 1.0, 0.55)
_marker_container.add_child(label)
_side_button_nodes.append(label)
for i in range(vertex_points.size()):
_marker_point_nodes[i].position = root.to_local(vertex_points[i])
var is_pending := false
for pending_point in pending_points:
if pending_point.distance_to(vertex_points[i]) < 0.025:
is_pending = true
break
_marker_point_nodes[i].modulate = Color(1.0, 0.88, 0.08) if is_pending else Color(0.05, 0.58, 1.0)
for i in range(side_points.size()):
_side_button_nodes[i].position = root.to_local(side_points[i].position)
if _marker_container:
_marker_container.visible = active_tool != TOOL_NONE or not side_points.is_empty()
func _clear_marker_container() -> void:
if is_instance_valid(_marker_container):
_marker_container.queue_free()
_marker_container = null
_marker_root = null
_marker_point_nodes.clear()
_side_button_nodes.clear()
func _get_side_button_points(root: ModularBuilderPiece) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for foundation in _get_foundations(root):
var top_y := foundation.thickness * 0.5 if foundation.centered_origin else foundation.thickness
var y := top_y + 0.22
var points := _foundation_polygon(foundation)
var centroid := Vector2.ZERO
for point in points:
centroid += point
centroid /= points.size()
for i in range(points.size()):
var a: Vector2 = points[i]
var b: Vector2 = points[(i + 1) % points.size()]
if _foundation_edge_is_occupied(root, foundation, a, b):
continue
var midpoint := (a + b) * 0.5
var outward := (midpoint - centroid).normalized()
var edge_length := a.distance_to(b)
var neighbor_polygon: Array[Vector2] = []
if _foundation_type_selected == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE:
var square_side := minf(foundation.unit_size, edge_length)
var edge_direction := (b - a).normalized()
var square_a := midpoint - edge_direction * square_side * 0.5
var square_b := midpoint + edge_direction * square_side * 0.5
neighbor_polygon = [square_a, square_b, square_b + outward * square_side, square_a + outward * square_side]
elif points.size() == 3:
for source_point in points:
neighbor_polygon.append(_reflect_point_across_edge(source_point, a, b))
else:
neighbor_polygon = [a, b, a + outward * edge_length]
var custom_points := PackedVector3Array()
for neighbor_point in neighbor_polygon:
var base_y := -foundation.thickness * 0.5 if foundation.centered_origin else 0.0
var foundation_local := Vector3(neighbor_point.x, base_y, neighbor_point.y)
custom_points.append(root.to_local(foundation.to_global(foundation_local)))
result.append({
"position": foundation.to_global(Vector3(midpoint.x + outward.x * 0.55, y, midpoint.y + outward.y * 0.55)),
"custom_points": custom_points,
"foundation": foundation
})
return result
func _foundation_edge_is_occupied(root: ModularBuilderPiece, foundation: ModularBuilderPiece, edge_a: Vector2, edge_b: Vector2) -> bool:
var foundation_base_y := -foundation.thickness * 0.5 if foundation.centered_origin else 0.0
var edge_a_3d := root.to_local(foundation.to_global(Vector3(edge_a.x, foundation_base_y, edge_a.y)))
var edge_b_3d := root.to_local(foundation.to_global(Vector3(edge_b.x, foundation_base_y, edge_b.y)))
var target_a := Vector2(edge_a_3d.x, edge_a_3d.z)
var target_b := Vector2(edge_b_3d.x, edge_b_3d.z)
for other in _get_foundations(root):
if other == foundation:
continue
var other_polygon := _foundation_polygon(other)
for i in range(other_polygon.size()):
var a := other_polygon[i]
var b := other_polygon[(i + 1) % other_polygon.size()]
var other_base_y := -other.thickness * 0.5 if other.centered_origin else 0.0
var a_in_root := root.to_local(other.to_global(Vector3(a.x, other_base_y, a.y)))
var b_in_root := root.to_local(other.to_global(Vector3(b.x, other_base_y, b.y)))
var other_a := Vector2(a_in_root.x, a_in_root.z)
var other_b := Vector2(b_in_root.x, b_in_root.z)
if absf(a_in_root.y - edge_a_3d.y) < 0.02 and absf(b_in_root.y - edge_b_3d.y) < 0.02 and _segments_overlap(target_a, target_b, other_a, other_b):
return true
return false
func _get_foundations(root: ModularBuilderPiece) -> Array[ModularBuilderPiece]:
var result: Array[ModularBuilderPiece] = []
if root.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE or root.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE:
result.append(root)
for child in root.get_children():
_collect_foundations(child, result)
return result
func _collect_foundations(node: Node, result: Array[ModularBuilderPiece]) -> void:
if node is ModularBuilderPiece and (node.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE or node.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE):
result.append(node)
for child in node.get_children():
_collect_foundations(child, result)
func _reflect_point_across_edge(point: Vector2, a: Vector2, b: Vector2) -> Vector2:
var edge := b - a
var projection := a + edge * ((point - a).dot(edge) / edge.length_squared())
return projection * 2.0 - point
func _nearest_side_button(camera: Camera3D, screen_position: Vector2) -> int:
var root := _get_selected_piece()
if root == null:
return -1
var buttons := _get_side_button_points(root)
var closest := MARKER_HIT_RADIUS + 5.0
var result := -1
for i in range(buttons.size()):
var point: Vector3 = buttons[i].position
if camera.is_position_behind(point):
continue
var distance := camera.unproject_position(point).distance_to(screen_position)
if distance < closest:
closest = distance
result = i
return result
func _add_foundation_at_side(root: ModularBuilderPiece, side: int) -> void:
var buttons := _get_side_button_points(root)
if side < 0 or side >= buttons.size():
return
var scene_root := get_editor_interface().get_edited_scene_root() as Node3D
var piece := Node3D.new()
piece.name = "Foundation"
piece.set_script(PIECE_SCRIPT)
var source_foundation: ModularBuilderPiece = buttons[side].foundation
piece.set("unit_size", source_foundation.unit_size)
piece.set("thickness", source_foundation.thickness)
piece.set("centered_origin", true)
piece.set("piece_type", _foundation_type_selected)
var points_in_root: PackedVector3Array = buttons[side].custom_points
var pivot := _packed_points_bounds_center(points_in_root)
pivot.y += source_foundation.thickness * 0.5
var custom_points := PackedVector3Array()
for point in points_in_root:
custom_points.append(point - pivot)
piece.position = pivot
var undo := get_undo_redo()
undo.create_action("Add foundation to selected side")
undo.add_do_method(root, "add_child", piece, true)
undo.add_do_property(piece, "custom_points", custom_points)
undo.add_do_property(piece, "owner", scene_root)
undo.add_undo_method(root, "remove_child", piece)
undo.commit_action()
_last_created_piece = piece
status_label.text = "Foundation added to structure."
func _packed_points_bounds_center(points: PackedVector3Array) -> Vector3:
if points.is_empty():
return Vector3.ZERO
var minimum := points[0]
var maximum := points[0]
for point in points:
minimum = minimum.min(point)
maximum = maximum.max(point)
return (minimum + maximum) * 0.5
func _get_tool_vertices(root: ModularBuilderPiece) -> Array[Vector3]:
var result: Array[Vector3] = []
if active_tool == TOOL_MERGE_VERTICES:
_collect_all_connection_vertices(root, result)
elif active_tool == TOOL_CREATE_VERTEX or active_tool == TOOL_MOVE_VERTEX or active_tool == TOOL_REMOVE_VERTEX:
_collect_all_connection_vertices(root, result)
elif active_tool == TOOL_STAIRS:
_collect_stair_vertices(root, result)
elif active_tool == TOOL_FENCE or active_tool == TOOL_PARAPET:
_collect_fence_vertices(root, result)
elif active_tool == TOOL_PILLAR:
var surfaces: Array[ModularBuilderPiece] = []
_collect_surface_pieces(root, surfaces)
for piece in surfaces:
if piece.piece_type == ModularBuilderPiece.PieceType.BUILDING:
continue
var polygon := _piece_polygon_xz(piece) if piece.piece_type == ModularBuilderPiece.PieceType.FLOOR else _foundation_polygon(piece)
var top_y := piece.custom_points[0].y if piece.piece_type == ModularBuilderPiece.PieceType.FLOOR and not piece.custom_points.is_empty() else (piece.thickness * 0.5 if piece.centered_origin else piece.thickness)
var bottom_y := top_y - piece.thickness
for point in polygon:
_add_unique_vertex(result, piece.to_global(Vector3(point.x, top_y, point.y)))
_add_unique_vertex(result, piece.to_global(Vector3(point.x, bottom_y, point.y)))
var walls: Array[Node] = []
_collect_walls(root, walls)
for wall_node in walls:
var wall := wall_node as ModularBuilderPiece
var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points
if endpoints.size() < 2:
continue
var direction := (endpoints[1] - endpoints[0]).normalized()
var side := Vector3(-direction.z, 0.0, direction.x).normalized() * wall.thickness * 0.5
for endpoint in endpoints:
for offset in [Vector3.ZERO, Vector3.UP * wall.unit_size]:
_add_unique_vertex(result, wall.to_global(endpoint + offset + side))
_add_unique_vertex(result, wall.to_global(endpoint + offset - side))
_add_gable_wall_vertices(root, result)
elif active_tool == TOOL_WALL:
for foundation in _get_foundations(root):
var top_y := foundation.thickness * 0.5 if foundation.centered_origin else foundation.thickness
for point in _foundation_polygon(foundation):
_add_unique_vertex(result, foundation.to_global(Vector3(point.x, top_y, point.y)))
var surfaces: Array[ModularBuilderPiece] = []
_collect_surface_pieces(root, surfaces)
for surface in surfaces:
if surface.piece_type != ModularBuilderPiece.PieceType.FLOOR:
continue
for point in surface.custom_points:
_add_unique_vertex(result, surface.to_global(point))
var existing_walls: Array[Node] = []
_collect_walls(root, existing_walls)
for wall_node in existing_walls:
var wall := wall_node as ModularBuilderPiece
var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points
if endpoints.size() < 2:
continue
var direction := (endpoints[1] - endpoints[0]).normalized()
var side := Vector3(-direction.z, 0.0, direction.x) * wall.thickness * 0.5
for endpoint in endpoints:
var top := endpoint + Vector3.UP * wall.unit_size
_add_unique_vertex(result, wall.to_global(top + side))
_add_unique_vertex(result, wall.to_global(top - side))
_add_gable_wall_vertices(root, result)
else:
var walls: Array[Node] = []
_collect_walls(root, walls)
for wall_node in walls:
var wall := wall_node as ModularBuilderPiece
var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points
if endpoints.size() < 2:
continue
var direction := (endpoints[1] - endpoints[0]).normalized()
var side := Vector3(-direction.z, 0.0, direction.x).normalized() * float(wall.get("thickness")) * 0.5
for endpoint in endpoints:
if active_tool == TOOL_ROOF_GABLE or active_tool == TOOL_ROOF_SHED:
_add_unique_vertex(result, wall.to_global(endpoint + side))
_add_unique_vertex(result, wall.to_global(endpoint - side))
var top := endpoint + Vector3.UP * float(wall.get("unit_size"))
_add_unique_vertex(result, wall.to_global(top + side))
_add_unique_vertex(result, wall.to_global(top - side))
_add_gable_wall_vertices(root, result)
_collect_detail_connection_vertices(root, result)
for manual_vertex in _get_manual_vertices(root):
_add_unique_vertex(result, manual_vertex)
_collapse_merged_vertex_groups(root, result)
if _marker_scope_check != null and _marker_scope_check.button_pressed:
var scope_pieces := _get_marker_scope_pieces(root)
var scope_vertices: Array[Vector3] = []
for piece in scope_pieces:
_collect_piece_geometry_vertices(piece, scope_vertices)
for manual_vertex in _get_manual_vertices(piece):
_add_unique_vertex(scope_vertices, manual_vertex)
for selected_node in get_editor_interface().get_selection().get_selected_nodes():
if selected_node is ModularBuilderDetail:
_collect_detail_connection_vertices(selected_node, scope_vertices)
_collapse_merged_vertex_groups(root, scope_vertices)
var filtered: Array[Vector3] = []
for vertex in result:
for scope_vertex in scope_vertices:
if vertex.distance_to(scope_vertex) < 0.025:
_add_unique_vertex(filtered, vertex)
break
result = filtered
if _simplify_vertices_check != null and _simplify_vertices_check.button_pressed and active_tool != TOOL_MERGE_VERTICES and active_tool != TOOL_CREATE_VERTEX and active_tool != TOOL_MOVE_VERTEX and active_tool != TOOL_REMOVE_VERTEX:
result = _simplify_tool_vertices(root, result)
return result
func _simplify_tool_vertices(root: ModularBuilderPiece, vertices: Array[Vector3]) -> Array[Vector3]:
var replacements := {}
var pieces: Array[ModularBuilderPiece] = []
_collect_all_modular_pieces(root, pieces)
for piece in pieces:
if piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_SQUARE and piece.piece_type != ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE and piece.piece_type != ModularBuilderPiece.PieceType.FLOOR and piece.piece_type != ModularBuilderPiece.PieceType.WALL and piece.piece_type != ModularBuilderPiece.PieceType.GABLE_WALL and piece.piece_type != ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT:
continue
var piece_vertices: Array[Vector3] = []
_collect_piece_geometry_vertices(piece, piece_vertices)
for original in piece_vertices:
var key := _vertex_position_key(original)
if not replacements.has(key):
replacements[key] = VERTEX_SIMPLIFIER.piece_vertex(piece, original)
_add_pillar_simplification_candidates(root, replacements)
var result: Array[Vector3] = []
for original in vertices:
_add_unique_vertex(result, replacements.get(_vertex_position_key(original), original))
return result
func _add_pillar_simplification_candidates(node: Node, replacements: Dictionary) -> void:
if node is ModularBuilderDetail:
var detail := node as ModularBuilderDetail
if detail.detail_type == ModularBuilderDetail.DetailType.PILLAR:
var half := detail.width * 0.5
for x in [-half, half]:
for y in [-detail.height * 0.5, detail.height * 0.5]:
for z in [-half, half]:
var original := detail.to_global(Vector3(x, y, z))
var key := _vertex_position_key(original)
if not replacements.has(key):
replacements[key] = VERTEX_SIMPLIFIER.pillar_vertex(detail, original)
for child in node.get_children():
_add_pillar_simplification_candidates(child, replacements)
func _vertex_position_key(point: Vector3) -> Vector3i:
return Vector3i(roundi(point.x * 1000.0), roundi(point.y * 1000.0), roundi(point.z * 1000.0))
func _collect_stair_vertices(root: ModularBuilderPiece, vertices: Array[Vector3]) -> void:
var surfaces: Array[ModularBuilderPiece] = []
_collect_surface_pieces(root, surfaces)
for surface in surfaces:
var polygon := _piece_polygon_xz(surface) if surface.piece_type == ModularBuilderPiece.PieceType.FLOOR else _foundation_polygon(surface)
var y := surface.thickness * 0.5 if surface.centered_origin else surface.thickness
for point in polygon:
_add_unique_vertex(vertices, surface.to_global(Vector3(point.x, y, point.y)))
var walls: Array[Node] = []
_collect_walls(root, walls)
for wall_node in walls:
var wall := wall_node as ModularBuilderPiece
var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points
if endpoints.size() < 2:
continue
var direction := (endpoints[1] - endpoints[0]).normalized()
var side := Vector3(-direction.z, 0.0, direction.x) * wall.thickness * 0.5
for endpoint in endpoints:
var top := endpoint + Vector3.UP * wall.unit_size
_add_unique_vertex(vertices, wall.to_global(top + side))
_add_unique_vertex(vertices, wall.to_global(top - side))
_add_gable_wall_vertices(root, vertices)
func _collect_fence_vertices(root: ModularBuilderPiece, vertices: Array[Vector3]) -> void:
var surfaces: Array[ModularBuilderPiece] = []
_collect_surface_pieces(root, surfaces)
for surface in surfaces:
var polygon := _piece_polygon_xz(surface) if surface.piece_type == ModularBuilderPiece.PieceType.FLOOR else _foundation_polygon(surface)
var top_y := surface.thickness * 0.5 if surface.centered_origin else surface.thickness
for point in polygon:
_add_unique_vertex(vertices, surface.to_global(Vector3(point.x, top_y, point.y)))
var walls: Array[Node] = []
_collect_walls(root, walls)
for wall_node in walls:
var wall := wall_node as ModularBuilderPiece
var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points
if endpoints.size() < 2:
continue
var direction := (endpoints[1] - endpoints[0]).normalized()
var side := Vector3(-direction.z, 0.0, direction.x) * wall.thickness * 0.5
for endpoint in endpoints:
for height in [0.0, wall.unit_size]:
_add_unique_vertex(vertices, wall.to_global(endpoint + Vector3.UP * height + side))
_add_unique_vertex(vertices, wall.to_global(endpoint + Vector3.UP * height - side))
_add_gable_wall_vertices(root, vertices)
func _get_marker_scope_pieces(root: ModularBuilderPiece) -> Array[ModularBuilderPiece]:
var result: Array[ModularBuilderPiece] = []
var selected := get_editor_interface().get_selection().get_selected_nodes()
for selected_node in selected:
var piece := selected_node as ModularBuilderPiece
if piece == null and selected_node is ModularBuilderDetail:
var parent := selected_node.get_parent()
while parent != null and not parent is ModularBuilderPiece:
parent = parent.get_parent()
piece = parent as ModularBuilderPiece
if piece == root:
_collect_all_modular_pieces(root, result)
return result
if piece != null and _is_descendant_of_piece(piece, root):
if not result.has(piece):
result.append(piece)
return result
func _collect_all_modular_pieces(node: Node, result: Array[ModularBuilderPiece]) -> void:
if node is ModularBuilderPiece and not result.has(node):
result.append(node)
for child in node.get_children():
_collect_all_modular_pieces(child, result)
func _is_descendant_of_piece(node: Node, ancestor: Node) -> bool:
var current := node.get_parent()
while current != null:
if current == ancestor:
return true
current = current.get_parent()
return false
func _collect_piece_geometry_vertices(piece: ModularBuilderPiece, vertices: Array[Vector3]) -> void:
if piece.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_SQUARE or piece.piece_type == ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE:
var top_y := piece.thickness * 0.5 if piece.centered_origin else piece.thickness
var bottom_y := -piece.thickness * 0.5 if piece.centered_origin else 0.0
for point in _foundation_polygon(piece):
_add_unique_vertex(vertices, piece.to_global(Vector3(point.x, top_y, point.y)))
_add_unique_vertex(vertices, piece.to_global(Vector3(point.x, bottom_y, point.y)))
elif piece.piece_type == ModularBuilderPiece.PieceType.FLOOR:
for point in piece.custom_points:
_add_unique_vertex(vertices, piece.to_global(point))
_add_unique_vertex(vertices, piece.to_global(point - Vector3.UP * piece.thickness))
elif piece.piece_type == ModularBuilderPiece.PieceType.WALL:
var endpoints: PackedVector3Array = piece.call("_get_centered_wall_points") if piece.center_align else piece.custom_points
if endpoints.size() < 2:
return
var direction := (endpoints[1] - endpoints[0]).normalized()
var side := Vector3(-direction.z, 0.0, direction.x) * piece.thickness * 0.5
for endpoint in endpoints:
for height in [0.0, piece.unit_size]:
_add_unique_vertex(vertices, piece.to_global(endpoint + Vector3.UP * height + side))
_add_unique_vertex(vertices, piece.to_global(endpoint + Vector3.UP * height - side))
elif piece.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL or piece.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT:
_add_single_gable_wall_vertices(piece, vertices)
elif piece.piece_type == ModularBuilderPiece.PieceType.ROOF_GABLE or piece.piece_type == ModularBuilderPiece.PieceType.ROOF_SHED or piece.piece_type == ModularBuilderPiece.PieceType.ROOF_PYRAMID:
for point in piece.custom_points:
_add_unique_vertex(vertices, piece.to_global(point))
if piece.piece_type == ModularBuilderPiece.PieceType.ROOF_PYRAMID and piece.custom_points.size() >= 4:
var base_center := (piece.custom_points[0] + piece.custom_points[1] + piece.custom_points[2] + piece.custom_points[3]) * 0.25
_add_unique_vertex(vertices, piece.to_global(base_center + Vector3.UP * piece.rise))
func _collect_all_connection_vertices(root: ModularBuilderPiece, vertices: Array[Vector3]) -> void:
var surfaces: Array[ModularBuilderPiece] = []
_collect_surface_pieces(root, surfaces)
for piece in surfaces:
var polygon := _piece_polygon_xz(piece) if piece.piece_type == ModularBuilderPiece.PieceType.FLOOR else _foundation_polygon(piece)
var top_y := piece.thickness * 0.5 if piece.centered_origin else piece.thickness
var bottom_y := -piece.thickness * 0.5 if piece.centered_origin else 0.0
for point in polygon:
_add_unique_vertex(vertices, piece.to_global(Vector3(point.x, top_y, point.y)))
_add_unique_vertex(vertices, piece.to_global(Vector3(point.x, bottom_y, point.y)))
var walls: Array[Node] = []
_collect_walls(root, walls)
for wall_node in walls:
var wall := wall_node as ModularBuilderPiece
var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points
if endpoints.size() < 2:
continue
var direction := (endpoints[1] - endpoints[0]).normalized()
var side := Vector3(-direction.z, 0.0, direction.x) * wall.thickness * 0.5
for endpoint in endpoints:
for height in [0.0, wall.unit_size]:
_add_unique_vertex(vertices, wall.to_global(endpoint + Vector3.UP * height + side))
_add_unique_vertex(vertices, wall.to_global(endpoint + Vector3.UP * height - side))
_add_gable_wall_vertices(root, vertices)
_collect_detail_connection_vertices(root, vertices)
var roofs: Array[ModularBuilderPiece] = []
_collect_roof_pieces(root, roofs)
for roof in roofs:
for point in roof.custom_points:
_add_unique_vertex(vertices, roof.to_global(point))
if roof.piece_type == ModularBuilderPiece.PieceType.ROOF_PYRAMID and roof.custom_points.size() >= 4:
var base_center := (roof.custom_points[0] + roof.custom_points[1] + roof.custom_points[2] + roof.custom_points[3]) * 0.25
_add_unique_vertex(vertices, roof.to_global(base_center + Vector3.UP * roof.rise))
func _collect_roof_pieces(node: Node, roofs: Array[ModularBuilderPiece]) -> void:
if node is ModularBuilderPiece and (node.piece_type == ModularBuilderPiece.PieceType.ROOF_GABLE or node.piece_type == ModularBuilderPiece.PieceType.ROOF_SHED or node.piece_type == ModularBuilderPiece.PieceType.ROOF_PYRAMID):
roofs.append(node)
for child in node.get_children():
_collect_roof_pieces(child, roofs)
func _collect_detail_connection_vertices(node: Node, vertices: Array[Vector3]) -> void:
if node is ModularBuilderDetail:
var detail := node as ModularBuilderDetail
if detail.detail_type == ModularBuilderDetail.DetailType.PILLAR:
var half := detail.width * 0.5
for x in [-half, half]:
for y in [-detail.height * 0.5, detail.height * 0.5]:
for z in [-half, half]:
_add_unique_vertex(vertices, detail.to_global(Vector3(x, y, z)))
elif (detail.detail_type == ModularBuilderDetail.DetailType.FENCE or detail.detail_type == ModularBuilderDetail.DetailType.PARAPET) and detail.path_points.size() >= 2:
for point in detail.path_points:
_add_unique_vertex(vertices, detail.to_global(point))
for local_point in detail.get_meta(CUSTOM_VERTICES_META, []):
_add_unique_vertex(vertices, detail.to_global(local_point))
for child in node.get_children():
_collect_detail_connection_vertices(child, vertices)
func _collapse_merged_vertex_groups(root: ModularBuilderPiece, vertices: Array[Vector3]) -> void:
if not root.has_meta(MERGED_VERTEX_GROUPS_META):
return
var groups: Array = root.get_meta(MERGED_VERTEX_GROUPS_META, [])
for group_value in groups:
if not group_value is Dictionary:
continue
var group: Dictionary = group_value
var members: PackedVector3Array = group.get("members", PackedVector3Array())
var anchor: Vector3 = group.get("position", Vector3.ZERO)
var had_member := false
for i in range(vertices.size() - 1, -1, -1):
var local_vertex := root.to_local(vertices[i])
for member in members:
if local_vertex.distance_to(member) <= 0.025:
vertices.remove_at(i)
had_member = true
break
if had_member:
_add_unique_vertex(vertices, root.to_global(anchor))
func _merge_members_for_point(root: ModularBuilderPiece, world_point: Vector3) -> PackedVector3Array:
var local_point := root.to_local(world_point)
var groups: Array = root.get_meta(MERGED_VERTEX_GROUPS_META, [])
for group_value in groups:
if not group_value is Dictionary:
continue
var group: Dictionary = group_value
var anchor: Vector3 = group.get("position", Vector3.ZERO)
if local_point.distance_to(anchor) <= 0.04:
return group.get("members", PackedVector3Array())
return PackedVector3Array([local_point])
func _finish_merge_vertices(root: ModularBuilderPiece) -> void:
if pending_points.size() < 2:
return
var selected_members := _merge_members_for_point(root, pending_points[0])
for member in _merge_members_for_point(root, pending_points[1]):
var found := false
for existing in selected_members:
if existing.distance_to(member) <= 0.025:
found = true
break
if not found:
selected_members.append(member)
if selected_members.size() < 2:
pending_points.clear()
status_label.text = "Choose two different vertices to merge."
update_overlays()
return
var old_groups: Array = root.get_meta(MERGED_VERTEX_GROUPS_META, []).duplicate(true)
var new_groups: Array = []
for group_value in old_groups:
if not group_value is Dictionary:
continue
var group: Dictionary = group_value
var members: PackedVector3Array = group.get("members", PackedVector3Array())
var overlaps := false
for member in members:
for selected in selected_members:
if member.distance_to(selected) <= 0.025:
overlaps = true
break
if overlaps:
break
if not overlaps:
new_groups.append(group)
var center := Vector3.ZERO
for member in selected_members:
center += member
center /= selected_members.size()
new_groups.append({"members": selected_members, "position": center})
var undo := get_undo_redo()
undo.create_action("Merge vertex markers")
undo.add_do_method(root, "set_meta", MERGED_VERTEX_GROUPS_META, new_groups)
undo.add_undo_method(root, "set_meta", MERGED_VERTEX_GROUPS_META, old_groups)
undo.commit_action()
pending_points.clear()
status_label.text = "Markers merged. Ctrl+Z undoes the merge."
update_overlays()
func _add_gable_wall_vertices(root: ModularBuilderPiece, vertices: Array[Vector3]) -> void:
var gable_walls: Array[Node] = []
_collect_gable_walls(root, gable_walls)
for node in gable_walls:
_add_single_gable_wall_vertices(node as ModularBuilderPiece, vertices)
func _add_single_gable_wall_vertices(gable: ModularBuilderPiece, vertices: Array[Vector3]) -> void:
if gable.custom_points.size() < 2:
return
var base_points: PackedVector3Array = gable.call("_get_centered_gable_base") if gable.center_align else gable.custom_points
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() * gable.thickness * 0.5
var peak := (a + b) * 0.5 + Vector3.UP * gable.rise if gable.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL else b + Vector3.UP * gable.rise
for endpoint in [a, b]:
_add_unique_vertex(vertices, gable.to_global(endpoint + side))
_add_unique_vertex(vertices, gable.to_global(endpoint - side))
_add_unique_vertex(vertices, gable.to_global(peak + side))
_add_unique_vertex(vertices, gable.to_global(peak - side))
func _collect_gable_walls(node: Node, gable_walls: Array[Node]) -> void:
if node is ModularBuilderPiece and (node.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL or node.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT) and not gable_walls.has(node):
gable_walls.append(node)
for child in node.get_children():
_collect_gable_walls(child, gable_walls)
func _collect_walls(node: Node, walls: Array[Node]) -> void:
if node is ModularBuilderPiece and node.piece_type == ModularBuilderPiece.PieceType.WALL and not walls.has(node):
walls.append(node)
for child in node.get_children():
if child is ModularBuilderPiece and child.piece_type == ModularBuilderPiece.PieceType.WALL:
if not walls.has(child):
walls.append(child)
_collect_walls(child, walls)
func _add_unique_vertex(vertices: Array[Vector3], point: Vector3) -> void:
for existing in vertices:
if existing.distance_to(point) < 0.02:
return
vertices.append(point)
func _get_manual_vertices(piece: ModularBuilderPiece) -> Array[Vector3]:
var result: Array[Vector3] = []
var owners: Array[Node3D] = []
_collect_manual_vertex_owners(piece, owners)
for owner_node in owners:
for local_point_value in owner_node.get_meta(CUSTOM_VERTICES_META, []):
var local_point: Vector3 = local_point_value
result.append(owner_node.to_global(local_point))
return result
func _collect_manual_vertex_owners(node: Node, owners: Array[Node3D]) -> void:
if node is ModularBuilderPiece or node is ModularBuilderDetail:
owners.append(node as Node3D)
for child in node.get_children():
_collect_manual_vertex_owners(child, owners)
func _raycast_piece_mesh(root: ModularBuilderPiece, camera: Camera3D, screen_position: Vector2) -> Variant:
return MESH_PICKER.raycast(root, camera, screen_position)
func _commit_manual_vertices(owner_node: Node3D, new_vertices: Array, action_name: String) -> void:
var old_vertices: Array = owner_node.get_meta(CUSTOM_VERTICES_META, []).duplicate()
var undo := get_undo_redo()
undo.create_action(action_name)
undo.add_do_method(owner_node, "set_meta", CUSTOM_VERTICES_META, new_vertices)
undo.add_undo_method(owner_node, "set_meta", CUSTOM_VERTICES_META, old_vertices)
undo.commit_action()
func _create_manual_vertex(root: ModularBuilderPiece, world_position: Vector3) -> void:
var owner_node := _get_vertex_attachment_node(root)
var local_position := owner_node.to_local(world_position)
var vertices: Array = owner_node.get_meta(CUSTOM_VERTICES_META, []).duplicate()
for existing in vertices:
var existing_position: Vector3 = existing
if existing_position.distance_to(local_position) < 0.025:
status_label.text = "A manual vertex already exists there."
return
vertices.append(local_position)
_commit_manual_vertices(owner_node, vertices, "Create connection vertex")
status_label.text = "Connection vertex created and linked to the selected object."
func _move_manual_vertex(root: ModularBuilderPiece, old_world_position: Vector3, new_world_position: Vector3) -> void:
var owner_record := _find_manual_vertex_record(root, old_world_position)
if owner_record.is_empty():
pending_points.clear()
status_label.text = "The selected vertex is no longer available."
return
var owner_node: Node3D = owner_record.node
var vertices: Array = owner_node.get_meta(CUSTOM_VERTICES_META, []).duplicate()
vertices[owner_record.index] = owner_node.to_local(new_world_position)
pending_points.clear()
_commit_manual_vertices(owner_node, vertices, "Move connection vertex")
status_label.text = "Vertex moved."
func _remove_manual_vertex(root: ModularBuilderPiece, world_position: Vector3) -> void:
var owner_record := _find_manual_vertex_record(root, world_position)
if owner_record.is_empty():
return
var owner_node: Node3D = owner_record.node
var vertices: Array = owner_node.get_meta(CUSTOM_VERTICES_META, []).duplicate()
vertices.remove_at(owner_record.index)
_commit_manual_vertices(owner_node, vertices, "Remove connection vertex")
status_label.text = "Vertex removed."
func _get_vertex_attachment_node(root: ModularBuilderPiece) -> Node3D:
var selected := get_editor_interface().get_selection().get_selected_nodes()
if selected.size() == 1:
var selected_piece := selected[0] as ModularBuilderPiece
if selected_piece != null and (selected_piece == root or _is_descendant_of_piece(selected_piece, root)):
return selected_piece
if selected[0] is ModularBuilderDetail and _is_descendant_of_piece(selected[0], root):
return selected[0] as ModularBuilderDetail
return root
func _find_manual_vertex_record(root: ModularBuilderPiece, world_position: Vector3) -> Dictionary:
var owners: Array[Node3D] = []
_collect_manual_vertex_owners(root, owners)
var nearest_record := {}
var nearest_distance := 0.05
for owner_node in owners:
var vertices: Array = owner_node.get_meta(CUSTOM_VERTICES_META, [])
for i in range(vertices.size()):
var local_point: Vector3 = vertices[i]
var distance := owner_node.to_global(local_point).distance_to(world_position)
if distance < nearest_distance:
nearest_distance = distance
nearest_record = {"node": owner_node, "index": i}
return nearest_record

View file

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

View file

@ -1,637 +0,0 @@
@tool
extends "res://addons/modular_builder/modular_builder_editor_vertices.gd"
const ROOF_SUPPORT = preload("res://addons/modular_builder/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:
pending_points.clear()
_editor_sync_elapsed = 1.0
status_label.text = "Current points cleared."
update_overlays()
return EditorPlugin.AFTER_GUI_INPUT_STOP
if _is_detail_tool(active_tool) and event is InputEventMouseMotion:
_preview_camera = camera
_preview_screen_position = event.position
_update_detail_preview()
return EditorPlugin.AFTER_GUI_INPUT_PASS
if not (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT):
return EditorPlugin.AFTER_GUI_INPUT_PASS
_editor_sync_elapsed = 1.0
var root := _get_selected_piece()
if root == null:
return EditorPlugin.AFTER_GUI_INPUT_PASS
if active_tool == TOOL_FOUNDATION:
var side := _nearest_side_button(camera, event.position)
if side >= 0:
_add_foundation_at_side(root, side)
return EditorPlugin.AFTER_GUI_INPUT_STOP
return EditorPlugin.AFTER_GUI_INPUT_PASS
if active_tool == TOOL_NONE:
return EditorPlugin.AFTER_GUI_INPUT_PASS
if active_tool == TOOL_CREATE_VERTEX:
var create_position := _raycast_piece_mesh(root, camera, event.position)
if create_position == null:
status_label.text = "Click a structure surface to create a connection vertex."
return EditorPlugin.AFTER_GUI_INPUT_STOP
_create_manual_vertex(root, create_position)
return EditorPlugin.AFTER_GUI_INPUT_STOP
if active_tool == TOOL_MOVE_VERTEX:
if pending_points.is_empty():
var selected_vertex := _nearest_vertex(camera, event.position, _get_manual_vertices(root))
if selected_vertex == null:
status_label.text = "Click a blue manual vertex to select it."
return EditorPlugin.AFTER_GUI_INPUT_STOP
pending_points.append(selected_vertex)
status_label.text = "Vertex selected. Click the surface where you want to move it."
else:
var move_position := _raycast_piece_mesh(root, camera, event.position)
if move_position == null:
status_label.text = "Click a structure surface to move the vertex."
return EditorPlugin.AFTER_GUI_INPUT_STOP
_move_manual_vertex(root, pending_points[0], move_position)
return EditorPlugin.AFTER_GUI_INPUT_STOP
if active_tool == TOOL_REMOVE_VERTEX:
var remove_position := _nearest_vertex(camera, event.position, _get_manual_vertices(root))
if remove_position == null:
status_label.text = "Click a blue manual vertex to remove it."
else:
_remove_manual_vertex(root, remove_position)
return EditorPlugin.AFTER_GUI_INPUT_STOP
if active_tool == TOOL_PILLAR:
var pillar_vertex := _nearest_vertex(camera, event.position, _get_tool_vertices(root))
if pillar_vertex == null:
return EditorPlugin.AFTER_GUI_INPUT_PASS
if event.alt_pressed:
_create_vertical_pillar_from_vertex(root, pillar_vertex)
return EditorPlugin.AFTER_GUI_INPUT_STOP
for previous_point in pending_points:
if previous_point.distance_to(pillar_vertex) < 0.02:
status_label.text = "Choose a different second vertex."
return EditorPlugin.AFTER_GUI_INPUT_STOP
pending_points.append(pillar_vertex)
if pending_points.size() >= 2:
_create_pillar_from_vertices(root, pending_points[0], pending_points[1])
else:
status_label.text = "First vertex marked. Click another vertex to place the pillar between them."
update_overlays()
return EditorPlugin.AFTER_GUI_INPUT_STOP
if active_tool == TOOL_FENCE or active_tool == TOOL_PARAPET:
var fence_vertex := _nearest_vertex(camera, event.position, _get_tool_vertices(root))
if fence_vertex == null:
return EditorPlugin.AFTER_GUI_INPUT_PASS
for previous_point in pending_points:
if previous_point.distance_to(fence_vertex) < 0.02:
status_label.text = "Choose a different second vertex for the path."
return EditorPlugin.AFTER_GUI_INPUT_STOP
pending_points.append(fence_vertex)
if pending_points.size() >= 2:
_create_fence_decoration(root, pending_points[0], pending_points[1])
else:
status_label.text = "Start marked. Click the final fence vertex."
update_overlays()
return EditorPlugin.AFTER_GUI_INPUT_STOP
if _is_detail_tool(active_tool):
if _create_detail_from_click(camera, event.position):
return EditorPlugin.AFTER_GUI_INPUT_STOP
status_label.text = "Click a surface compatible with the selected tool."
return EditorPlugin.AFTER_GUI_INPUT_PASS
var point := _nearest_vertex(camera, event.position, _get_tool_vertices(root))
if point == null:
return EditorPlugin.AFTER_GUI_INPUT_PASS
for previous_point in pending_points:
if previous_point.distance_to(point) < 0.02:
status_label.text = "Choose a different vertex."
return EditorPlugin.AFTER_GUI_INPUT_STOP
if active_tool == TOOL_FLOOR_SQUARE and pending_points.size() == 1:
var first_local := root.to_local(pending_points[0])
var second_local := root.to_local(point)
if absf(first_local.x - second_local.x) < 0.02 or absf(first_local.z - second_local.z) < 0.02:
status_label.text = "To complete a square floor, choose the opposite diagonal vertex."
return EditorPlugin.AFTER_GUI_INPUT_STOP
pending_points.append(point)
var needed := _required_clicks()
if pending_points.size() >= needed:
_finish_shape(root)
elif active_tool == TOOL_ROOF_SHED:
status_label.text = "Low point marked. Click the high point on the other side of the roof."
elif active_tool == TOOL_MERGE_VERTICES:
status_label.text = "First vertex marked. Click the second to merge the markers."
else:
status_label.text = "Point %d of %d marked. Click the next vertex." % [pending_points.size(), needed]
update_overlays()
return EditorPlugin.AFTER_GUI_INPUT_STOP
func _forward_3d_draw_over_viewport(overlay: Control) -> void:
pass
func _required_clicks() -> int:
if active_tool == TOOL_WALL or active_tool == TOOL_GABLE_WALL or active_tool == TOOL_GABLE_WALL_RIGHT or active_tool == TOOL_MERGE_VERTICES or active_tool == TOOL_FLOOR_SQUARE or active_tool == TOOL_ROOF_GABLE or active_tool == TOOL_ROOF_SHED or active_tool == TOOL_ROOF_PYRAMID or active_tool == TOOL_STAIRS or active_tool == TOOL_FENCE:
return 2
return 3
func _finish_shape(root: ModularBuilderPiece) -> void:
if active_tool == TOOL_MERGE_VERTICES:
_finish_merge_vertices(root)
return
if active_tool == TOOL_WALL:
_finish_wall_segments(root)
return
if active_tool == TOOL_GABLE_WALL or active_tool == TOOL_GABLE_WALL_RIGHT:
_finish_gable_wall(root)
return
if active_tool == TOOL_ROOF_GABLE or active_tool == TOOL_ROOF_SHED or active_tool == TOOL_ROOF_PYRAMID:
_finish_roof(root)
return
if active_tool == TOOL_STAIRS:
_finish_stairs(root)
return
if active_tool == TOOL_FENCE:
_finish_fence(root)
return
var local_points := PackedVector3Array()
for world_point in pending_points:
local_points.append(root.to_local(world_point))
var piece := Node3D.new()
piece.set_script(PIECE_SCRIPT)
piece.set("centered_origin", true)
piece.name = "FloorTriangle" if active_tool == TOOL_FLOOR_TRIANGLE else "FloorSquare"
piece.set("piece_type", ModularBuilderPiece.PieceType.FLOOR)
piece.set("thickness", root.thickness)
if active_tool == TOOL_FLOOR_SQUARE:
var a := local_points[0]
var b := local_points[1]
var left := minf(a.x, b.x)
var right := maxf(a.x, b.x)
var near_z := minf(a.z, b.z)
var far_z := maxf(a.z, b.z)
var y := (a.y + b.y) * 0.5
local_points = PackedVector3Array([
Vector3(left, y, near_z), Vector3(right, y, near_z),
Vector3(right, y, far_z), Vector3(left, y, far_z)
])
var floor_pivot := _packed_points_bounds_center(local_points)
floor_pivot.y -= piece.thickness * 0.5
piece.position = floor_pivot
for i in range(local_points.size()):
local_points[i] -= floor_pivot
piece.set("custom_points", local_points)
var undo := get_undo_redo()
undo.create_action("Create %s from vertices" % piece.name)
undo.add_do_method(root, "add_child", piece, true)
undo.add_do_property(piece, "owner", get_editor_interface().get_edited_scene_root())
undo.add_undo_method(root, "remove_child", piece)
undo.commit_action()
_last_created_piece = piece
pending_points.clear()
status_label.text = "%s created inside ModularBuilderPiece. The tool remains active." % piece.name
update_overlays()
func _finish_wall_segments(root: ModularBuilderPiece) -> void:
var start_world := pending_points[0]
var finish_world := pending_points[1]
var path := finish_world - start_world
var path_length_squared := path.length_squared()
if path_length_squared < 0.0001:
pending_points.clear()
status_label.text = "Wall points are too close together."
update_overlays()
return
var parameters: Array[float] = [0.0, 1.0]
for vertex in _get_tool_vertices(root):
var t := (vertex - start_world).dot(path) / path_length_squared
if t <= 0.001 or t >= 0.999:
continue
var projected := start_world + path * t
if vertex.distance_to(projected) > 0.02:
continue
var already_added := false
for existing_t in parameters:
if absf(existing_t - t) < 0.005:
already_added = true
break
if not already_added:
parameters.append(t)
parameters.sort()
var pieces: Array[ModularBuilderPiece] = []
var centralize_wall := _gable_centralize_check.button_pressed
for i in range(parameters.size() - 1):
var a_world := start_world.lerp(finish_world, parameters[i])
var b_world := start_world.lerp(finish_world, parameters[i + 1])
pieces.append(_make_wall_segment(root, a_world, b_world, i + 1, centralize_wall))
var scene_root := get_editor_interface().get_edited_scene_root()
var undo := get_undo_redo()
undo.create_action("Create wall in %d segments" % pieces.size())
for piece in pieces:
undo.add_do_method(root, "add_child", piece, true)
undo.add_do_property(piece, "owner", scene_root)
undo.add_undo_method(root, "remove_child", piece)
undo.commit_action()
_last_created_piece = pieces[-1]
pending_points.clear()
status_label.text = "Wall split into %d segments at foundation vertices." % pieces.size()
update_overlays()
func _finish_gable_wall(root: ModularBuilderPiece) -> void:
var start := root.to_local(pending_points[0])
var finish := root.to_local(pending_points[1])
if Vector2(start.x - finish.x, start.z - finish.z).length() < 0.02 or absf(start.y - finish.y) > 0.05:
pending_points.clear()
status_label.text = "Choose two separate vertices at the same height for the gable base."
update_overlays()
return
var piece_node := Node3D.new()
piece_node.set_script(PIECE_SCRIPT)
var piece := piece_node as ModularBuilderPiece
var is_right_triangle := active_tool == TOOL_GABLE_WALL_RIGHT
piece.name = "GableWallRight" if is_right_triangle else "GableWall"
piece.piece_type = ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT if is_right_triangle else ModularBuilderPiece.PieceType.GABLE_WALL
piece.unit_size = root.unit_size
piece.thickness = root.thickness
piece.centered_origin = true
piece.center_align = _gable_centralize_check.button_pressed
var pivot := (start + finish) * 0.5 + Vector3.UP * rise_spin.value * 0.5
piece.position = pivot
piece.rise = rise_spin.value
piece.custom_points = PackedVector3Array([start - pivot, finish - pivot])
var undo := get_undo_redo()
undo.create_action("Create gable wall")
undo.add_do_method(root, "add_child", piece, true)
undo.add_do_property(piece, "owner", get_editor_interface().get_edited_scene_root())
undo.add_undo_method(root, "remove_child", piece)
undo.commit_action()
_last_created_piece = piece
pending_points.clear()
status_label.text = "Gable wall created. The tool remains active."
update_overlays()
func _finish_roof(root: ModularBuilderPiece) -> void:
var first := root.to_local(pending_points[0])
var second := root.to_local(pending_points[1])
var is_gable := active_tool == TOOL_ROOF_GABLE
var is_pyramid := active_tool == TOOL_ROOF_PYRAMID
var is_shed := active_tool == TOOL_ROOF_SHED
if (is_gable or is_pyramid) and absf(first.y - second.y) > 0.05:
pending_points.clear()
status_label.text = "For this roof, choose two diagonal points at the same height."
update_overlays()
return
if is_shed and second.y <= first.y + 0.05:
pending_points.clear()
status_label.text = "Choose a low point first, then a higher point on the opposite side."
update_overlays()
return
if is_shed and not _shed_points_are_on_opposite_sides(root, pending_points[0], pending_points[1]):
pending_points.clear()
status_label.text = "Roof points must be on opposite, parallel walls."
update_overlays()
return
var roof_rise := rise_spin.value
var roof_width := 0.0
var corners := PackedVector3Array()
if is_gable:
var gable_result := _find_roof_between_gables(root, pending_points[0], pending_points[1])
if not gable_result.is_empty():
corners = gable_result.corners
roof_rise = gable_result.rise
elif is_pyramid:
corners = _find_roof_corners(root, first, second)
else:
corners = _find_shed_roof_corners(root, first, second)
if corners.size() == 4:
roof_width = corners[0].distance_to(corners[1])
if is_gable and corners.is_empty():
corners = ROOF_SUPPORT.find_corners(root, pending_points[0], pending_points[1], _simplify_vertices_check.button_pressed)
if corners.is_empty():
corners = _find_roof_corners(root, first, second)
if corners.size() != 4:
pending_points.clear()
status_label.text = "Could not find the other roof corners. Choose two diagonal vertices on the top edges of opposite walls."
update_overlays()
return
var piece_node := Node3D.new()
piece_node.set_script(PIECE_SCRIPT)
var piece := piece_node as ModularBuilderPiece
piece.name = "RoofGable" if is_gable else ("RoofPyramid" if is_pyramid else "RoofShed")
piece.piece_type = ModularBuilderPiece.PieceType.ROOF_GABLE if is_gable else (ModularBuilderPiece.PieceType.ROOF_PYRAMID if is_pyramid else ModularBuilderPiece.PieceType.ROOF_SHED)
piece.unit_size = root.unit_size
piece.thickness = 0.05
piece.centered_origin = true
piece.rise = roof_rise
if is_shed:
piece.roof_width = roof_width
var pivot := Vector3.ZERO
for corner in corners:
pivot += corner
pivot /= corners.size()
if is_gable or is_pyramid:
pivot += Vector3.UP * roof_rise * 0.5
piece.position = pivot
if is_gable or is_pyramid:
var local_corners := PackedVector3Array()
for corner in corners:
var local_corner: Vector3 = corner - pivot
local_corner.y = -roof_rise * 0.5
local_corners.append(local_corner)
piece.custom_points = local_corners
else:
var local_corners := PackedVector3Array()
for corner in corners:
local_corners.append(corner - pivot)
piece.custom_points = local_corners
var undo := get_undo_redo()
var roof_title := "gable roof" if is_gable else ("pyramid roof" if is_pyramid else "shed roof")
undo.create_action("Create %s" % roof_title)
undo.add_do_method(root, "add_child", piece, true)
undo.add_do_property(piece, "owner", get_editor_interface().get_edited_scene_root())
undo.add_undo_method(root, "remove_child", piece)
undo.commit_action()
_last_created_piece = piece
pending_points.clear()
status_label.text = "%s created. The tool remains active." % piece.name
update_overlays()
func _finish_stairs(root: ModularBuilderPiece) -> void:
if pending_points.size() < 2:
return
var low := root.to_local(pending_points[0])
var high := root.to_local(pending_points[1])
if high.y <= low.y + 0.05:
pending_points.clear()
status_label.text = "Choose the low point first, then a point at least 5 cm higher."
update_overlays()
return
if Vector2(high.x - low.x, high.z - low.z).length() < 0.1:
pending_points.clear()
status_label.text = "Stair points need horizontal separation."
update_overlays()
return
var piece_node := Node3D.new()
piece_node.set_script(PIECE_SCRIPT)
var piece := piece_node as ModularBuilderPiece
var pivot := (low + high) * 0.5
piece.name = "Stairs"
piece.piece_type = ModularBuilderPiece.PieceType.STAIRS
piece.position = pivot
piece.unit_size = root.unit_size
piece.thickness = 0.1
piece.centered_origin = true
piece.stair_count = int(stair_count_spin.value)
piece.stair_width = stair_width_spin.value
piece.custom_points = PackedVector3Array([low - pivot, high - pivot])
var undo := get_undo_redo()
undo.create_action("Create stairs")
undo.add_do_method(root, "add_child", piece, true)
undo.add_do_property(piece, "owner", get_editor_interface().get_edited_scene_root())
undo.add_undo_method(root, "remove_child", piece)
undo.commit_action()
_last_created_piece = piece
pending_points.clear()
status_label.text = "Stairs created with %d steps. The tool remains active." % piece.stair_count
update_overlays()
func _finish_fence(root: ModularBuilderPiece) -> void:
if pending_points.size() < 2:
return
var start := root.to_local(pending_points[0])
var finish := root.to_local(pending_points[1])
if start.distance_to(finish) < 0.1:
pending_points.clear()
status_label.text = "Choose two points far enough apart for the fence."
update_overlays()
return
var piece_node := Node3D.new()
piece_node.set_script(PIECE_SCRIPT)
var piece := piece_node as ModularBuilderPiece
var pivot := (start + finish) * 0.5
piece.name = "Fence"
piece.piece_type = ModularBuilderPiece.PieceType.FENCE
piece.position = pivot
piece.unit_size = root.unit_size
piece.thickness = 0.08
piece.centered_origin = true
piece.fence_divisions = int(fence_divisions_spin.value)
piece.fence_height = 1.0
piece.custom_points = PackedVector3Array([start - pivot, finish - pivot])
var undo := get_undo_redo()
undo.create_action("Create fence")
undo.add_do_method(root, "add_child", piece, true)
undo.add_do_property(piece, "owner", get_editor_interface().get_edited_scene_root())
undo.add_undo_method(root, "remove_child", piece)
undo.commit_action()
_last_created_piece = piece
pending_points.clear()
status_label.text = "Fence created with %d intermediate post(s). The tool remains active." % piece.fence_divisions
update_overlays()
func _find_roof_corners(root: ModularBuilderPiece, first: Vector3, second: Vector3) -> PackedVector3Array:
var diagonal_center := (first + second) * 0.5
var diagonal_length := first.distance_to(second)
var candidates: Array[Vector3] = []
for world_point in _get_tool_vertices(root):
var local_point := root.to_local(world_point)
if absf(local_point.y - first.y) > 0.04:
continue
_add_unique_vertex(candidates, local_point)
for adjacent in candidates:
if adjacent.distance_to(first) < 0.02 or adjacent.distance_to(second) < 0.02:
continue
var opposite := diagonal_center * 2.0 - adjacent
for other_adjacent in candidates:
if other_adjacent.distance_to(first) < 0.02 or other_adjacent.distance_to(second) < 0.02 or other_adjacent.distance_to(adjacent) < 0.02:
continue
if other_adjacent.distance_to(opposite) > 0.04:
continue
if absf(adjacent.distance_to(other_adjacent) - diagonal_length) > 0.06:
continue
return PackedVector3Array([first, adjacent, second, other_adjacent])
return PackedVector3Array()
func _find_shed_roof_corners(root: ModularBuilderPiece, low: Vector3, high: Vector3) -> PackedVector3Array:
var candidates: Array[Vector3] = []
for world_point in _get_tool_vertices(root):
_add_unique_vertex(candidates, root.to_local(world_point))
var match_tolerance := maxf(0.08, root.thickness * 1.5)
var best := PackedVector3Array()
var best_score := INF
for low_adjacent in candidates:
if absf(low_adjacent.y - low.y) > 0.04 or low_adjacent.distance_to(low) < 0.4:
continue
var target_high_adjacent := low + high - low_adjacent
for high_adjacent in candidates:
if absf(high_adjacent.y - high.y) > 0.04 or high_adjacent.distance_to(high) < 0.4:
continue
var corner_error := high_adjacent.distance_to(target_high_adjacent)
if corner_error > match_tolerance:
continue
var low_edge := Vector2(low_adjacent.x - low.x, low_adjacent.z - low.z)
var high_edge := Vector2(high.x - high_adjacent.x, high.z - high_adjacent.z)
if low_edge.length() < 0.4 or high_edge.length() < 0.4:
continue
if absf(low_edge.normalized().dot(high_edge.normalized())) < 0.94:
continue
var edge_error := absf(low_edge.length() - high_edge.length())
if edge_error > match_tolerance * 2.0:
continue
var score := corner_error + edge_error
if score < best_score:
best_score = score
best = PackedVector3Array([low, low_adjacent, high_adjacent, high])
return best
func _shed_points_are_on_opposite_sides(root: ModularBuilderPiece, low_world: Vector3, high_world: Vector3) -> bool:
var low_supports := _get_roof_supports_at_point(root, low_world)
var high_supports := _get_roof_supports_at_point(root, high_world)
var across := root.to_local(high_world) - root.to_local(low_world)
var across_horizontal := Vector2(across.x, across.z)
if across_horizontal.length() < 0.1:
return false
across_horizontal = across_horizontal.normalized()
for low_support in low_supports:
for high_support in high_supports:
if low_support.piece == high_support.piece:
continue
var low_direction: Vector2 = low_support.direction
var high_direction: Vector2 = high_support.direction
if absf(low_direction.dot(high_direction)) < 0.85:
continue
var side_axis := Vector2(-low_direction.y, low_direction.x)
var center_delta: Vector3 = high_support.center - low_support.center
var side_separation := absf(Vector2(center_delta.x, center_delta.z).dot(side_axis))
if side_separation >= 0.4:
return true
return false
func _get_roof_supports_at_point(root: ModularBuilderPiece, world_point: Vector3) -> Array[Dictionary]:
var result: Array[Dictionary] = []
var walls: Array[Node] = []
_collect_walls(root, walls)
for node in walls:
var wall := node as ModularBuilderPiece
var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points
if endpoints.size() < 2:
continue
var world_a := wall.to_global(endpoints[0])
var world_b := wall.to_global(endpoints[1])
var local_direction := root.to_local(world_b) - root.to_local(world_a)
var direction := Vector2(local_direction.x, local_direction.z).normalized()
var wall_local_direction := (endpoints[1] - endpoints[0]).normalized()
var side := Vector3(-wall_local_direction.z, 0.0, wall_local_direction.x).normalized() * wall.thickness * 0.5
for endpoint in endpoints:
for height in [0.0, wall.unit_size]:
var center := wall.to_global(endpoint + Vector3.UP * height)
var support_points := [center + side, center - side]
if _simplify_vertices_check.button_pressed:
support_points.append(center)
for point in support_points:
if point.distance_to(world_point) <= 0.04:
result.append({"piece": wall, "direction": direction, "center": root.to_local(center)})
var gables: Array[Node] = []
_collect_gable_walls(root, gables)
for node in gables:
var gable := node as ModularBuilderPiece
if gable.custom_points.size() < 2:
continue
var base: PackedVector3Array = gable.call("_get_centered_gable_base") if gable.center_align else gable.custom_points
var a: Vector3 = base[0]
var b: Vector3 = base[1]
var local_direction := b - a
var direction := Vector2(local_direction.x, local_direction.z).normalized()
var side := Vector3(-local_direction.z, 0.0, local_direction.x).normalized() * gable.thickness * 0.5
var peak := (a + b) * 0.5 + Vector3.UP * gable.rise if gable.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL else b + Vector3.UP * gable.rise
var support_points := [a + side, a - side, b + side, b - side, peak + side, peak - side]
if _simplify_vertices_check.button_pressed:
support_points.append_array([a, b, peak])
for point_local in support_points:
var point := gable.to_global(point_local)
if point.distance_to(world_point) <= 0.04:
result.append({"piece": gable, "direction": direction, "center": root.to_local(gable.to_global((a + b) * 0.5))})
if result.is_empty() and root.has_meta(MERGED_VERTEX_GROUPS_META):
for group_value in root.get_meta(MERGED_VERTEX_GROUPS_META, []):
if not group_value is Dictionary:
continue
var group: Dictionary = group_value
var anchor: Vector3 = group.get("position", Vector3.ZERO)
if root.to_global(anchor).distance_to(world_point) > 0.04:
continue
var members: PackedVector3Array = group.get("members", PackedVector3Array())
for member in members:
result.append_array(_get_roof_supports_at_point(root, root.to_global(member)))
return result
func _find_roof_between_gables(root: ModularBuilderPiece, first_world: Vector3, second_world: Vector3) -> Dictionary:
var gables: Array[Node] = []
_collect_gable_walls(root, gables)
var first_gable: ModularBuilderPiece
var second_gable: ModularBuilderPiece
var first_base := PackedVector3Array()
var second_base := PackedVector3Array()
for node in gables:
var gable := node as ModularBuilderPiece
if gable.custom_points.size() < 2:
continue
var base: PackedVector3Array = gable.call("_get_centered_gable_base") if gable.center_align else gable.custom_points
var a: Vector3 = base[0]
var b: Vector3 = base[1]
var direction := (b - a).normalized()
var side := Vector3(-direction.z, 0.0, direction.x).normalized() * gable.thickness * 0.5
var peak := (a + b) * 0.5 + Vector3.UP * gable.rise if gable.piece_type == ModularBuilderPiece.PieceType.GABLE_WALL else b + Vector3.UP * gable.rise
var peak_a := gable.to_global(peak + side)
var peak_b := gable.to_global(peak - side)
var peak_center := gable.to_global(peak)
if first_gable == null and (peak_a.distance_to(first_world) < 0.12 or peak_b.distance_to(first_world) < 0.12 or (_simplify_vertices_check.button_pressed and peak_center.distance_to(first_world) < 0.12)):
first_gable = gable
first_base = base
elif second_gable == null and gable != first_gable and (peak_a.distance_to(second_world) < 0.12 or peak_b.distance_to(second_world) < 0.12 or (_simplify_vertices_check.button_pressed and peak_center.distance_to(second_world) < 0.12)):
second_gable = gable
second_base = base
if first_gable == null or second_gable == null:
return {}
var a0 := root.to_local(first_gable.to_global(first_base[0]))
var a1 := root.to_local(first_gable.to_global(first_base[1]))
var b0 := root.to_local(second_gable.to_global(second_base[0]))
var b1 := root.to_local(second_gable.to_global(second_base[1]))
var direct_score := a0.distance_to(b0) + a1.distance_to(b1)
var crossed_score := a0.distance_to(b1) + a1.distance_to(b0)
var corners := PackedVector3Array([a0, a1, b1, b0]) if direct_score <= crossed_score else PackedVector3Array([a0, a1, b0, b1])
return {"corners": corners, "rise": (first_gable.rise + second_gable.rise) * 0.5}
func _make_wall_segment(root: ModularBuilderPiece, start_world: Vector3, finish_world: Vector3, index: int, centralize_wall: bool) -> ModularBuilderPiece:
var centered_start := _center_wall_anchor(root, start_world) if centralize_wall else start_world
var centered_finish := _center_wall_anchor(root, finish_world) if centralize_wall else finish_world
var start := root.to_local(centered_start)
var finish := root.to_local(centered_finish)
var piece_node := Node3D.new()
piece_node.set_script(PIECE_SCRIPT)
var piece := piece_node as ModularBuilderPiece
piece.name = "Wall_%02d" % index
piece.set("piece_type", ModularBuilderPiece.PieceType.WALL)
piece.set("unit_size", root.unit_size)
piece.set("thickness", root.thickness)
piece.set("centered_origin", true)
piece.set("center_align", centralize_wall)
var pivot := (start + finish) * 0.5 + Vector3.UP * root.unit_size * 0.5
piece.position = pivot
piece.set("custom_points", PackedVector3Array([start - pivot, finish - pivot]))
return piece
func _center_wall_anchor(root: ModularBuilderPiece, world_point: Vector3) -> Vector3:
var walls: Array[Node] = []
_collect_walls(root, walls)
var best_point := world_point
var best_distance := INF
for wall_node in walls:
var wall := wall_node as ModularBuilderPiece
var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points
if endpoints.size() < 2:
continue
var direction := (endpoints[1] - endpoints[0]).normalized()
var side := Vector3(-direction.z, 0.0, direction.x).normalized() * wall.thickness * 0.5
for endpoint in endpoints:
var center := wall.to_global(endpoint + Vector3.UP * wall.unit_size)
for surface_corner in [center + side, center - side]:
var distance := world_point.distance_to(surface_corner)
if distance < best_distance and distance <= wall.thickness + 0.04:
best_distance = distance
best_point = center
return best_point

View file

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

View file

@ -1,166 +0,0 @@
@tool
extends RefCounted
func bake(building: ModularBuilderPiece) -> ArrayMesh:
var mesh_instances: Array[MeshInstance3D] = []
_collect_mesh_instances(building, mesh_instances)
var groups: Array[Dictionary] = []
var projected_materials: Dictionary = {}
for mesh_instance in mesh_instances:
if not is_instance_valid(mesh_instance) or not mesh_instance.is_visible_in_tree() or _is_export_excluded(mesh_instance, building):
continue
var source_mesh := mesh_instance.mesh
if source_mesh == null:
continue
var relative_transform := building.global_transform.affine_inverse() * mesh_instance.global_transform
if absf(relative_transform.basis.determinant()) < 0.000001:
continue
var normal_transform := relative_transform.basis.inverse().transposed()
var mirrored := relative_transform.basis.determinant() < 0.0
for surface_index in range(source_mesh.get_surface_count()):
var primitive_type := Mesh.PRIMITIVE_TRIANGLES
var surface_arrays: Array
if source_mesh is PrimitiveMesh:
# PrimitiveMesh provides mesh arrays directly; surface_get_* methods
# are used for other meshes.
if surface_index > 0:
continue
surface_arrays = source_mesh.get_mesh_arrays()
else:
primitive_type = source_mesh.surface_get_primitive_type(surface_index)
surface_arrays = source_mesh.surface_get_arrays(surface_index)
if primitive_type != Mesh.PRIMITIVE_TRIANGLES:
continue
if surface_arrays.is_empty() or not surface_arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array:
continue
var source_vertices: PackedVector3Array = surface_arrays[Mesh.ARRAY_VERTEX]
if source_vertices.size() < 3:
continue
var source_normals := PackedVector3Array()
var source_uvs := PackedVector2Array()
var source_colors := PackedColorArray()
if surface_arrays[Mesh.ARRAY_NORMAL] is PackedVector3Array:
source_normals = surface_arrays[Mesh.ARRAY_NORMAL]
if surface_arrays[Mesh.ARRAY_TEX_UV] is PackedVector2Array:
source_uvs = surface_arrays[Mesh.ARRAY_TEX_UV]
if surface_arrays[Mesh.ARRAY_COLOR] is PackedColorArray:
source_colors = surface_arrays[Mesh.ARRAY_COLOR]
var source_indices := PackedInt32Array()
if surface_arrays[Mesh.ARRAY_INDEX] is PackedInt32Array:
source_indices = surface_arrays[Mesh.ARRAY_INDEX]
var material := mesh_instance.get_active_material(surface_index)
var force_projected_uvs := false
var bake_material := material
if material is StandardMaterial3D and material.uv1_triplanar:
force_projected_uvs = true
var material_id := material.get_instance_id()
if not projected_materials.has(material_id):
var material_copy := material.duplicate() as StandardMaterial3D
material_copy.uv1_triplanar = false
material_copy.uv1_world_triplanar = false
projected_materials[material_id] = material_copy
bake_material = projected_materials[material_id] as Material
var group_index := _find_or_create_group(groups, bake_material)
var group: Dictionary = groups[group_index]
var group_vertices: Array = group["vertices"]
var group_normals: Array = group["normals"]
var group_uvs: Array = group["uvs"]
var group_colors: Array = group["colors"]
var has_indices := not source_indices.is_empty()
var triangle_count := source_indices.size() if has_indices else source_vertices.size()
for triangle_start in range(0, triangle_count - 2, 3):
var index_a := source_indices[triangle_start] if has_indices else triangle_start
var index_b := source_indices[triangle_start + 1] if has_indices else triangle_start + 1
var index_c := source_indices[triangle_start + 2] if has_indices else triangle_start + 2
if index_a >= source_vertices.size() or index_b >= source_vertices.size() or index_c >= source_vertices.size():
continue
if mirrored:
var swap_index := index_b
index_b = index_c
index_c = swap_index
var local_a: Vector3 = source_vertices[index_a]
var local_b: Vector3 = source_vertices[index_b]
var local_c: Vector3 = source_vertices[index_c]
var baked_a: Vector3 = relative_transform * local_a
var baked_b: Vector3 = relative_transform * local_b
var baked_c: Vector3 = relative_transform * local_c
var face_normal := (baked_b - baked_a).cross(baked_c - baked_a).normalized()
if face_normal.length_squared() < 0.5:
continue
var baked_points := [baked_a, baked_b, baked_c]
var source_point_indices := [index_a, index_b, index_c]
var source_face_normal := (local_b - local_a).cross(local_c - local_a).normalized()
for corner in range(3):
var source_index: int = source_point_indices[corner]
var baked_point: Vector3 = baked_points[corner]
var baked_normal := face_normal
if source_normals.size() == source_vertices.size():
baked_normal = (normal_transform * source_normals[source_index]).normalized()
if baked_normal.length_squared() < 0.5:
baked_normal = face_normal
var uv: Vector2
if not force_projected_uvs and source_uvs.size() == source_vertices.size():
uv = source_uvs[source_index]
else:
uv = _project_uv(source_vertices[source_index], source_face_normal)
var color := source_colors[source_index] if source_colors.size() == source_vertices.size() else Color.WHITE
group_vertices.append(baked_point)
group_normals.append(baked_normal)
group_uvs.append(uv)
group_colors.append(color)
groups[group_index] = group
if groups.is_empty():
return null
var combined := ArrayMesh.new()
combined.resource_name = "%s_Mesh" % building.name
for group in groups:
var vertices := PackedVector3Array(group["vertices"])
if vertices.is_empty():
continue
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_NORMAL] = PackedVector3Array(group["normals"])
arrays[Mesh.ARRAY_TEX_UV] = PackedVector2Array(group["uvs"])
arrays[Mesh.ARRAY_COLOR] = PackedColorArray(group["colors"])
var surface_tool := SurfaceTool.new()
surface_tool.create_from_arrays(arrays, Mesh.PRIMITIVE_TRIANGLES)
if group["material"] is Material:
surface_tool.set_material(group["material"])
surface_tool.generate_tangents()
var temp_mesh := surface_tool.commit()
if temp_mesh == null or temp_mesh.get_surface_count() == 0:
continue
combined.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, temp_mesh.surface_get_arrays(0))
if group["material"] is Material:
combined.surface_set_material(combined.get_surface_count() - 1, group["material"])
return combined if combined.get_surface_count() > 0 else null
func _find_or_create_group(groups: Array[Dictionary], material: Material) -> int:
for i in range(groups.size()):
if groups[i]["material"] == material:
return i
groups.append({"material": material, "vertices": [], "normals": [], "uvs": [], "colors": []})
return groups.size() - 1
func _project_uv(point: Vector3, normal: Vector3) -> Vector2:
var absolute_normal := normal.abs()
if absolute_normal.y >= absolute_normal.x and absolute_normal.y >= absolute_normal.z:
return Vector2(point.x, point.z)
if absolute_normal.x >= absolute_normal.z:
return Vector2(point.z, point.y)
return Vector2(point.x, point.y)
func _is_export_excluded(mesh_instance: MeshInstance3D, building: ModularBuilderPiece) -> bool:
var current: Node = mesh_instance
while current != null and current != building:
if current.has_meta("modular_builder_preview") or current.name == "ModularBuilderMarkers":
return true
current = current.get_parent()
return false
func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
result.append(node)
for child in node.get_children():
_collect_mesh_instances(child, result)

View file

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

View file

@ -1,55 +0,0 @@
@tool
extends RefCounted
static func raycast(root: Node3D, camera: Camera3D, screen_position: Vector2) -> Variant:
var ray_origin := camera.project_ray_origin(screen_position)
var ray_direction := camera.project_ray_normal(screen_position).normalized()
var nearest_point: Variant = null
var nearest_distance := INF
var mesh_nodes: Array[MeshInstance3D] = []
_collect_mesh_instances(root, mesh_nodes)
for mesh_node in mesh_nodes:
var source_mesh := mesh_node.mesh
if source_mesh == null or not mesh_node.is_visible_in_tree():
continue
for surface_index in range(source_mesh.get_surface_count()):
var arrays: Array
if source_mesh is PrimitiveMesh:
if surface_index > 0:
continue
arrays = source_mesh.get_mesh_arrays()
else:
if source_mesh.surface_get_primitive_type(surface_index) != Mesh.PRIMITIVE_TRIANGLES:
continue
arrays = source_mesh.surface_get_arrays(surface_index)
if arrays.is_empty() or not arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array:
continue
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var indices := PackedInt32Array()
if arrays[Mesh.ARRAY_INDEX] is PackedInt32Array:
indices = arrays[Mesh.ARRAY_INDEX]
var has_indices := not indices.is_empty()
var triangle_count := indices.size() if has_indices else vertices.size()
for triangle_start in range(0, triangle_count - 2, 3):
var index_a := indices[triangle_start] if has_indices else triangle_start
var index_b := indices[triangle_start + 1] if has_indices else triangle_start + 1
var index_c := indices[triangle_start + 2] if has_indices else triangle_start + 2
if index_a < 0 or index_b < 0 or index_c < 0 or index_a >= vertices.size() or index_b >= vertices.size() or index_c >= vertices.size():
continue
var vertex_a := mesh_node.to_global(vertices[index_a])
var vertex_b := mesh_node.to_global(vertices[index_b])
var vertex_c := mesh_node.to_global(vertices[index_c])
var hit: Variant = Geometry3D.ray_intersects_triangle(ray_origin, ray_direction, vertex_a, vertex_b, vertex_c)
if hit == null:
continue
var distance := ray_origin.distance_to(hit)
if distance < nearest_distance:
nearest_distance = distance
nearest_point = hit
return nearest_point
static func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
result.append(node)
for child in node.get_children():
_collect_mesh_instances(child, result)

View file

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

View file

@ -1,61 +0,0 @@
@tool
class_name ModularBuilderPiece
extends "res://core/vendor/modular_builder/modular_piece_geometry.gd"
func _ready() -> void:
_rebuild()
func _validate_property(property: Dictionary) -> void:
if property.name == &"symmetric_dimensions" and piece_type == PieceType.BUILDING:
property.usage = PROPERTY_USAGE_NO_EDITOR
if property.name == &"dimension_height" and (piece_type == PieceType.FOUNDATION_SQUARE or piece_type == PieceType.FOUNDATION_TRIANGLE or piece_type == PieceType.FLOOR):
property.usage = PROPERTY_USAGE_NO_EDITOR
if property.name == &"dimension_depth" and piece_type == PieceType.WALL:
property.usage = PROPERTY_USAGE_NO_EDITOR
if property.name == &"dimension_width" or property.name == &"dimension_height" or property.name == &"dimension_depth":
if symmetric_dimensions:
property.usage = PROPERTY_USAGE_NO_EDITOR
if property.name == &"rise" and piece_type != PieceType.GABLE_WALL and piece_type != PieceType.GABLE_WALL_RIGHT and piece_type != PieceType.ROOF_GABLE and piece_type != PieceType.ROOF_PYRAMID:
property.usage = PROPERTY_USAGE_NO_EDITOR
if property.name == &"roof_width" and piece_type != PieceType.ROOF_SHED:
property.usage = PROPERTY_USAGE_NO_EDITOR
if property.name == &"stair_count" and piece_type != PieceType.STAIRS:
property.usage = PROPERTY_USAGE_NO_EDITOR
if property.name == &"stair_width" and piece_type != PieceType.STAIRS:
property.usage = PROPERTY_USAGE_NO_EDITOR
if property.name == &"fence_divisions" and piece_type != PieceType.FENCE:
property.usage = PROPERTY_USAGE_NO_EDITOR
if property.name == &"fence_height" and piece_type != PieceType.FENCE:
property.usage = PROPERTY_USAGE_NO_EDITOR
if property.name == &"center_align" and piece_type != PieceType.GABLE_WALL and piece_type != PieceType.GABLE_WALL_RIGHT and piece_type != PieceType.WALL:
property.usage = PROPERTY_USAGE_NO_EDITOR
func _rebuild() -> void:
if not is_inside_tree():
return
var old_collision := get_node_or_null("Collision")
if old_collision != null:
remove_child(old_collision)
old_collision.queue_free()
var mesh_instance := get_node_or_null("Geometry") as MeshInstance3D
if piece_type == PieceType.BUILDING:
if mesh_instance != null:
remove_child(mesh_instance)
mesh_instance.queue_free()
return
if mesh_instance == null:
mesh_instance = MeshInstance3D.new()
mesh_instance.name = "Geometry"
add_child(mesh_instance)
_set_scene_owner(mesh_instance)
mesh_instance.mesh = _make_mesh()
mesh_instance.material_override = surface_material
if mesh_instance.mesh != null and mesh_instance.mesh.get_surface_count() > 0:
_dimension_reference = mesh_instance.mesh.get_aabb().size
_apply_dimension_scale()
func _set_scene_owner(node: Node) -> void:
if Engine.is_editor_hint():
var scene_root := get_tree().edited_scene_root
if scene_root:
node.owner = scene_root

View file

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

View file

@ -1,34 +0,0 @@
@tool
extends RefCounted
static func vertical_direction_for_piece(piece: ModularBuilderPiece, world_anchor: Vector3) -> Vector3:
var local_anchor := piece.to_local(world_anchor)
var low_y := 0.0
var high_y := piece.unit_size
match piece.piece_type:
ModularBuilderPiece.PieceType.FOUNDATION_SQUARE, ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE:
low_y = -piece.thickness * 0.5 if piece.centered_origin else 0.0
high_y = piece.thickness * 0.5 if piece.centered_origin else piece.thickness
ModularBuilderPiece.PieceType.FLOOR:
high_y = piece.custom_points[0].y if not piece.custom_points.is_empty() else (piece.thickness * 0.5 if piece.centered_origin else piece.thickness)
low_y = high_y - piece.thickness
ModularBuilderPiece.PieceType.GABLE_WALL, ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT:
if piece.custom_points.size() >= 2:
var base_points: PackedVector3Array = piece.call("_get_centered_gable_base") if piece.center_align else piece.custom_points
low_y = base_points[0].y
high_y = low_y + piece.rise
_:
if piece.custom_points.size() >= 2:
var nearest_endpoint := piece.custom_points[0]
var nearest_horizontal := INF
for endpoint in piece.custom_points:
var horizontal := Vector2(endpoint.x - local_anchor.x, endpoint.z - local_anchor.z).length_squared()
if horizontal < nearest_horizontal:
nearest_horizontal = horizontal
nearest_endpoint = endpoint
low_y = nearest_endpoint.y
high_y = nearest_endpoint.y + piece.unit_size
return Vector3.DOWN if absf(local_anchor.y - low_y) < absf(local_anchor.y - high_y) else Vector3.UP
static func vertical_direction_for_pillar(pillar: ModularBuilderDetail, world_anchor: Vector3) -> Vector3:
return Vector3.DOWN if pillar.to_local(world_anchor).y < 0.0 else Vector3.UP

View file

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

View file

@ -1,72 +0,0 @@
@tool
extends RefCounted
# Find the roof outline from two diagonal corners at the top
# of parallel walls, even when the structure is rotated around Y.
static func find_corners(root: ModularBuilderPiece, first_world: Vector3, second_world: Vector3, allow_center: bool = false) -> PackedVector3Array:
var first_edges := _matching_top_edges(root, first_world, allow_center)
var second_edges := _matching_top_edges(root, second_world, allow_center)
var best_corners := PackedVector3Array()
var best_score := INF
var best_length := 0.0
for first_edge in first_edges:
for second_edge in second_edges:
if first_edge["wall"] == second_edge["wall"]:
continue
var a: Vector3 = first_edge["selected"]
var adjacent_a: Vector3 = first_edge["other"]
var c: Vector3 = second_edge["selected"]
var adjacent_c: Vector3 = second_edge["other"]
var edge_a := Vector2(adjacent_a.x - a.x, adjacent_a.z - a.z)
var edge_c := Vector2(adjacent_c.x - c.x, adjacent_c.z - c.z)
var length_a := edge_a.length()
var length_c := edge_c.length()
if length_a < 0.2 or length_c < 0.2:
continue
if edge_a.normalized().dot(edge_c.normalized()) > -0.9:
continue
var center_a := (a + adjacent_a) * 0.5
var center_c := (c + adjacent_c) * 0.5
if Vector2(center_c.x - center_a.x, center_c.z - center_a.z).length() < 0.2:
continue
var diagonal_error := (a + c - adjacent_a - adjacent_c).length()
var length_error := absf(length_a - length_c)
var tolerance := maxf(0.12, maxf(first_edge["thickness"], second_edge["thickness"]) * 0.5)
if diagonal_error > tolerance or length_error > tolerance:
continue
var score := diagonal_error + length_error
if score < best_score - 0.01 or (absf(score - best_score) <= 0.01 and length_a > best_length):
best_score = score
best_length = length_a
best_corners = PackedVector3Array([a, adjacent_a, c, adjacent_c])
return best_corners
static func _matching_top_edges(root: ModularBuilderPiece, world_point: Vector3, allow_center: bool) -> Array[Dictionary]:
var result: Array[Dictionary] = []
var walls: Array[ModularBuilderPiece] = []
_collect_walls(root, walls)
for wall in walls:
var endpoints: PackedVector3Array = wall.call("_get_centered_wall_points") if wall.center_align else wall.custom_points
if endpoints.size() < 2:
continue
var direction := endpoints[1] - endpoints[0]
var horizontal := Vector2(direction.x, direction.z)
if horizontal.length() < 0.2:
continue
var side := Vector3(-horizontal.y, 0.0, horizontal.x).normalized() * wall.thickness * 0.5
for offset in [side, -side]:
var top_a := wall.to_global(endpoints[0] + Vector3.UP * wall.unit_size + offset)
var top_b := wall.to_global(endpoints[1] + Vector3.UP * wall.unit_size + offset)
var center_a := wall.to_global(endpoints[0] + Vector3.UP * wall.unit_size)
var center_b := wall.to_global(endpoints[1] + Vector3.UP * wall.unit_size)
if top_a.distance_to(world_point) <= 0.08 or (allow_center and center_a.distance_to(world_point) <= 0.08):
result.append({"wall": wall, "selected": root.to_local(top_a), "other": root.to_local(top_b), "thickness": wall.thickness})
if top_b.distance_to(world_point) <= 0.08 or (allow_center and center_b.distance_to(world_point) <= 0.08):
result.append({"wall": wall, "selected": root.to_local(top_b), "other": root.to_local(top_a), "thickness": wall.thickness})
return result
static func _collect_walls(node: Node, result: Array[ModularBuilderPiece]) -> void:
if node is ModularBuilderPiece and node.piece_type == ModularBuilderPiece.PieceType.WALL:
result.append(node)
for child in node.get_children():
_collect_walls(child, result)

View file

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

View file

@ -1,32 +0,0 @@
@tool
extends RefCounted
static func piece_vertex(piece: ModularBuilderPiece, world_point: Vector3) -> Vector3:
var local := piece.to_local(world_point)
match piece.piece_type:
ModularBuilderPiece.PieceType.FOUNDATION_SQUARE, ModularBuilderPiece.PieceType.FOUNDATION_TRIANGLE:
local.y = piece.thickness * 0.5 if piece.centered_origin else piece.thickness
ModularBuilderPiece.PieceType.FLOOR:
if not piece.custom_points.is_empty():
local.y = piece.custom_points[0].y
ModularBuilderPiece.PieceType.WALL, ModularBuilderPiece.PieceType.GABLE_WALL, ModularBuilderPiece.PieceType.GABLE_WALL_RIGHT:
var endpoints: PackedVector3Array
if piece.piece_type == ModularBuilderPiece.PieceType.WALL:
endpoints = piece.call("_get_centered_wall_points") if piece.center_align else piece.custom_points
else:
endpoints = piece.call("_get_centered_gable_base") if piece.center_align else piece.custom_points
if endpoints.size() >= 2:
var start := Vector2(endpoints[0].x, endpoints[0].z)
var edge := Vector2(endpoints[1].x, endpoints[1].z) - start
if edge.length_squared() > 0.000001:
var fraction := clampf((Vector2(local.x, local.z) - start).dot(edge) / edge.length_squared(), 0.0, 1.0)
var center := start + edge * fraction
local.x = center.x
local.z = center.y
return piece.to_global(local)
static func pillar_vertex(pillar: ModularBuilderDetail, world_point: Vector3) -> Vector3:
var local := pillar.to_local(world_point)
local.x = 0.0
local.z = 0.0
return pillar.to_global(local)

View file

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

View file

@ -1,7 +0,0 @@
[plugin]
name="Modular Builder (English)"
description="Create snap-together modular building pieces directly in the Godot editor."
author="Michel Savadim"
version="0.2.0"
script="modular_builder_plugin.gd"

View file

@ -1,162 +0,0 @@
@tool
extends RefCounted
## ScopedVR-Bibliothek-Export für den Modular Builder (Karte t_82d2a8dd).
##
## Kern-Logik des „Struktur exportieren → ScopedVR-Bibliothek"-Flows — bewusst
## ALLES statisch und ohne Editor-UI, damit tests/test_builder_export.gd sie
## headless prüfen kann. Der UI-Ablauf (Name-Dialog, Pipeline-Aufrufe, Status)
## lebt in scopedvr_export_ui.gd; der Editor-Button hängt in
## modular_builder_editor_actions.gd (_on_export_pressed → EXPORT_LIBRARY).
##
## Pipeline (wie Fass, docs/object_pipeline.md):
## 1. Mesh normalisieren (Fußpunkt = y 0, XZ zentriert)
## 2. GLB schreiben (GLTFDocument, Texturen eingebettet)
## 3. Trimesh-Vertices (Dreiecks-Suppe, dedupliziert, 1 mm quantisiert)
## 4. tools/objekt mesh → registry (trimesh, --budget) → client → editor
## Die Registry bekommt "shape": "trimesh" + "vertices" + "size" (AABB) —
## der Match-Server-Lader baut daraus ein ConcavePolygonShape3D (t_c949ebf2).
const QUANT := 0.001 # 1 mm — Kollision darf ~1 mm vom visuellen Mesh abweichen
## Name → Objekt-ID: kleinbuchstaben, ä/ö/ü/ß aufgelöst, alles Ungültige → '_',
## führende Ziffern weg, max. 48 Zeichen. Rückgabe "" = Name unbrauchbar.
static func slugify(name: String) -> String:
var s := name.strip_edges().to_lower()
s = s.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
var out := ""
for c in s:
var ok := (c >= "a" and c <= "z") or (c >= "0" and c <= "9") or c == "_"
out += c if ok else "_"
# Führende Unterstriche/Ziffern abstreifen (IDs müssen mit Buchstabe beginnen).
while out.length() > 0 and not (out[0] >= "a" and out[0] <= "z"):
out = out.substr(1)
while out.contains("__"):
out = out.replace("__", "_")
out = out.strip_edges()
while out.ends_with("_"):
out = out.substr(0, out.length() - 1)
if out.length() > 48:
out = out.substr(0, 48)
if out.length() == 0 or not (out[0] >= "a" and out[0] <= "z"):
return ""
return out
## Mesh normalisieren: Kopie, in der der Boden auf y = 0 liegt und das Haus
## um seinen XZ-Mittelpunkt steht — der svmap-Fußpunkt-Vertrag (pos = Boden).
## Rückgabe {mesh: ArrayMesh, offset: Vector3 (angewandter Versatz), size: Vector3 (AABB)}.
## null im Ergebnis-Mesh = nichts zu holen (leeres Mesh).
static func normalisiere(mesh: ArrayMesh) -> Dictionary:
var aabb := mesh.get_aabb()
if aabb.size.length() <= 0.0:
return {"mesh": null, "offset": Vector3.ZERO, "size": Vector3.ZERO}
var offset := Vector3(-aabb.get_center().x, -aabb.position.y, -aabb.get_center().z)
var out := ArrayMesh.new()
out.resource_name = mesh.resource_name
for s in mesh.get_surface_count():
var arrays := mesh.surface_get_arrays(s)
if arrays.is_empty() or not (arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array):
continue
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
for i in verts.size():
verts[i] = verts[i] + offset
arrays[Mesh.ARRAY_VERTEX] = verts
out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
var src_mat := mesh.surface_get_material(s)
if src_mat != null:
out.surface_set_material(out.get_surface_count() - 1, src_mat)
return {"mesh": out, "offset": offset, "size": aabb.size}
## Trimesh-Vertices (Dreiecks-Suppe, Loader-Vertrag svmap_loader._trimesh_faces):
## je 3 Vertices ein Dreieck, aus ALLEN Oberflächen, exakte Doppel-Dreiecke
## (quantisiert) fliegen raus — Decken/Wände aus aneinander liegenden Teilen
## dürfen die Kollisions-Suppe nicht unnötig aufblähen.
static func trimesh_vertices(mesh: ArrayMesh) -> PackedVector3Array:
var seen := {}
var out := PackedVector3Array()
for s in mesh.get_surface_count():
var arrays := mesh.surface_get_arrays(s)
if arrays.is_empty() or not (arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array):
continue
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
# ARRAY_INDEX ist null (nicht PackedInt32Array), wenn das Mesh nicht
# indiziert ist — der direkte Cast bricht sonst die ganze Funktion ab.
var idx := PackedInt32Array()
if arrays[Mesh.ARRAY_INDEX] is PackedInt32Array:
idx = arrays[Mesh.ARRAY_INDEX]
var tri_count := 0
if not idx.is_empty():
tri_count = idx.size() / 3
else:
tri_count = verts.size() / 3
for t in tri_count:
var a: Vector3
var b: Vector3
var c: Vector3
if not idx.is_empty():
a = verts[idx[t * 3]]
b = verts[idx[t * 3 + 1]]
c = verts[idx[t * 3 + 2]]
else:
a = verts[t * 3]
b = verts[t * 3 + 1]
c = verts[t * 3 + 2]
var key := _dreieck_schluessel(a, b, c)
if key.is_empty():
continue # Entartet (Fläche 0 in min. einer Komponente).
if seen.has(key):
continue
seen[key] = true
out.append_array(PackedVector3Array([a, b, c]))
return out
## Sortierter Quantisierungs-Schlüssel eines Dreiecks (unabhängig von der
## Vertex-Reihenfolge) — "" bei entarteten Dreiecken (zwei identische Punkte).
static func _dreieck_schluessel(a: Vector3, b: Vector3, c: Vector3) -> String:
var pts := [_quant(a), _quant(b), _quant(c)]
if pts[0] == pts[1] or pts[1] == pts[2] or pts[0] == pts[2]:
return ""
pts.sort()
return "%s|%s|%s" % [pts[0], pts[1], pts[2]]
static func _quant(v: Vector3) -> String:
return "%d,%d,%d" % [roundi(v.x / QUANT), roundi(v.y / QUANT), roundi(v.z / QUANT)]
## Mesh als GLB-Bytes (Texturen eingebettet, image/png) — über GLTFDocument,
## funktioniert headless und im Editor (Probe: tests/test_builder_export.gd).
## Leere Bytes = Export fehlgeschlagen.
static func glb_bytes(mesh: ArrayMesh, name_hint := "Struktur") -> PackedByteArray:
if mesh == null:
return PackedByteArray() # Kein Mesh → Export fehlschlagen, kein Engine-Error.
var root := Node3D.new()
root.name = name_hint
var mi := MeshInstance3D.new()
mi.name = "BakedMesh"
mi.mesh = mesh
root.add_child(mi)
var state := GLTFState.new()
var doc := GLTFDocument.new()
var err := doc.append_from_scene(root, state)
if err != OK:
return PackedByteArray()
var bytes := doc.generate_buffer(state)
root.free()
return bytes
## GLB-Bytes grob prüfen (glTF-Magic + Version + JSON-Chunk) — reicht für die
## Selbst-Verifikation VOR dem Pipeline-Aufruf; die Dreiecks-Zählung macht
## tools/objekt eh noch mal selbst.
static func glb_gueltig(bytes: PackedByteArray) -> bool:
if bytes.size() < 20:
return false
if bytes.decode_u32(0) != 0x46546C67 or bytes.decode_u32(4) != 2:
return false
if bytes.decode_u32(16) != 0x4E4F534A:
return false
return 20 + bytes.decode_u32(12) <= bytes.size()

View file

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

View file

@ -1,250 +0,0 @@
@tool
extends RefCounted
## UI-Ablauf des ScopedVR-Bibliothek-Exports (Karte t_82d2a8dd): Name-Dialog →
## Mesh normalisieren → GLB + Trimesh-Vertices → tools/objekt (Match-Server-Repo)
## → Thumbnail → Status.
##
## Bewusst getrennt von scopedvr_export.gd (Kern, statisch/testbar) — dieses
## 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 BUDGET := 20000 # Export-Strukturen (match-server BUILDER_BUDGET)
const THUMB_SIZE := 128
var _plugin: EditorPlugin
var _status: Callable
var _building: Node3D
var _mesh: ArrayMesh
var _dialog: AcceptDialog
var _name_edit: LineEdit
## Startet den Flow. building = gewählte Struktur, mesh = gebackenes Mesh
## (aus modular_mesh_baker), status = Callable(label: String) für das Dock-Statusfeld.
func starten(plugin: EditorPlugin, building: Node3D, mesh: ArrayMesh, status: Callable) -> void:
_plugin = plugin
_building = building
_mesh = mesh
_status = status
if _dialog != null:
_dialog.queue_free()
_dialog = AcceptDialog.new()
_dialog.title = "Struktur exportieren — ScopedVR-Bibliothek"
_dialog.ok_button_text = "Exportieren"
var vbox := VBoxContainer.new()
vbox.custom_minimum_size = Vector2(380, 0)
var hinweis := Label.new()
hinweis.text = "Name der Struktur (wird zur Objekt-ID: kleine Buchstaben,\nZahlen, Unterstrich — z. B. „Bauernhaus Rot\" → haus_bauernhaus_rot... \nrichtig: „Bauernhaus rot\" → bauernhaus_rot).\n\nDer Export durchläuft die Objekt-Pipeline (Mesh, Registry,\nClient, Editor) — der Client-Import dauert einen Moment."
hinweis.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
vbox.add_child(hinweis)
_name_edit = LineEdit.new()
_name_edit.text = str(building.name)
vbox.add_child(_name_edit)
_dialog.add_child(vbox)
_dialog.confirmed.connect(_on_name_ok)
plugin.add_child(_dialog)
_name_edit.text_submitted.connect(func(_t: String) -> void:
_dialog.hide()
_on_name_ok())
_name_edit.call_deferred("grab_focus")
_dialog.popup_centered()
func _on_name_ok() -> void:
var name_roh := _name_edit.text
var oid := KERN.slugify(name_roh)
if oid.is_empty():
_status.call("FEHLER: Aus dem Namen „%s\" wird keine gültige Objekt-ID — bitte Buchstaben verwenden." % name_roh)
return
_status.call("Export läuft: „%s\" → %s (Pipeline: Mesh → Registry → Client → Editor) ..." % [name_roh, oid])
# Einen Frame abwarten, damit der Status sichtbar wird, bevor OS.execute blockiert.
await _plugin.get_tree().process_frame
await _export(oid, name_roh)
func _export(oid: String, name_roh: String) -> void:
var server_dir := _match_server_dir()
if server_dir.is_empty():
_status.call("FEHLER: Match-Server-Repo nicht gefunden — erwartet ../scopedvr-match-server neben dem Map-Editor (oder SCOPEDVR_MATCH_SERVER_DIR).")
return
var objekt_tool := server_dir + "/tools/objekt"
# 1) Normalisieren (Fußpunkt y=0, XZ zentriert).
var norm: Dictionary = KERN.normalisiere(_mesh)
var mesh: ArrayMesh = norm.get("mesh")
if mesh == null:
_status.call("FEHLER: Das gebackene Mesh ist leer — nichts zu exportieren.")
return
var size: Vector3 = norm["size"]
var faces := KERN.trimesh_vertices(mesh)
if faces.is_empty():
_status.call("FEHLER: Keine Kollisions-Dreiecke im gebackenen Mesh.")
return
# 2) GLB + Vertices temporär schreiben.
var tmp_dir := _tmp_dir()
var glb_path := tmp_dir + "/scopedvr_export_%s.glb" % oid
var verts_path := tmp_dir + "/scopedvr_export_%s.json" % oid
var glb := KERN.glb_bytes(mesh, oid)
if glb.is_empty() or not KERN.glb_gueltig(glb):
_status.call("FEHLER: GLB-Export fehlgeschlagen (GLTFDocument).")
return
var gf := FileAccess.open(glb_path, FileAccess.WRITE)
if gf == null:
_status.call("FEHLER: Konnte %s nicht schreiben." % glb_path)
return
gf.store_buffer(glb)
gf.close()
var vertices_liste: Array = []
for v in faces:
vertices_liste.append([_r(v.x), _r(v.y), _r(v.z)])
var verts_json := JSON.stringify({
"vertices": vertices_liste,
"size": [_r(size.x), _r(size.y), _r(size.z)],
})
var vf := FileAccess.open(verts_path, FileAccess.WRITE)
if vf == null:
_status.call("FEHLER: Konnte %s nicht schreiben." % verts_path)
return
vf.store_string(verts_json)
vf.close()
# 3) Pipeline-Schritte 1–2 (Mesh + Registry). Fehler = sofort raus, klare Meldung.
var schritt1 := _lauf(objekt_tool, ["mesh", oid, glb_path, "--budget", str(BUDGET)])
if schritt1 != "":
_status.call(schritt1)
return
var schritt2 := _lauf(objekt_tool, ["registry", oid, name_roh.strip_edges(), "trimesh", "--vertices", verts_path, "--budget", str(BUDGET)])
if schritt2 != "":
_status.call(schritt2)
return
# 4) Client-Sync (kopiert GLB + Registry in den Client, Import-Probe).
_status.call("Export läuft: Client-Import (dauert einen Moment) ...")
var schritt3 := _lauf(objekt_tool, ["client", oid])
if schritt3 != "":
_status.call(schritt3)
return
# 5) Thumbnail AUS DEM ECHTEN MESH rendern (bevor der Editor-Sync kommt —
# gen_thumbs überspringt Trimesh-Objekte mit vorhandener Datei).
var editor_dir := _editor_dir()
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")
# 6) Editor-Sync (Registry-Kopie; vorhandene Thumbnails bleiben stehen).
var schritt4 := _lauf(objekt_tool, ["editor", oid])
if schritt4 != "":
_status.call(schritt4)
return
var vis_tris := 0
var tris := int(faces.size() / 3)
for s in mesh.get_surface_count():
var arrays := mesh.surface_get_arrays(s)
if arrays.is_empty():
continue
# ARRAY_INDEX ist null bei nicht-indizierten Oberflächen (kein Cast!).
var idx := PackedInt32Array()
if arrays[Mesh.ARRAY_INDEX] is PackedInt32Array:
idx = arrays[Mesh.ARRAY_INDEX]
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
vis_tris += (idx.size() / 3) if not idx.is_empty() else (verts.size() / 3)
_status.call("OK: „%s\" als '%s' exportiert (poly %d, Trimesh %d Dreiecke, Budget %d). Objekt liegt in Bibliothek + Client — Einchecken nicht vergessen (git add in 3 Repos)." % [name_roh, oid, vis_tris, tris, BUDGET])
_aufraeumen([glb_path, verts_path])
func _aufraeumen(pfade: Array) -> void:
for p in pfade:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
func _lauf(objekt_tool: String, args: PackedStringArray) -> String:
var out := []
var code: int = OS.execute(objekt_tool, args, out, true)
var text := "\n".join(out)
if code == 0:
return ""
# Die FEHLER-Zeile des Werkzeugs durchreichen (letzte gewinnt).
var fehler := "Pipeline fehlgeschlagen (Exit %d)." % code
for zeile in text.split("\n"):
if str(zeile).begins_with("FEHLER: "):
fehler = str(zeile)
elif str(zeile).begins_with("FEHLER:"):
fehler = str(zeile)
return fehler
func _match_server_dir() -> String:
var env := OS.get_environment("SCOPEDVR_MATCH_SERVER_DIR")
if not env.is_empty() and DirAccess.dir_exists_absolute(env):
return env.rstrip("/")
var root := ProjectSettings.globalize_path("res://").rstrip("/")
var cand := root.path_join("../scopedvr-match-server")
if DirAccess.dir_exists_absolute(cand):
return cand
return ""
func _editor_dir() -> String:
var env := OS.get_environment("SCOPEDVR_EDITOR_DIR")
if not env.is_empty() and DirAccess.dir_exists_absolute(env):
return env.rstrip("/")
return ProjectSettings.globalize_path("res://").rstrip("/")
func _tmp_dir() -> String:
var t := OS.get_environment("TMPDIR")
if t.is_empty():
t = "/tmp"
return t.rstrip("/")
static func _r(v: float) -> float:
return snappedf(v, 0.0001)
## Thumbnail aus dem Mesh rendern (Editor-Rendering vorausgesetzt) —
## gleicher Stil wie tools/gen_thumbs.gd (128 px, dunkler Hintergrund).
func _thumbnail_bilden(mesh: ArrayMesh) -> Image:
var vp := SubViewport.new()
vp.size = Vector2i(THUMB_SIZE, THUMB_SIZE)
vp.own_world_3d = true
vp.render_target_update_mode = SubViewport.UPDATE_ONCE
_plugin.add_child(vp)
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.16, 0.18, 0.21)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(1, 1, 1)
env.ambient_light_energy = 0.7
var we := WorldEnvironment.new()
we.environment = env
vp.add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-55.0, 35.0, 0.0)
sun.light_energy = 1.1
vp.add_child(sun)
var mi := MeshInstance3D.new()
mi.mesh = mesh
vp.add_child(mi)
var cam := Camera3D.new()
cam.current = true
vp.add_child(cam)
var aabb := mesh.get_aabb()
var center := aabb.get_center()
var radius := aabb.size.length() * 0.5
var dir := Vector3(1.0, 0.65, 1.0).normalized()
cam.position = center + dir * (radius * 2.3 + 0.3)
cam.look_at(center, Vector3.UP)
# Ein Frame Rendering abwarten, dann auslesen und aufräumen.
await _plugin.get_tree().process_frame
await _plugin.get_tree().process_frame
var img := vp.get_texture().get_image()
vp.queue_free()
return img

View file

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

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.

View file

@ -1,29 +0,0 @@
[preset.0]
name="Linux"
platform="Linux"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="models/*"
export_path="export/MapEditor.x86_64"
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
binary_format/embed_pck=false
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
binary_format/architecture="x86_64"
ssh_remote_deploy/enabled=false

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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