Compare commits

...

169 commits

Author SHA1 Message Date
slyphax
313500b988 Kern-Stand nachgezogen, Test für Terrain in Stücken, Loadout-Sync-Pfad
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 20:27:35 +01:00
slyphax
8f01775a92 Editor-Code liegt jetzt komplett im Kern (res://core), PC-App startet core/scenes/main.tscn; Addon nutzt Bau-Geometrie aus dem Kern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:34:05 +01:00
slyphax
295f79011f Werkzeuge hängen nicht mehr an der Maus: PointerEvent, MousePointer, PointerRouter; Test Maus gegen Zeiger
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:26:17 +01:00
slyphax
949d73dfdd Map-Kern ausgelagert: scopedvr-map-core als Submodul unter res://core, Update holt ihn mit; Test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:37:09 +01:00
slyphax
e8ab954876 Editor: Blickrichtung für Spawn-Punkte und Conquest-Zonen (yaw_deg, optional): Pfeil im Bild, Spitze ziehen oder Zahl im Panel, Standard-Knopf, Undo, Symmetrie spiegelt mit, Playtest nutzt sie; Prüfung; Test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 13:00:02 +01:00
slyphax
ff1c0e345e Datei-Menü: Hand-Haltung der Waffen startet das Hand-Haltungs-Tool aus dem Client-Ordner (importiert nach neuem Client-Stand im Hintergrund); Test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:02:36 +01:00
slyphax
91c66a0b98 Loadout-Bereich: Granaten und Minen pro Karte festlegen (V18, Undo, auch im Spieler-Modus, gilt für Bots); Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkB4i3mQaxfg6aEncovy2Q
2026-10-31 01:55:45 +01:00
slyphax
330365e59b Loadout-Auswahl: Holo- und Schrägvisier klarer benannt (Ring mit Punkt, seitlich) neben dem Rotpunktvisier
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 01:52:21 +01:00
slyphax
9589703f7c Loadout-Auswahl: Rotpunktvisier und andere Visiere mit deutschem Namen statt Teil-ID; Tests für Auswahl, Speichern und V18
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 01:00:32 +01:00
slyphax
b04a953190 Tab Server: Server neu starten mit Bestätigung und Warten auf den neuen Prozess, Bot-Warnung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:08:06 +01:00
slyphax
48db200d3e Server-Sektion im Editor: Client der Admin-API (Server speichern mit verschluesseltem Token, Uebersicht, Einstellungen, Spieler kicken/sperren, Map laden/loeschen, Rotation, Logs) und 'Aktuelle Map auf Server pushen' mit verstaendlicher 422-Fehlerliste; SvmapIO.to_signed_text; Tests inkl. E2E gegen echten Server
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 00:04:11 +01:00
slyphax
92e2c688c1 Spielmodi im Editor: Checkpoints → Conquest (Alt-Maps werden beim Laden umgestellt), King of the Hill mit Hill-Liste + Reihenfolge, Deathmatch, eigene Spawns je Modus (mode_spawns, Spawn-Satz-Auswahl, Undo), V21–V24
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 20:38:54 +01:00
slyphax
f42e133bae Spielmodus-Auswahl beim Neu-Erstellen + Checkpoint-Zonen im Map-Tab (V21/V22, Undo, 3D-Anzeige)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 09:21:13 +01:00
slyphax
bb9eb71770 Map-Loadout: Loadout-Bereich im Map-Tab (Spieler-Loadout oder festes Map-Loadout mit Aufsaetzen), V18, Undo/Redo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 09:10:16 +01:00
slyphax
8345903ce7 Container: offene Varianten (eine Tür offen / beide Türen offen / ohne Türen), 8 Farben, Innenseiten mit Paletten-Textur + Trimesh-Kollision
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:42:09 +01:00
slyphax
902181500a Drag&Drop aus der Bibliothek: Ablege-Fläche über der 3D-Ansicht (echter Maus-Drag getestet), Drag-Vorschau, Doku
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 22:50:37 +01:00
slyphax
bcbd416960 Bauen: Geschoss-Wahl (Decken mitten im Raum), 1 cm Wand-Überstand gegen Z-Fighting, Dach aus Einzelteilen, größeres Einstellungen-Fenster
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 22:47:15 +01:00
slyphax
5e32c771fc Revert "Bauen: begehbare Vorlagen (Kaserne, Wachhaus, Lagerraum) per Klick, ein Undo-Schritt"
This reverts commit 961e8c6ff6.
2026-10-25 22:37:24 +01:00
slyphax
961e8c6ff6 Bauen: begehbare Vorlagen (Kaserne, Wachhaus, Lagerraum) per Klick, ein Undo-Schritt
Pack-Kaserne/-Checkpoint/-Container sind innen geschlossen; die Vorlagen sind
begehbare Gebäude aus bau_*-Teilen. Test beweist per Strahl durch die echte Kollision.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 22:00:49 +01:00
slyphax
075735f426 Laden: gelöschte Objekte (Pilze) beim Öffnen ersetzen/überspringen statt Abbruch (test.svmap wieder ladbar)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 21:53:40 +01:00
slyphax
fa2feee780 Thumbnails für die großen Dächer/Giebelwände (4 m, 6 m)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 20:32:53 +01:00
slyphax
3f0d0f3f33 Map-Editor: UI aufgeräumt, Auswahl-Sync, Drag&Drop, Bauen (Wände bündig, Geschosshöhe, große Dächer)
- Tabs Auswahl/Prüfen/Test-Start/Einstellungen/Symmetrie weg; Spawn-Zonen, Test-Start, Symmetrie im Map-Tab; Einstellungen-Fenster (Gitter, Höhenlinien, Symmetrie-Modus, UI-Größe, Raster-Snap, Gizmo-Schritt, Karte prüfen)
- Bauen-Tab nur Bauteile; Objekt-Werkzeuge entfernt
- Klick in der Map-Inhalt-Liste wählt in der Szene aus (SELECT_MULTI feuert nur multi_selected)
- Objekt-Kachel per Ziehen in die Szene setzen
- Wände bündig in der Zelle, gleiche Geschosshöhe mit/ohne Decke, Dächer/Giebelwände 4 m und 6 m

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 20:32:32 +01:00
slyphax
ab9a798343 Thumb-Importdateien der bau_*-Bauteile nachgetragen 2026-10-25 16:32:21 +01:00
slyphax
491a47d3f1 Editor-Bibliothek auf das Military-Desert-Pack umgestellt
- 423 Objekte in 19 deutschen Kategorien (Panzer, Radfahrzeuge, Flugzeuge, Gebaeude,
  Zelte, Tuerme, Mauern, Sperren, Kisten, Technik, Felsen, Boden, Decals ...);
  Gruppen "Bauwerke" und "Kisten & Barrieren" samt Test-Asset-Namenslisten entfernt
- LibraryBrowser: Gruppen/Namen aus dem Manifest (kategorie, name_de), unbekannte
  Kategorien bekommen automatisch eine eigene Gruppe
- Thumbs neu, library/ synchronisiert (inkl. neuer Kollision)
- Neue Identitaetsprobe: Kollision ALLER Objekte Editor = Client = Server
- Tests auf Pack-Objekte umgestellt

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 16:31:28 +01:00
slyphax
a76c3ecae7 Bauen-Modus neu: Bauteile als normale Objekte (bau_*), Snap an Kante/Zelle/Ebene, Gebäude = Gruppe
- BauKatalog: 12 Teile (Fundament, Boden, Wände mit Tür/Fenster/Scharte, Brüstung, Pfeiler, Giebel, Dächer, Treppe) auf Basis der CC0-Geometrie des Modular Builder 0.2.0, Wandhöhe 3 m
- BuildSnap: Wände auf Zellkanten, Böden in Zellmitte, Höhe aus Ebenen statt Klickhöhe, Ersetzen (Tür in Wand)
- Gebäude kopieren/auswählen/löschen im Bauen-Tab, Kopien bekommen eigene Gruppe
- Kollision serverkonsistent über Trimesh-Dateien (Türen/Fenster echte Löcher), tools/gen_bauteile.gd erzeugt GLB, Kollision, Registry-Fragment
- Alte gebaeude-Sektion wird beim Laden migriert, altes Bauen-Werkzeug entfernt, Resize behält Gruppennamen
- Tests: test_bauen (258 Checks), Playtest läuft Treppe/Tür

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:30:15 +01:00
slyphax
fcc7f16f1c Skybox-Auswahl im Map-Panel: 13 neue Himmel, Vorschau, Undo/Redo, V17 über Katalog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:30:38 +01:00
slyphax
e3a6b2ad21 UI-Nachbesserung: Statuszeile eine Zeile, Ring-Drehen (Variablen-Schatten im Gizmo), Tab-Klick aktiviert Werkzeug, Panels offen 2026-10-25 11:20:24 +01:00
slyphax
35a047cc2a Ringe dicker, neuer Test in run_tests 2026-10-25 10:34:37 +01:00
slyphax
93cdcb0cd4 3D-Werkzeugleiste, Dreh-/Kipp-Gizmo um alle Achsen mit Undo, Test 2026-10-25 10:33:37 +01:00
slyphax
59c932dc35 UI: Info-Zeile kompakt, kurze Viewport-Hinweise 2026-10-25 10:32:01 +01:00
slyphax
3094395444 UI: Erklärtexte raus, Kontrast-Fix (Theme-Farben), größere Bibliothek-Kacheln 2026-10-25 10:31:37 +01:00
slyphax
3d083648d8 UI: Terrain/Malen/Bauen als eigene Tabs, Material-Vorschau, neue Hilfe nach Werkzeugen, Hilfe-Knopf 2026-10-25 10:30:06 +01:00
slyphax
8cde37b85c Updater: Klon vor/auf anderem Branch ist kein Update mehr, Pull gezielt origin main 2026-10-25 10:25:55 +01:00
slyphax
26bcbe0cdd Terrain-Pinsel: Kante-Regler (Falloff), Einebnen, Rampe, Druck-Stärke, Mal-Slot-Ersatz
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 09:52:03 +01:00
slyphax
b1d8f7626d Terrain-Shader: detail_kontrast (1,3) gegen blasse Büschel nach Hex-Tiling
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 09:50:20 +01:00
slyphax
a1f386f5e6 Terrain-Shader: Hex-Tiling + Makro-Variation statt Raster-Kachel (Büschel nicht mehr im Raster, keine Diagonalstreifen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 09:42:40 +01:00
slyphax
99a06c6cb8 Boden-Texturen: 3-m-Kachel + Anti-Wiederholung, „Gras“ heißt jetzt „Steppe“
Sly 25.10.: Texturen wiederholen sich zu krass, „Gras“ sieht nicht nach Gras aus.
- Kachel 3 m statt 1 m; zweite Abtastung gedreht + 2,3× größer, großflächiges
  Rauschen mischt beide, zweites Rauschen variiert die Helligkeit (Spiegel Client).
- Material 0 (gras_steppe) heißt im Editor „Steppe“ (Format unverändert);
  Hilfetexte nachgezogen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 01:05:07 +02:00
slyphax
9f2a32df70 Terrain-Shader: Ganzzahl-Hash statt sin()-Hash im Kanten-Warp (gerade harte Kanten weg)
Sly 25.10.: nah gezoomt gerade, rechteckige harte Kanten in den Verläufen.
Ursache: sin() großer Argumente ist auf der GPU ungenau, die Warp-Noise
bekam rasterförmige Sprünge. Mit malentest.svmap reproduziert (ohne Warp
weg, mit Ganzzahl-Hash weg, Warp bleibt organisch).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 00:58:08 +02:00
slyphax
5a70824dd5 Malen mit Symmetrie: Überlappung beider Seiten nimmt den stärkeren Anteil (stetig statt Mittelsenkrechten-Kante)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 00:55:18 +02:00
slyphax
7f06e28472 Malen mit Symmetrie: nur wirklich bemalte Punkte spiegeln (harte gerade Kanten weg)
Sly 25.10.: „teilweise sehr harte Kanten in den Übergängen“. Ursache: bei
aktiver Symmetrie kopierte jeder Mal-Schritt das GANZE Snapshot-Quadrat um
den Pinsel auf die Gegenseite — dort vorhandene weiche Verläufe wurden an
den Quadratkanten gerade abgeschnitten. Jetzt werden nur Punkte gespiegelt,
die der Schritt verändert hat; überlappen beide Seiten, gewinnt wie bisher
der näher am Strichpunkt liegende Wert.
Test: test_terrain_mirror 109/109 (neuer Fall schlägt mit altem Code fehl).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 00:50:09 +02:00
slyphax
4e973795f6 Malen: echter weicher Übergang am Pinselrand (Misch-Schicht statt Streuung)
Sly 25.10.: „weich“ soll ein Textur-Verlauf am Pinselrand sein, nicht fleckig;
gerade Strichkanten wirkten weiter hart (1-m-Übergang).
- Format (optional, alte Karten gültig): materials_b_r8_b64 (zweites Material
  je Punkt) + materials_w_r8_b64 (Anteil 0–255); ohne Mischung fallen die
  Felder weg. V15 prüft Länge/IDs/Paarigkeit.
- TerrainField.apply_material_brush_mix: innen voll, außen Smoothstep-Anteil;
  nur verstärken, nie abschwächen; Gras drübermalen mischt zurück.
- Shader: Top-2 aus 4 Nachbarpunkten × (a, b) mit Anteilen → nur noch 2
  Albedo-Fetches; Warp bleibt.
- Pinsel/Live-Vorschau, Spiegeln, Symmetrie-Analyse/-Anwenden, Resize,
  Anheben, Undo (terrain-Snapshot) tragen die Misch-Schicht mit.
- Streu-Rand (vorheriger Commit) entfernt.
- Tests: test_mal_fein 30/30 (Verlauf fällt weich, nie abschwächen,
  zurückmischen, Kodierung), Editor-Suite unverändert grün bis auf die
  bekannten Umgebungs-Tests; Kette Editor → Server → Client per
  run_map_download_e2e 15/15 mit gemischter Karte.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 00:43:26 +02:00
slyphax
4a7b99ee3e Malen: Regler „Kante hart ↔ weich“ + Pinsel ab 1 m
Sly 25.10.: harte Kanten an Strichrändern, Pinsel auf 1 m, Regler für die
Kantenschärfe.
- apply_material_brush(…, weichheit): im äußeren Anteil des Radius wird
  gestreut ausgedünnt (fester Hash je Punkt → deterministisch, Nachmalen
  füllt nicht auf); der Shader macht daraus einen ausfransenden Übergang.
  Kein Formatwechsel (ein Material je Punkt bleibt).
- Panel: Regler 0–100 % (Default 40 %), nur im Malen-Modus; Pinselgröße ab 1 m.
- test_mal_fein 26/26 (Dichte fällt nach außen, deterministisch, 1-m-Pinsel),
  test_terrain_mirror an die Tool-Weichheit angepasst.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 00:30:16 +02:00
slyphax
40ca5ab6ba Terrain-Malen: weiche, organische Kanten statt 1-m-Pixelblöcke
Sly 25.10.: „immer noch harte Kanten beim Malen“. Ursache: die Splat-Textur
hatte beim feinen 1-m-Raster genau einen Texel je Materialpunkt und wird
nearest gesampelt → jede Zelle ein harter Block.

- Shader liest die Material-ID-Karte (R8, ein Texel je Punkt, texelFetch)
  und mischt PRO PIXEL bilinear über die 4 Nachbarpunkte.
- Leichter Domain-Warp (Value-Noise in Rasterzellen, eine Oktave) verwackelt
  die Abtaststelle → keine Rastertreppen, natürliche Kante.
- Malen aktualisiert nur noch die Bytes der Karte (kein Splat-Neuberechnen).
- TerrainArrays.material_image (Spiegel-Datei, identisch im Client).
- Render-Probe tests/probe_mal_weich_render.gd (vorher/nachher geprüft).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 00:23:18 +02:00
slyphax
69668eb250 Feines Material-Raster: Malen auf ~1 m statt Höhenraster (bei 1024 m vorher 8 m)
Sly: „feiner malen“. Die Material-Schicht bekommt ein eigenes Raster
(terrain.material_resolution, optional; Default = resolution → alte Karten
unverändert gültig): ~1 m pro Punkt, auf dem Höhenraster ausgerichtet,
max. 1025 (1024 m → 1025, 256 m → 257, 2048 m → 1025 = 2 m).

- SvmapIO: material_resolution / material_resolution_for / resample /
  upgrade_material_layer / material_at_height_point; V15 prüft Raster + Länge.
- Neue Karten starten fein; beim Öffnen werden alte Karten optisch identisch
  angehoben (Datei ändert sich erst beim Speichern); Resize rechnet die
  Schicht auf ihrem eigenen Raster an der Weltposition um.
- Pinsel, Spiegeln (Symmetrie live + „Symmetrie anwenden“) und Live-Splat
  arbeiten auf dem Material-Raster; Vertex-Farben lesen den passenden Punkt.
- Malen-Hinweis zeigt die echte feinste Einheit.
- Tests: test_mal_fein 22/22 (Raster, Anheben, 2-m-Pinsel ≈ 13 Punkte,
  V15, Roundtrip, Resize); test_terrain/test_terrain_mirror/
  test_symmetry_onclick auf das neue Raster nachgezogen; Identitäts-Runner OK.
  Kette Editor → Server (Prüfsumme) → Client per run_map_download_e2e 15/15.

Format-Spiegel: Match-Server svmap_loader (V15) + Client svmap_terrain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 00:07:25 +02:00
slyphax
66df3bdc1e Editor: Klick wählt das exakt getroffene Objekt (Mauer statt Nachbar-Gebäude) + Malen-Hinweis statt Stärke-Regler
- _pick_object: Kugel-Näherung nur noch Vorfilter, entschieden wird über den
  echten Mesh-Treffer (SelectionMath.ray_mesh_distance, Faces gecacht).
  Ohne Mesh-Treffer bleibt die alte Kugel-Wahl als Fallback.
- Terrain-Panel: im Malen-Modus ist der Stärke-Regler weg (Malen setzt das
  Material zellenweise, Stärke hatte nie eine Wirkung); stattdessen steht die
  feinste Mal-Einheit der Karte da (size / (resolution - 1)).
- Tests: test_klick_exakt 6/6 (reproduziert Gebäude-Kugel vor Mauer),
  test_mal_hinweis 4/4. Vorhandene Suiten unverändert (4 schon vorher rote
  Umgebungs-Tests: assets_panel, identitaet_client, model_sources,
  playtest_identitaet).

Ersetzt die nie gepushten Arbeitskopie-Änderungen der Karten t_12feca66 /
t_c487a430 / t_271a77bd (Material-IDs per lerp gemischt, Sub-Zellen ohne
Client/Server-Unterstützung).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 23:27:48 +02:00
Hermes (scopedvr-dev)
1d8763b881 Karte t_5c94d098 Terrain-Blending v2: Splat-Texel-Dichte folgt der KARTENGROESSE
Gleiche Ursache wie im Client (BEWEISE.md in work/terrain_blending_v2_t_5c94d098_bilder/):
splat_side_for(129) = 512 px = 2 m/Texel auf der 1024-m-Standardmap (8 m/Zelle)
-> Gewichtsrampe in 2-m-Stufen, sichtbar als Baender/Stufen an der Materialgrenze.

Aenderung Editor:
- library/terrain_arrays.gd (Spiegel-Datei, byte-identisch zum Client):
  splat_side_for(res, map_size_m = 0.0) = MAX(4 Texel/Zelle, 1 m/Texel aus der
  Karten-Kantenlaenge), Zweierpotenz, geklemmt [256, 1024]; build_splat_bytes
  ohne Allokation je Texel + Schnellpfad (gleiche ID in allen vier Nachbarzellen)
  -> unveraenderte Bytes, deutlich schneller.
- src/viewport/terrain_mesh.gd: _build_splat bekommt size_x/size_z und gibt sie an
  splat_side_for; der Live-Sub-Rect-Pfad greift nur noch, wenn auch die
  Kantengroesse stimmt (Size-Aenderung ohne res-Aenderung baut die Splat neu).
- tests/test_terrain_splat.gd (Spiegel, identisch im Client): +6 Kartenmass-Checks
  (31/31).

Suiten (frischer Forgejo-Klon): test_terrain_splat 31/31, test_terrain 155/155,
test_terrain_mirror 106/106.
2026-10-22 19:37:30 +00:00
Hermes (scopedvr-dev)
04c6c67ba8 Karte t_7a0a6b79 Terrain-Qualitaet: Splat-Top-2-Blend (weiche Uebergaenge), Live-Sub-Rect beim Malen, Ausbrennen-Fix (Shading-Normalisierung) 2026-10-21 22:12:04 +00:00
Hermes (scopedvr-dev)
f5a4ab54d0 Wasser pro Map (t_28fdef03): V20-Validator, Panel-Regler, Vorschau-Renderer
- src/svmap/svmap_io.gd: _validate_wasser (V20) — optional, endliche Zahl,
  Terrain-Hoehenbereich +/-10 m; identisch zum Match-Server
- src/ui/panels/map_panel.gd: Regler 'Wasserhoehe' mit Sofort-Rebuild;
  ganz links (-10) = Feld ENTFERNEN (Legacy-sauber, keine Migration)
- src/viewport/map_view_builder.gd: _build_wasser nach _build_terrain
  (Senken fuellen sich); Legacy ohne Feld = kein Wasser-Node
- src/viewport/terrain_mesh.gd: Matsch-Ring in relief_color (Ufer-Faktor,
  dieselbe Formel wie der Client)
- library/svmap_wasser.gd + src/viewport/wasser.gdshader: Spiegel-Modul,
  byteidentisch zum Client (Identitaets-Vertrag der Vorschau)
- tests/test_wasser.gd: 33 Checks (W1 V20, W2 Panel, W3 Vorschau + Matsch-Mathe, W4 Spiegel)

Verifiziert: Editor-Suite 33/33, test_editor_svmap 31, test_terrain 155,
test_gui_smoke 92, test_skybox 23 — alle OK
2026-10-21 06:13:59 +00:00
Hermes (scopedvr-dev)
8468b89456 Terrain-Textur-Panel + Editor-Spiegel (t_ab408127): Terrain-Panel zeigt die 20 CC0-Materialien; terrain_mesh bindet terrain_arrays.res (Spiegel aus Client via tools/sync_terrain_tex.sh, Ordner OHNE .gdignore — ResourceLoader braucht den Index); TerrainArrays-Klasse spiegelgleich in library/ (identischer Pfad wie im Client — .res-Script-Pfad löst in beiden Repos auf); Kontur-Shader bekommt Textur-Sampling + Höhenlinien; V15-Test auf neue Palette (ID 20 abgelehnt, 3 gültig); test_terrain 155/155 2026-10-21 00:48:41 +00:00
Hermes (scopedvr-dev)
338e7152a0 ALLES Trimesh (t_ff7fc919): Registry + 250 Kollisions-Suppen synchron; KolliderBau.hindernis_aabb aus der Suppe (Spiegel Client/Server); Identitäts-Probe um Trimesh-Fälle erweitert (12 IDENT-Zeilen, ALL OK) 2026-10-20 22:58:42 +00:00
Hermes (scopedvr-dev)
42a205b6ff Platzierungsfreiheit (t_e166e656): Scale-Zeile entfernt (Groesse fix), Kippen X/Z (rot-Feld, Strg+R/Alt+R, Ghost + Platzierung), Versenken unter Terrain erlaubt + Tiefe-Anzeige, Spiegel-Mathe 3 Achsen, KolliderBau rot + hindernis_aabb, Suiten + Identitaets-Runner 2026-10-20 22:32:10 +00:00
Hermes (scopedvr-dev)
78d311c75f Atmosphaere & Skybox (t_f72913d4): Render-Probe Editor-Vorschau (Panorama vs. Prozedural, xvfb/opengl3) 2026-10-20 22:11:30 +00:00
Hermes (scopedvr-dev)
0505463ace Atmosphaere & Skybox (t_f72913d4): Map-Option „Himmel: Pack-Skybox“ (weather_sky=sky_panorama, checksum-neutral), Vorschau = 1:1-Spiegel Client-Wetter (ACES/Fog/sun_deg/Panorama via Nachbar-Klon), V17-Validator; Suite test_skybox 23/23 2026-10-20 22:10:36 +00:00
Hermes (scopedvr-dev)
35a1d80d3e svmap: Prüfsummen-Repair-Dialog „Trotzdem laden“ (t_9814f18f) 2026-10-20 22:05:53 +00:00
Hermes (scopedvr-dev)
65b33a207a svmap: Prüfsummen-Schema v2 (t_9814f18f)
- sign_map schreibt schema=v2, Hash über die quantisierte Payload
  (12 signifikante Stellen, Snap über die Godot-Textform → idempotent)
- save_to_file quantisiert VOR dem Schreiben → Dateien ohne Rausch-Floats
- Laden: schema=v2 → v2-Hash; fehlt → Legacy-Fallback (Bestandskarten)
- FirstFight-Fall: Python-repr-Floats (15+ sig) hashen formatunabhängig
- Beweise: tests/test_svmap_checksum_v2.gd (18 Checks, ALL OK);
  echte FirstFight.svmap lädt nach v2-Resave, cross-validiert im Server
2026-10-20 21:33:28 +00:00
Hermes (scopedvr-dev)
7a1b5037e7 Playtest: Splitter-Lagen bleiben erhalten (Sly-Befund 20.10.)
Beim Start des Test-Modus versteckt der Editor die Panels — Godot
klappt dabei die Splitter zusammen (Lage springt z. B. [-320,0] →
[160]), beim Wiedereinblenden baut er die Trenner mit Defaults wieder
auf → die fein gezogenen Seitenbereiche waren weg („riesen groß").

Fix in main.gd (besitzt die UI, überlebt den Playtest-Node): beim
Start alle split_offsets von MainSplit/BottomSplit sichern, im
Exit-Handler deferred UND im nächsten Frame zurücksetzen (das Layout
muss die Panels erst neu vermessen haben). Suite test_playtest 52/52
(mit Splitter-Regression: merken → verbiegen → Exit → unverändert),
test_ui_layout 79, test_gui_smoke 92 grün.
2026-10-20 21:21:05 +00:00
Hermes (scopedvr-dev)
812da1a261 Playtest-Avatar (t_17a35c59): Kommentar-Harmonisierung nach Rebase auf 52919b3
Beide WASD-Yaw-Fixes koexistieren (52919b3: Bewegung aus yaw_deg;
t_17a35c59: Koerper-Rotation fuer Avatar-Front + Third-Person-Kamera).
Die Kommentare behaupteten gegensaetzliche Fakten — jetzt konsistent.
Suiten: test_playtest_avatar 84/84, test_playtest 39/39.
2026-10-20 21:19:20 +00:00
Hermes (scopedvr-dev)
6f00737dd3 Playtest-Avatar (t_17a35c59): Spieler-Modell = Bot-Rig + First/Third-Person (Taste V)
- playtest_avatar.gd (NEU): Spiegel-Modul des Bot-Avatars (Quelle
  bot_avatar.gd/MatchRemoteAvatar) — UAL-GLB + m40-Rifle aus dem
  Nachbar-Klon ../scopedvr-client (GLTFDocument, kein Duplikat),
  AnimationTree-Pipeline 1:1 (BlendSpace1D Idle/Walk/Jog auf den
  Clip-Naturgeschwindigkeiten 0/0,979/2,620 m/s, sync, TimeScale-Kadenz
  nach BOT2: Schrittlängen 0,95/0,85 m, Klammern 1,0–4,5 / 0,6–2,0,
  Blend-Glättung 0,25 s), Gait-Schwellen 0,3/2,6 m/s aus der ECHTEN
  PlaytestBody-Geschwindigkeit, Frontachse +Z → MODEL_YAW_OFFSET = PI.
  Importer-Semantik nachgebildet: "_loop"-Endungen gestrichen +
  LOOP_LINEAR (GLTFDocument liefert rohe GLTF-Namen).
- playtest_body.gd: Körper trägt den Blick-Yaw (1:1 TestmapPlayer
  rotate_y — vorher weltachsen-fixierte WASD-Bewegung, Beweis-Probe),
  Avatar-Halterung (Kopf-Anker 1,65 m, GLB-Füße am Boden), Third-Person-
  SpringArm3D (3,5 m + 0,3 m Margin, eigener Body ausgeschlossen),
  toggle_perspektive/set_perspektive, horizontale_geschwindigkeit().
- playtest_settings.gd: persistenter Eintrag dritte_person (Default First).
- playtest_mode.gd: Avatar-Bau in enter() (graceful ohne Client-Klon:
  First Person + Klartext-HUD-Hinweis), Taste V, Gait-Speisung pro Frame,
  HUD-Hinweis + F1-Hilfe erweitert.
- settings_panel.gd: Perspektive-Auswahl (Erste/Dritte) im Einstellungen-
  Panel, live wirksam + persistiert.
- tests/test_playtest_avatar.gd (NEU, 84 checks): Identitäts-Vertrag gegen
  die Client-Quelle (Konstanten aus bot_avatar/match_remote_avatar/
  testmap_player gelesen), GLB-Ladung, Anim-Pipeline, Kadenz-Mathe,
  Modellhöhe == Kapsel == Client (1,83 m), Rifle-Mount, V-Umschalter +
  Persistenz, SpringArm (Blick zum Körper, kein Clipping durch Wand),
  Blickrichtung == Bewegungsrichtung, Playtest-Integration (enter + Gait).
2026-10-20 21:18:25 +00:00
Hermes (scopedvr-dev)
52919b36b4 Playtest: WASD folgt der Blickrichtung (Sly-Befund 20.10.)
Bug: Die Laufrichtung kam aus transform.basis des Körperknotens — die
Kamera-Drehung steckt aber im look_cam-Kindknoten, der Körper rotiert
nicht → WASD lief stur in eine Himmelsrichtung. Auf der Quest rotiert
der ganze Origin mit dem Kopf, dort trat der Bug nicht auf.

Fix: forward/right aus yaw_deg bauen (identisch zu view_direction(),
identisch zum Kopf-Yaw des Clients). Regression in test_playtest:
yaw 0° + W → -Z, yaw 90° + W → -X. Suite 39/39 grün.
2026-10-20 21:10:09 +00:00
Hermes (scopedvr-dev)
0cdf68622c Playtest-Physik = Quest 3 identisch (t_1da96275): CharacterBody3D + echte Kollider statt AABB-Blocking — KolliderBau (1:1-Spiegel svmap_world), Testmap-Player-Parameter (Kapsel 0.35x1.8, 6/14 m/s, Jump 4.5, Step-Up 0.45), Bauteil-Trimeshe (Treppe/Tür), Identitäts-Vertrag Suite (Editor vs. Client, 8 Entscheidungen identisch), FPS-Probe (Physik-Overhead 7%) 2026-10-20 21:01:37 +00:00
Hermes (scopedvr-dev)
fabbbcce20 Playtest-Kollision: echter Registry-Fußabdruck statt Pick-Radius-Kugel
Sly-Befund 20.10.: Im Editor-Testmodus hing man an unsichtbaren Wänden weit
VOR den Gebäuden. Ursache: _collect_obstacles nutzte die Viewport-Metas
pick_radius (Kugel-Näherung = volle Raumdiagonale + Zuschlag) — bei der
Kaserne (17×35 m) blockte das ~40×40 m. Die Trimesh-Fixes aus t_9558038d
wirken nur im Spiel-Client, der Editor-Playtest baut seine Blockierung
selbst (keine Physik-Welt, nur AABBs).

Fix: Hindernis-AABB aus der Registry (collision.size × scale, um rot_y_deg
konservativ gedreht, Y vom Fußpunkt bis Fußpunkt + Höhe — gleiche
Konvention wie der Client-Kollider). Fallback: Kugel-Näherung bleibt für
Objekte ohne Registry-Eintrag.

Regression in test_playtest.gd: gedreht/ungedreht, Scale, Fallback
(20 checks, alle grün). Suiten: playtest_comfort 51, playtest_spawn 56,
resize_dialog 44, terrain 153 — ALL OK.
2026-10-20 20:11:11 +00:00
Hermes (scopedvr-dev)
1f1a413065 Begehbare Gebäude (t_9558038d): Registry + Kollisionsdateien gezogen
- library/objects.json vom Match-Server (dc9432b): 37 Gebäude jetzt
  shape=trimesh mit collision.quelle -> library/kollisions/<oid>.json
  (36 038 Dreiecke gesamt, objects.json bleibt klein).
- library/kollisions/ (37 Dateien) im Editor-Repo. Editor-Platzhalter nutzt
  weiterhin collision.size — Optik/Anzeige unverändert.
- Suiten nach dem Sync grün: test_library 101, test_object_mirror 74,
  test_library_browser_ui 30, test_editor_svmap 31, test_map_tint 31/0.
2026-10-20 19:50:36 +00:00
Hermes (scopedvr-dev)
d637314b82 Update-Check: sichtbares Ergebnis-Fenster für „🔄 Updates prüfen“ (Sly-Wunsch 20.10.).
Befund: der Check funktionierte die ganze Zeit — die Rückmeldung kam aber
NUR in der Statuszeile („Du bist auf dem neusten Stand (…).“) und verblasst
dort schnell → las sich wie „tut nichts“. Probe (echter Menü-Klick): Check
läuft, ls-remote ok, Status korrekt.

Fix: nach dem MANUELLEN Check (report=true) öffnet ein kleines AcceptDialog
„Update-Check“ mit Klartext-Ergebnis (Stil wie Map-Check-Dialog):
- up_to_date: „Du bist auf dem neusten Stand (<sha>).“
- update_available: Zusätzlich zum Banner das Fenster mit local → remote
  und Hinweis auf „Jetzt updaten“.
- unreachable / auth_failed / error: gleicher Klartext wie Statuszeile.
Der Still-Start-Check (report=false) zeigt bewusst KEIN Fenster (nur Banner
bei „Update verfügbar“) — Probe belegt: Popup sichtbar=false.
Probe: probe_update_check (echter Menü-Klick → Popup sichtbar + Text).
Suiten: test_updater 75 OK, test_resize_dialog 44 OK, test_theme 35 OK,
test_gui_smoke 92 OK, test_ux 137 OK, test_ui_layout 79 OK.
2026-10-20 18:37:38 +00:00
Hermes (scopedvr-dev)
ae551f387a Fix: Resize-Dialog 5930 px hoch + OK/Abbrechen unterhalb des Bildschirms (Sly-Befund 20.10. „viel zu lang, kann nichts bestätigen").
Ursache (Proben im Container, xvfb): autowrap-Labels OHNE Mindestbreite
melden VOR dem ersten Layout eine explosionsartige Mindesthöhe (Wrap bei
~1 px → jedes Wort eine Zeile). popup_centered() übernimmt genau diesen
Wert als Fensterhöhe: Resize-Dialog 5930 px statt ~470, Neue-Map-Dialog
4781 px statt ~390 — beide waren betroffen. Nach dem Layout fällt der
Mindestwert zurück, die Fenstergröße bleibt aber.
Zusatz-Befund: wächst die Live-Vorschau NACH dem Öffnen (Preset 256 →
Warnband), bleibt das Fenster oben hängen → Buttons bei y≈940 in einem
900-px-Sichtbereich = unerreichbar.

Fix:
- resize_map_dialog + new_map_dialog: autowrap-Labels bekommen
  Mindestbreite ~ Dialogbreite → Umbruch rechnet mit echter Breite,
  Fenster normal hoch (Probe: 481 px).
- resize_map_dialog: Inhalt in ScrollContainer (horizontal aus), Höhe
  dynamisch = min(Inhalt, sichtbarer Platz); nach jeder Vorschau-Änderung
  wird das Fenster gedeckelt und neu zentriert (_fit_dialog/
  _fit_after_layout) → OK/Abbrechen IMMER erreichbar. unresizable.
- tests: test_resize_dialog prüft jetzt Mindesthöhe VOR dem Popup
  (absolut, < 1500 px) für beide Dialoge + echten Menü-Klick;
  Preset-Button-Suche robust über find_children.
Proben (xvfb): Dialog 662×481, nach Preset 256 → 662×631, unten 765 < 900,
Buttons sichtbar (Beweisbild work/dialog_beweis/resize_dialog_fix.png).
Suiten: test_resize_dialog 44 OK, test_theme 35 OK, test_gui_smoke 92 OK,
test_ux 137 OK, test_ui_layout 79 OK.
2026-10-20 18:33:07 +00:00
Hermes (scopedvr-dev)
f4cfe17594 Fix: Datei-Menü komplett tot — id_pressed landete als Argument VOR dem Methodennamen (Sly-Befund 20.10. „Kartengröße ändern tut nichts“).
popup.id_pressed.connect(main.call.bind("_on_menu_action")) rief effektiv
call(6, "_on_menu_action") → „Cannot convert argument 1 from int to
StringName“ (map-builder.log 20.10., 2 Treffer) — KEIN Menü-Eintrag tat
etwas (Neue Map/Öffnen/Speichern/Kartengröße/Updates). Alle anderen
.call.bind-Stellen hängen an arg-losen Signalen (pressed/timeout) und
waren unbeeinträchtigt. Fix: Lambda mit expliziter Reihenfolge
(Stil wie grid_btn/contours_btn). Regression: test_resize_dialog stellt
jetzt den echten Klick nach (id_pressed.emit(6) am Datei-Popup) — vorher
rief die Suite _open_resize_dialog() direkt auf und sah den Bruch nicht.
Suiten: test_resize_dialog 42 OK, test_theme 35 OK, test_gui_smoke 92 OK, test_ux 137 OK.
2026-10-20 18:19:42 +00:00
Hermes (scopedvr-dev)
f6c754115a U7 (t_2336c58e): Feinschliff — Theme konsequent (Disabled/Fokus/Hover, LineEdit, Tab-Hover, Splitter-Anfassen, Scrollbars, Farb-Token warning/badge_outline), Abstands-Raster 4/8 über UIScale, Typografie-Raster (heading/body/small via ui_scale.gd), Tooltips für alle Knöpfe inkl. Dialoge, Leerzustand in der Bibliothek, Fenstertitel mit „(ungespeichert)“. Neue Suite tests/test_theme.gd (35 Checks). Keine Funktionsänderung. 2026-10-20 12:08:10 +00:00
Slyphax
0e1406a63e M5 (t_60014669): Ein-Klick „Karte symmetrisch machen“ + Symmetrie-Check mit Klartext-Bericht (Spec vc27). Neu: symmetry_ops.gd (analyze/apply/check — Paarung über Spiegel-Position + sym-Links, Ergänzen ohne Löschen, Widersprüche zählen und der gewählten Quelle zusprechen, Terrain zellgenau Höhen+Material, Spawn-Zwillinge im Gegner-Team via M4-Mechanik, frische sym-Kopplungen ohne V16-Waisen) + Symmetrie-Panel über Panel-Registry (main.gd unberührt; Widerspruchs-Dialog nennt die Zahlen VOR dem Anwenden, Quell-Hälfte links/rechts bzw. vorn/hinten, EIN Undo-Schritt). Suite test_symmetry_onclick (66 Checks: 0 Abweichungen nach Klick, Zellgleichheit, Quell-Flip, Legacy ohne sym, EIN Undo stellt alles her, Modus AUS no-op) + Renderprobe probe_m5_symmetry_render (Vorher/Nachher). 2026-10-20 10:55:06 +00:00
Hermes (scopedvr-dev)
835a7f7363 U3 t_a4bf9b8c: Objekt-Gruppen — GroupState (group_state.gd, fuell-/datei-agnostisch ueber get_meta/set_meta), scene_tree_panel: Gruppen-Ebene im Baum (Name + (N Objekte), eingerueckte Mitglieder, Klick waehlt alle Mitglieder, Gruppen-Knoten markiert wenn alle Mitglieder gewaehlt), zwei-Wege mit Auswahl, Rendering bleibt Gruppen-unabhaengig. Neue Suite tests/test_scene_groups.gd + Renderprobe probe_u3_groups_render.gd. 2026-10-20 10:36:04 +00:00
Hermes (scopedvr-dev)
5dca5513da U6 t_639135dd: dezente Auswahl — Kontur statt Vollorange (Textur bleibt sichtbar).
Neu shaders/selection_outline.gdshader: Inverted-Hull-Außenhülle (unshaded,
cull_front, Grow entlang der Normalen; Streifen-Uniform für den Zwilling).

Markierung jetzt über Outline-Kind am Mesh (Auswahl/Hover/Partner); Farben +
Breiten aus theme/editor_theme.tres (selection_outline*, *_width_mm) — eine
Quelle. material_override wird von der Markierung nicht mehr angefasst:
GLB-Texturen bleiben im Auswahl-Zustand voll sichtbar, der Tint bleibt beim
Markieren sichtbar (kein "Orange gewinnt" mehr).

Spawn-Zonen: konstante Disc-Deckkraft + dezenter Kontur-Ring (Torus) statt
Aufhellung/Emission; Label-Farbe aus dem Theme statt hartkodiertem Gelb.
Bau-Teile: Kontur um das Bauteil-Mesh statt halbtransparenter Vollbox.
Neu: set_hover()-API (schwächste Stufe) — Anschluss an die Mausbewegung
liegt in interaction.gd (Grenze dieser Karte) und folgt als eigene Karte.

Suite tests/test_selection_look.gd (41 checks: Theme-Quelle, Grundmaterial
unangetastet, Outline nur bei Markierung, restlos weg bei Abwahl, Zwilling
dünner+gestrichelt, Spawn-Ring, Bau-Teil-Kontur). Altsuiten an die neue
Semantik angepasst (test_glb_view: GLB-Auswahl ohne Override; test_map_tint
T3: Kontur statt Orange-gewinnt; test_object_mirror: Partner per Kontur).

Beweise: 4 Renderproben (fass, echtes GLB — vorher Vollorange / nachher
Kontur, je auf Sand und Fels) in /opt/data/scopedvr/work/u6_auswahl_beweise/.
Voll-Batterie 32/32 grün (EXIT 0) inkl. der drei angepassten Suiten.
2026-10-20 10:33:04 +00:00
Hermes (scopedvr-dev)
f16b9c8fb6 U5 t_5e3e97c9: Platzieren EINMAL — ein Klick setzt, Werkzeug beendet sich, neues Objekt ausgewählt (Baum+Inspektor), Klartext-Status; Shift = Serienmodus (weiter setzen, Status nennt ESC); Ghost/Panel-Kachel nach dem Setzen zurück; eine Platzierung = ein Undo-Schritt. Abbruch via ESC (ShortcutPolicy) — Rechtsklick bleibt Flug-Vorrang (t_2470de89, test_rmb_vorrang). Neue Suite tests/test_place_once.gd (52 checks) + Renderprobe. 2026-10-20 10:15:01 +00:00
Hermes (scopedvr-dev)
403895b743 t_a7d15717: run_suites.py False-Positive gefixt — Fehler-Regex nur noch auf ^SCRIPT ERROR: / ^Parse Error: am Zeilenanfang (re.MULTILINE). Vorher matchte die Phrase-Regex das eigene Check-Label der Suite ("ok: Parse Error wird erkannt ...", test_updater.gd Zeile 73) -> SCRIPT_ERROR obwohl ALL OK: 75. Zusaetzlich \n zwischen stdout+stderr (sonst verkleben die Streams und der ^-Anker verliert die erste stderr-Zeile). Abnahme: Voll-Batterie SUITEN_TOTAL=30 GRUEN=30 ROT=0, test_updater sauber OK. 2026-10-20 10:12:14 +00:00
Hermes (scopedvr-dev)
60b09bfb74 U2 t_e3c0310a: Szenen-Baum links mit Kategorie-Ebene + Zwei-Wege-Auswahl — scene_tree_panel: Objekte unter Kategorie-Knoten (LibraryBrowser.group_of + Label-Mapping), Spawn-Zonen Team A/B, Bau-Teile, Gruppen-Ebene (GroupOps; Klick auf Gruppe waehlt alle Mitglieder), Kopf mit Zaehlern (Objekte (N)), Auge-Praefix je Eintrag (nur Anzeige, keine Doc-Aenderung), leerer Zustand mit Klartext-Hinweis, Zwei-Wege-Kopplung ueber _obj_index_map (O(1) Markieren + scroll_to_item). Neue Suite tests/test_scene_tree.gd (24 checks: Baum fuellt sich, Klick->3D, 3D->Baum, Mehrfach, Gruppen-Klick, leerer Hinweis, UIScale). Renderprobe probe_u2_scene_tree_render.gd. main.gd NICHT angefasst. 2026-10-20 10:03:34 +00:00
scopedvr-operator
3d7ba993d5 glb_mesh_store: verschachtelte Pack-Modelle auch in models/ finden
Operator-Befund aus dem Eigenstaendigkeits-Beweis auf dem PC (20.10.):

Die App baute den Registry-Pfad nur als <quelle>/<rel-pfad-mit-Praefix
'assets/objekte/'>. Der mitgelieferte models/-Ordner NEBEN der App enthaelt die
Pack-Struktur aber DIREKT (models/military_desert/props/box_010.glb). Ergebnis:
291 von 334 Modellen (alle verschachtelten Pack-Objekte — genau die, mit denen
die Karten gebaut werden) wurden in der eigenstaendigen App nicht gefunden.

Fix: zusaetzlicher Kandidat <quelle>/<rel-pfad OHNE 'assets/objekte/'> — der
flache Alt-Pfad bleibt unveraendert.

Belegt (exportierte App, nacktes Umfeld, frisches HOME):
  box_010       -> models/military_desert/props/box_010.glb   (vorher: leer)
  baum_kiefer_1 -> models/baum_kiefer_1.glb
  fehlende_glbs=0   PROBE ERGEBNIS: OK
Suiten: gui_smoke 92 OK, Import 0 Fehler.
2026-10-20 10:01:57 +00:00
scopedvr-operator
a62357dad8 tools/probe_gen_map.gd: OIDs per --oid= waehlbar
Damit lassen sich auch VERSCHACHTELTE Pack-Modelle pruefen
(models/military_desert/<kategorie>/<oid>.glb) — noetig, weil der erste
Eigenstaendigkeits-Beweis nur flache Modelle abdeckte.
2026-10-20 10:00:29 +00:00
scopedvr-operator
e317706df0 tools/sync_models.sh: Pack-Referenzordner als Textur-Quelle
Operator-Befund auf dem PC (20.10.): die Original-Texturen liegen NICHT im
Client-Klon, sondern in einem eigenen Pack-Ordner:
  ~/godot-projects/ithappy_pack_ref/ithappy/military_desert/textures/
Deshalb sucht das Skript jetzt zusaetzlich dort (<clone>/../ithappy_pack_ref/...)
und als letzte Instanz per find -maxdepth 6 unter <clone>/.. und <client>/..
(also neben dem Projekt). Belegt mit einem nachgebauten PC-Aufbau: Palette wird
gefunden, landet FLACH in models/textures/, --check danach Exit 0.
2026-10-20 09:58:47 +00:00
scopedvr-operator
8c97d00871 tools/sync_models.sh: Texturen suchen + flach spiegeln + Textur-Ordner nicht mehr loeschen
Operator-Nachbesserung nach dem Eigenstaendigkeits-Beweis (exportierte App,
kein Client/Godot im Dateisystem):

- SRC_TEX wird GESUCHT statt fest verdrahtet: SCOPEDVR_TEX_DIR ->
  assets/textures/military_desert -> assets/textures -> Ordner der
  Demonstration_Textures*.png unter assets/. Findet er nichts, gibt es einen
  HINWEIS samt Loesung (SCOPEDVR_TEX_DIR=/pfad).
- Texturen werden FLACH nach models/textures/<name>.png gespiegelt — die App
  sucht sie dort; im Client liegen sie verschachtelt.
- Modell-Spiegel schliesst den Unterordner textures/ aus (skip_top): vorher
  galt er als 'ueberfluessig' und wurde bei JEDEM Lauf geloescht, der
  Textur-Spiegel legte ihn neu an -> Endlosschleife, --check nie sauber.

Belegt: 2. Lauf 0 Aenderungen, --check Exit 0; exportierte App findet
baum_kiefer_1/stein_mittel_1 aus models/, fehlende_glbs=0, ERGEBNIS: OK.
2026-10-20 09:55:05 +00:00
Hermes (scopedvr-dev)
7c7dc38566 U-Nachlauf t_efb3d022: assets_panel _library_load_open_states robuster Typ-Check
bool(cfg.get_value()) warf SCRIPT ERROR "Nonexistent bool constructor" bei
Nicht-Bool-Eintraegen in [library] (Array-Eintraege neben zuletzt_benutzt) —
die Klapp-Zustaende luden dann gar nicht (STATES leer).

Fix: nur echte Bool-Werte uebernehmen, String/Int/Array auf Fallback true,
kein Crash mehr.

Beweis-Probe tests/probe_t_efb3d022_repro.gd:
  vorher: 2x SCRIPT ERROR "Invalid call. Nonexistent 'bool' constructor",
          STATES leer
  nachher: 0 Fehler, 3 Zustaende geladen (Array-Eintrag -> Fallback true)

Suite test_assets_panel.gd erweitert um Testgruppe 0
"Klapp-Zustaende aus kaputter cfg" (6 neue Checks, 75 -> 81, ALL OK):
Array/String/Int-Eintraege + save->load-Rundtrip.

test_inspector 47/47, test_gui_smoke 92/92, --import 0 Parse-Errors.
2026-10-20 09:52:26 +00:00
Hermes (scopedvr-dev)
4fb883a44c T3 t_aaff5dcc: Komfort im Testlauf — Fliegen/God-Mode (G, Noclip+Blickrichtung), Spawn-Sprung 1-4 (exakt, Terrain-Hoehe), HUD-Toggle H + Blick/Hoehe, Achsen-Linien X, Hilfe F1, Maus-Empfindlichkeit (PlaytestSettings + Einstellungen-Panel via Registry, main.gd unangetastet); Suite tests/test_playtest_comfort (51 Checks), test_ui_layout Tabs registry-getrieben 2026-10-20 09:47:28 +00:00
scopedvr-operator
ab173ac8aa T2 Ueberall installierbar (Operator-Push): Modelle aus mitgeliefertem models/-Ordner
Worker hatte die Arbeit fertig, aber NICHT committet/gepusht (3. Fall heute) —
aus dem Karten-Arbeitsordner gerettet und hier gepusht.

- GlbMeshStore.quellen(): Reihenfolge EINE Stelle — Setting -> Sibling-Repos ->
  mitgelieferter Ordner models/ direkt neben der App (dedupliziert, typisiert)
- Klartext-Meldung FEHLT_TEXT statt stillem Platzhalter; Notfall-Modus: Terrain
  und Bauteile laufen ohne GLBs
- neu: tools/sync_models.sh (fuellt models/, --check), utils/modelle_status.gd
  (Autoload ModelleStatus), tools/probe_gen_map.gd
- neu: README_INSTALL.md (85 Zeilen, Schritt fuer Schritt + §5 Lizenzhinweis:
  ITHappy-Rohdaten NICHT weitergeben, App fuer Slys eigene Rechner)
- export_presets.cfg (eigenstaendiger Linux-/Windows-Export), .gitignore:
  models/ + export/ bleiben draussen (Lizenz + Groesse)
- Tests: test_model_sources (26/27; die rote ist umgebungsabhaengig:
  'lager_ist_leer' erwartet fehlende Sibling-Repos)

Belegt: Import 0 Fehler, gui_smoke 92, ui_layout 71, playtest_spawn 56.
2026-10-20 09:43:05 +00:00
scopedvr-operator
0f86472cfa T1b-Wiederanbindung (Operator): Test-Startpunkt zurueck nach dem U1-Umbau
Sly-Befund: der Knopf 'Test-Start' fehlte; tests/test_playtest_spawn.gd brach
mit "Nonexistent function '_test_start_reload'" ab und hing.

- Werkzeug-Modus 'teststart' wieder in _set_tool_mode (+ enter/leave/sync)
- ShortcutPolicy.esc_action: exit_teststart inkl. Test
- Taste T: Editor = Werkzeug umschalten, Lauf-Modus = Punkt setzen + Teleport
  aufs Fadenkreuz (playtest.teleport_crosshair)
- Entf im Werkzeug loescht den Punkt; Klartext-Hinweis des Werkzeugs wieder in
  der Statuszeile ('auf dem Boden unter dem Objekt')
- Leisten-Knopf '🎯 Test-Start' als Umschalter (registry.gd ACTIONS)
- neues Panel-Modul src/ui/panels/test_start_panel.gd + Registry-Eintrag
- Haken _test_start_apply/_delete/_reload + test_start_point/test_start_panel/
  test_start_btn stehen fest auf main (Vertrag der Pruefsuite)
- _test_start_reload() wieder nach Neu/Laden/Speichern; playtest.enter() bekommt
  den aufgeloesten Startpunkt (TestStart.resolve_start)

Belegt: test_playtest_spawn 56/56, test_shortcuts 204, gui_smoke 92, ui_layout 71,
Import 0 Fehler.
2026-10-20 09:37:05 +00:00
Hermes (scopedvr-dev)
40449932b9 K1d t_6c1027c1: Startansicht 80 m über der Kartenmitte (Pos1 bleibt ganze Karte)
fit_to_document setzt die Ansicht jetzt auf START_DISTANCE_M = 80 m über der
Kartenmitte (XZ-Mitte der Map-Bounds, Höhe = Gelände dort), yaw 45°, pitch 35°.
Die Regel ist unabhängig von Kartengröße (256/1024/4096) und Inhaltslage;
leeres Dokument ebenfalls 80 m. Statuszeile: „Ansicht: Kartenmitte, Abstand
80 m (Pos1 = ganze Karte).“ Pos1/Home bleibt fit_to_bounds = ganze Karte
(MAP-Bounds werden weiterhin gemerkt, nicht die Content-Bounds).

Ziel-Y bewusst auf der Geländehöhe: die y-Mitte der Bounds liegt bei neuen
Maps 35 m über dem Boden — damit hätte die Ansicht über den Boden gezielt und
die Objekte stünden am unteren Bildrand.

Suite test_orbit_camera auf die neue Regel umgestellt (50 Checks):
Startabstand/Richtung, 80-m-Regel über Größen und Inhaltslagen, leeres
Dokument, „Pos1 ≠ Startansicht“ (80 m → 1215,6 m, alle 8 Map-Ecken).
Dazu Renderprobe tests/probe_k1d_render.gd (2 PNGs).
2026-10-20 09:33:16 +00:00
scopedvr-operator
85bee00f3d U1-Nachbesserung (Operator, Sly-Befund 'Damit kann man nicht mehr arbeiten'): Werkzeugleiste erbte EditorPanel (=VBoxContainer) und wurde selbst senkrecht -> jeder Knopf eine fensterbreite Zeile (~440px), 3D-Fenster auf einen Streifen gedrueckt. Fix: toolbar.gd extends HBoxContainer + size_flags SHRINK_BEGIN; neue Groessenprobe in test_ui_layout (Leiste <=80px, Mittelbereich >=120px). 2026-10-20 09:22:39 +00:00
Hermes (scopedvr-dev)
8f0e011a07 U4 t_69687afc: Assets-Bereich Unity-artig ausgebaut — Kategorien-Leiste (Alle + Gruppen aus LibraryBrowser, Summe = Gesamtzahl), Suche Name+ID, Zaehler "N von M sichtbar", Zuletzt benutzt (cfg [library] zuletzt_benutzt, Chip + Ansicht), Kacheln mit 96-px-Vorschau + ID + Name, Hover vergroessert die ID, fehlende GLB = abgedunkeltes Bild + orange Badge "Modell fehlt" (GlbMeshStore.fehlt), Klick = auswaehlen (Signal object_selected genau 1x je Wechsel), Doppelklick = Werkzeug starten wie bisher (nochmal = abbrechen), Spaltenzahl breitenabhaengig (180 px je Kachel, ueber ui_scale), Dock-Groesse bleibt in [fenster] gemerkt (U1). Klapp-Gruppen + Alle auf/zu + gepinnte Masse (Suchfeld 36, Kachel 126, Thumb 96) unveraendert. Suite tests/test_assets_panel.gd 75 checks ALL OK; test_library_browser_ui 30, test_library 101, test_ui_layout 63, test_gui_smoke 92, test_inspector 49, test_playtest 11, test_object_mirror 74, test_ui_scale OK — alle unveraendert gruen. Renderproben unter /opt/data/scopedvr/work/u4_assets_beweise/. test_playtest_spawn ist VOR dieser Karte rot (U1-Regression _test_start_reload, siehe Kommentar auf t_eaccbfed) — nicht von U4 beruehrt. 2026-10-20 09:19:37 +00:00
scopedvr-operator
69ffe23a71 t_eaccbfed: U1 UI-Fundament (Operator-Push) — main.gd 3677->2479 Zeilen, src/ui/panels/ (11 Module + registry), Tabs rechts+links, Assets unten, theme/editor_theme.tres, tests/test_ui_layout.gd (63 checks). Worker meldete fertig ohne Push; Arbeit aus dem Scratch-Ordner gerettet. 2026-10-20 09:11:00 +00:00
Hermes (scopedvr-dev)
a5d8f7f79d T1b t_96e3c4ee: Test-Startpunkt per Klick — Toolbar/Taste T setzt den Punkt, wo F5 den Testlauf startet (Höhe automatisch vom Terrain, Klick auf Objekt/Bauteil = Boden darunter). Sidecar user://scopedvr_teststart.cfg je Kartenpfad, KEIN .svmap-Eintrag, kein Undo, kein Spawn-Check-Kontakt. F5-Fallback mit Klartext: Test-Start → erster Team-Spawnpunkt → Kartenmitte. T im Testlauf versetzt sofort zum Blickpunkt (Augenhöhe 1,70 m, Kollision geprüft) und setzt den Punkt neu. Marker (Säule+Ring, Akzentfarbe) sichtbar solange gesetzt. Suite tests/test_playtest_spawn.gd 56/56, Voll-Batterie 25/25 grün, Renderprobe (4 PNGs) unter /opt/data/scopedvr/work/t96e3_beweise/ 2026-10-20 08:22:47 +00:00
Hermes (scopedvr-dev)
b53e05a145 T1 t_1cdc1a72: F5-Smoke-Test an neuen Lauf-Modus angepasst (PlaytestMode statt Client-Fehler) + Render-Probe-Script 2026-10-20 08:07:15 +00:00
Hermes (scopedvr-dev)
1b943ceb35 T1 t_1cdc1a72: Testlauf im Map Builder selbst (erste Person, WASD+Maus, ohne Client/VR/Server) — PlaytestMode + PlaytestBody in src/editor/playtest/, minimale main.gd-Einbindung (F5/Toolbar), test_run.gd als ALT markiert, Suite tests/test_playtest.gd (11 Checks, vor Push noch Rebase auf 969e928 + Voll-Suiten-Batterie + Beweis-Bild) 2026-10-20 07:45:33 +00:00
Hermes (scopedvr-dev)
969e928d74 k1b: Editor-Kamera mit Inhalt ausrichten (Startansicht auf Objekte/Bauteile, Pos1 ganzer Terrain-Klotz, 180-Grad-Schwenk um Inhaltsmitte) 2026-10-20 07:42:50 +00:00
Hermes (scopedvr-dev)
2c5b58f04c t_950fddaa K1: Kamera-Drehen fixen — Drehpunkt unter den Mauszeiger, Map beim Laden passend einpassen
orbit_camera.gd: set_pivot(hit) rechnet aus unveränderter Position/Basis
target/yaw/pitch/distance neu (kein Sprung); MMT-Druck pivoted auf den
Punkt unterm Cursor — Objekt-Treffer zuerst (interaction._pick_object),
sonst Terrain (interaction._ground_point), sonst alter Ziel-Punkt.
ORBIT_DEG_PER_PX = 0.3 gekapselt, Zoom-Minimum DISTANCE_MIN = 5.
fit_to_bounds(mn, mx) ersetzt das tote frame_map(): target = Kartemitte
aus bounds, Abstand über Umkugel-Formel (alle 8 Ecken sicher im Frustum —
reiner Kanten-Faktor 0,65 reicht bei flachen Maps nicht), Mindestabstand
30 m; Pos1/Home = ganze Karte ins Bild (Bounds bleiben gemerkt).
main.gd: Laden/Neu + Resize rufen fit_to_bounds mit den echten doc-Bounds.
Neu: tests/test_orbit_camera.gd (30 checks: kein Sprung, Drehpunkt wandert,
fit, gleichmäßiges Ziehen, Flug-Übergang, Pos1).
2026-10-20 07:09:11 +00:00
Hermes (scopedvr-dev)
dca0947a71 t_1e82f747: Test-Umgebung aufgeraeumt — test_updater Setup robust, test_glb_view auf Pack-Palette (beide Suiten statt immer rot jetzt gruen/SKIP). test_updater: Verzeichnisname war Time.get_ticks_msec (kleine Zahl, kollidierte mit nicht loeschbaren Alt-Laeufen aus read-only git-Packs) → jetzt Unix-Zeit+ticks_usec; Cleanup chmod -R u+w (blocking via OS.execute mit output-Array, 2-Arg-Variante ist async) + include_hidden (.git ist versteckt) + rm -rf-Fallback; git-Fehler geben echten Fehlertext statt roter Zahl. Echte Updater-Pruefungen laufen wieder (75/75, vorher 15/75 FAIL an Setup). test_glb_view: Nachbar-Repos suchen wie tools/sync_objects.sh (Env SCOPEDVR_CLIENT_DIR/SCOPEDVR_MATCH_SERVER_DIR, dann ../+../../-Siblings), Quellen zur Laufzeit in ProjectSettings gesetzt (basis-relativ gerechnet, path_join ketten Absolutpfade); ohne Nachbar klare SKIP-Zeilen+Exit 0 statt 15 FAILs. Camo-Erwartungen entfernt (ueberholt seit d0d4fbd): jetzt Pack-Palette — alle Flaehen albedo_texture != null, Kante 1024², pack_datei_fuer-Zuordnung (military_base→Demonstration_Textures1.png, picture_1/2→Demonstration_picture_N.png 2048²) + Pack-Dateien im Nachbar-Repo (48/48, vorher 4/38 FAIL). Notiz tools/TEST_UMGEBUNG.md (Abhaengigkeiten + test_gui_smoke-F5 vorbestehend rot am 0adb161 via git-stash verifiziert, nicht Teil dieser Karte). 2026-10-20 07:06:48 +00:00
Hermes (scopedvr-dev)
2c10863759 M2 (t_f9eff526): Terrain spiegeln — Höhen UND Boden-Material in allen Live-Pfaden (Spec vc27). terrain_tool: jeder Pinsel-Schritt (Heben/Senken/Glätten/Malen) wendet den Pinsel EINMAL an und kopiert die geänderten Zellen (Snapshot-Vergleich) exakt per SymmetryCore.mirror_cell in die andere Hälfte — Strich-Seite ist Master, Überlappung gewinnt strichnäher, Mitte/Achse wird nicht gedoppelt. demo_stroke läuft über dieselben Schritte. Ohne sym_mode-Feld/Modus AUS: exakt altes Verhalten (byte-identisch zu origin/main, SHA256-Gegenprobe). terrain_mesh unverändert (Live-Pfade tragen Höhe+Schicht bereits, Audit im Handoff). Neu: tests/test_terrain_mirror.gd (105 checks, alle 3 Modi × 4 Pinsel + Mitte/Achse/Rand/Überlapp + Gegenprobe) und tests/probe_terrain_mirror_render.gd (Beweis-Bild Draufsicht). 2026-10-20 06:49:08 +00:00
Hermes (scopedvr-dev)
238ca7bb2f M3 (t_34908425): Objekte spiegeln — Platzieren/Verschieben/Drehen/Löschen/Duplizieren inkl. Partner-Bindung (Spec vc27). ObjectOps: symmetry_place_twin/symmetry_sync_pairs/symmetry_expand_delete (Mathe aus SymmetryCore M1, keine zweite Spiegel-Mathe; Mitte/Achse nicht gedoppelt). interaction: Zwilling in ALLE Objekt-Pfade integriert (Platzieren, Drehen, Löschen, Duplizieren, Pfeiltasten, Ziehen live+Commit, Greifen live+Esc-Rückabwicklung, Spiegeln-Button) — jede Änderung = EIN Undo-Schritt. map_view_builder: schwächere Partner-Markierung über die bestehende Auswahl-Material-Logik (keine zweite Technik). Neu: tests/test_object_mirror.gd (74 checks, alle 3 Modi + Gegenprobe AUS), tests/probe_m3_partner_render.gd (Beweis-Bild). 2026-10-20 06:48:29 +00:00
Hermes (scopedvr-dev)
c15c606311 t_ffa207b1: M4 Bau-Teile + Spawnpunkte spiegeln (Spec vc27)
- BuildOps.place_mirrored / update_mirrored / remove_mirrored /
  inside_match_bounds: pure Statics in build_ops.gd, sym-Kopplung aus M1,
  gleiche Snap-Regeln (Paritaet bleibt), tiefe Kopien (kein Aliasing).
- V9-Grenzen scharf: laege der Zwilling ausserhalb der Match-Grenzen
  (Loader-Regel _inside_bounds ±1 m), wird er NICHT gespeichert und der
  Status meldet V9 im Klartext — auch kein halber sym-Link.
- SpawnTool-Spiegelung im GEGNER-Team (Blau West <-> Rot Ost = faire
  Gegenstelle, Radius 1:1), Partner-Map in design_notes.spawn_mirror
  (Loader-frei, rundtrip-sicher). Auf der Mitte: nicht gedoppelt.
- build_tool.gd: duenne Hooks an den 6 Klick-Mutationen + demo_place —
  EIN Undo-Schritt nimmt Original UND Zwilling zurueck.
- spawn_tool.gd: Hooks in setzen/verschieben/loeschen/demo_place/demo_move;
  _sym_mode() defensiv ueber main.get() (Mock-Mains ohne sym_mode -> OFF,
  statt SCRIPT ERROR — Lektion aus test_shortcuts).
- Neue Suite tests/test_build_spawn_mirror.gd (76 Checks ALL OK) +
  Probe tests/probe_m4_mirror_render.gd (2 Beweis-Bilder).
- Regression: 19/20 Suiten gruen; test_glb_view 15/25 FAIL pre-existing
  (per git-stash gegen 437f6bf verifiziert, Ursache unabhaengig vom Diff).
2026-10-20 06:47:58 +00:00
Hermes (scopedvr-dev)
0adb161386 t_f5935023: M1 Symmetrie-Fundament (Spec vc27, Sly 20.10.) — SymmetryCore (Punkt/Achse X/Achse Z, Zell-Abbildung, Partner-Link sym, alle statisch/pure, headless testbar) + Validator V16 in svmap_io (sym muss String, nicht leer, ≤64 Zeichen, genau 2 pro Kennung) + Toolbar-Schalter + Statuszeile-Anzeige in main.gd (Standard=Punkt, Sly sieht Modus immer) + Suite test_symmetry_core (93 Checks: Modus-Katalog, Kartenmitte aus bounds, zweimal-spiegeln=Identity für alle Modi, Achse/Zentrum-nicht-doppeln, Raster-Zell-Mapping inkl. gerader/ungerader res, Partner-Link-API, V16-Validator, Roundtrip mit sym byte-identisch, Legacy-Karte ohne sym unverändert). 17/19 Suiten grün (test_updater 15/75 FAIL + test_glb_view 15/25 FAIL = PRE-EXISTING per git-stash-Verfahren gegen 437f6bf verifiziert, Ursache unabhängig vom Diff). M1 legt nur Mathe + Anzeige fest — gespiegeltes Bauen selbst kommt mit M2 (Terrain), M3 (Objekte), M4 (Bau-Teile + Spawns). 2026-10-20 06:37:50 +00:00
Hermes (scopedvr-dev)
437f6bf368 Fix Update-Banner hing endlos: git-Warnzeile wurde als Ziel-Commit gelesen (t_50a07d4b-Live-Bug, Meldung Sly 20.10.)
git_run mischt stdout+stderr; bei der HTTP-Umleitung der Forgejo-URL schreibt
git zuerst 'warning: Umleitung nach ...'. Die alte Fassung nahm die ERSTE
Zeile und hielt 'warning' fuer den Remote-SHA -> Banner 'Update verfuegbar:
<sha> -> warning' blieb fuer immer, obwohl der Klon auf origin/main stand
(live auf dem PC belegt: STDOUT korrekt, STDERR Warnung).

Jetzt: sha_aus_ls_remote() liest nur echte '<sha>\t<refs/heads/...>'-Zeilen
(Warnungen/hints/fatal uebersprungen, SHA muss Hex sein), Notnagel = erste
hex-artige Zeile; 'kein Commit in der Ausgabe' gilt als unreachable statt
update_available. Regressionstest: 6 neue Checks in test_updater.gd.
2026-10-20 05:27:02 +00:00
Hermes (scopedvr-dev)
d0d4fbd221 Fix Editor-Live-Texturen: Pack-Original-Zuordnung nach Materialname (t_50a07d4b, aa94bde korrigiert)
aa94bde hat im Live-Pfad die alten Camo-Dateien (desert_multicam_*) verwendet und
zusaetzlich per Inhaltsvergleich auch KORREKTE Texturen wieder durch Camo ersetzt
- das haette die Palette-Arbeit aus t_279df760 im Editor rueckgaengig gemacht.
Jetzt: Zuordnung Materialname -> Pack-Textur (military_base -> Demonstration_Textures1.png,
picture_1/2/3 -> Demonstration_picture_N.png), Pfad ../../../textures/military_desert/
(Client-Repo, dort liegen die Dateien seit 88fded1). Fuer den Inhalt gibt es EINE Quelle:
tools/PACK_TEXTURE_MAPPING.md. Fuer den Inhalt gibt es EINE Quelle: dieselbe Tabelle.
Flaechen mit bereits aufgeloester Textur bleiben unangetastet.
2026-10-20 00:24:54 +00:00
Hermes Agent
aa94bde708 WYSIWYG Camo-Texturen: Live-Viewport lädt Multicam-Texturen (Cache, Warnung bei Fehlen) 2026-10-19 23:26:38 +00:00
Hermes (scopedvr-dev)
c5bdb0518e Pack-Original-Optik (t_279df760): gen_thumbs auf Pack-Palette (Materialname->Demonstration_*.png, Client assets/textures/military_desert) + 345 Thumbs neu gerendert
- _camo_tex_setzen -> Pack-Zuordnung je MATERIALNAME (PACK_TEXTURE_MAPPING.md
  im Client = Quelle der Wahrheit): military_base -> Demonstration_Textures1,
  picture_1/_2 -> Demonstration_picture_1/_2; je Surface ihre Textur
- Thumbs: alle 345 Bibliotheksobjekte neu (Palette-Objekte zeigen Pack-
  Farbfelder, Multicam-Objekte echtes Multicam statt der alten Camo-Box-Projektion)
- Beweis-Thumbs: barrier_001 (weiss/rot wie Pack), cactus_001 (Pack-Gruen
  27,94,32 per UV-Sampling belegt), tent_001 (Palette + Multicam-Flaechen),
  apc_002 (Sand/Multicam)
2026-10-19 23:05:47 +00:00
Hermes (scopedvr-dev)
967e4dc54a Mehrfachauswahl gemaessigt (t_bceb0965): Gemeinsames Verschieben aller markierten Objekte relativ — Anker-Objekt fuehrt, Abstaende bleiben exakt erhalten (Probe: 3 Objekte, Delta (+4,-2), Distanzen 6.325/5.831/7.616 vor UND nach). Entf loescht die GESAMTE Auswahl (vorher: nur letztes Objekt), mit einem Undo-Schritt fuer die Gruppe. Undo: 1 Schritt stellt ALLE Objekte gemeinsam wieder her (vorher: 3 Einzelschritte); Redo groupen-basiert. Drehung/Scale-Eingabe bei Mehrfachauswahl: '—' eintippen setzt ALLE auf denselben Wert, Position bleibt relativ. Einzelauswahl unveraendert (Gizmo-Zug ist gizmo-gated und nutzt gemeinsam-ziehend-Pfad nicht). Tests: tests/probe_multi_move.gd neu (12 Checks: Positionen, Abstaende, Undo/Redo, Entf), tests/test_inspector.gd 47/47 gruen (T5 erweitert), test_ui_scale 51/0 gruen, --import 0 Parse-Fehler. 2026-10-19 21:57:28 +00:00
Hermes Agent
370917f96e D11 (t_c8748efc): Thumb-Neurender mit Camo-Fix — 292 Thumbs neu gerendert, gen_thumbs.gd lädt Multicam-Texturen manuell (GLTFDocument kann externe URIs in Sibling-Repos nicht laden). 2026-10-19 21:31:57 +00:00
Hermes (Operator)
e664d09666 Map Builder Inspektor Teil 2 (t_e0120dc2): Achsen-Gizmo — drei farbige Pfeile (X rot / Y gruen / Z blau) am einzeln markierten Objekt, ziehbar; Zieh-Werte rasten auf die Schrittweite (Mausrad waehrend des Zugs blaettert fein 0.1 / normal 0.5 / grob 2.0, SpinBox 'Schrittweite' im rechten Panel, ueberlebt UI-Rebuilds); Inspektor-Zahlen folgen live; Y-Zug klemmt am Terrain, X/Z folgen dem Boden; Esc rollt exakt auf die Startposition zurueck (kein History-Eintrag), Loslassen committet EINEN Undo-Schritt; nur Einzelauswahl. Neues src/viewport/gizmo.gd haengt als Kind von interaction.gd (Input-Prioritaet), interaction.gd unveraendert. OPERATOR-NACHTRAG: dieser Stand lag nur als Karten-Anhang vor (nie committet) — vom Operator uebernommen und verifiziert: --import 0 Parse-Fehler, test_gui_smoke 89 OK, test_inspector 33 OK, test_ui_scale 51/0, test_library 101 OK. 2026-10-19 21:29:09 +00:00
Hermes (scopedvr-dev)
9fa8029910 Farbe je Objekt (t_b0edb807): Inspektor-Abschnitt 'Farbe / Material' mit 7er-Palette (Wüstensand/Oliv/Tarn-Grün/Stahlgrau/Holzbraun/Dunkelbraun/Weiß) + 'Zurücksetzen (Pack-Farbe)'; Mehrfachauswahl = EIN Undo-Schritt; Speichern als OPTIONALES Objekt-Feld tint:[r,g,b] (Anzeige-sRGB, 4 Stellen — Rückwärtskompatibel, Karten ohne Feld unverändert). Neuer Helfer src/svmap/svmap_tint.gd (1:1-Spiegel im Client, gemeinsame Semantik Editor+Spiel): albedo_color-ERSATZ via Surface-Override-Kopien — Original-Materialien bleiben unangetastet, Auswahl-Orange (material_override) gewinnt weiter; set_selected_multi re-synced Tint beim Abwählen, tint_refresh für Inspector-Änderungen ohne Rebuild. Map-View-Bauer wendet tint beim Aufbau an (echte GLBs UND Platzhalter). tests/test_map_tint.gd neu (31 Checks grün), Editor-Gesamtsuite 18/18 (gui_smoke-F5-Artefakt per Gegenprobe entkräftet, updater falsch-rot im Sammel-Filter). Rauheit/Metallisch bewusst weggelassen (Begründung im Handoff/BEWEIS.md). 2026-10-19 20:54:34 +00:00
Hermes (scopedvr-dev)
c8b9c59fda Objekt-Inspektor rechts (t_bb0d11c9): Position/Drehung/Größe tippbar+ziehbar (Godot-Konvention: am Label ziehen), Mehrfachauswahl mit „—“ bei ungleichen Werten, JEDE Änderung = 1 Undo-Schritt (Tippen beim Bestätigen, Ziehen beim Loslassen), live im 3D (Marker-Sync ohne Rebuild, Rescale inkl. Pick-Metas), Auf-Boden-Setzen über vorhandene Logik, Info-Block (Fußpunkt/Kollision/echtes Modell), Kopf mit N von M + Poly. Scale via UIScale (alle 3 Stufen layout-geprüft @2328x1310). Drive-by-Fixes (Vorbestand): Tree.ensure_visible → Tree.scroll_to_item (SCRIPT ERROR bei jeder Auswahl) + sel_label füllt jetzt bei select_object/select_many. tests/test_inspector.gd neu (35 Checks), Suiten grün. 2026-10-19 20:02:14 +00:00
Hermes (scopedvr-dev)
366d482a1a A6-Aufraeum (t_a6a96e4c): Beispiel-Faesser entfernt — Thumbs + .import geloescht, Registry-Sync (345 Objekte, note erhalten), probe_store_a6 auf barrel_001 umgezogen 2026-10-19 20:00:52 +00:00
Hermes (scopedvr-dev)
c8a41c0794 UI-Größe: zentrale Skalierung Klein/Mittel/Groß (t_297221b5) — Top-Bar-Menü, sofort wirksam (Rebuild), gespeichert in user://scopedvr_editor.cfg [ui]; Fenster startet maximiert + merkt Lage; Panel-Min-Maße/Schrift an einen Faktor gekoppelt; Layout-Überlauf-Fixes (Hilfe-Hinweis Umbruch+Grow-up, Statuszeile/Top-Bar-Ellipsis, linkes Panel scrollbar) 2026-10-19 19:21:45 +00:00
Hermes (scopedvr-dev)
f4748af371 D9 (t_9962eba7): 293 Bibliotheks-Thumbs neu gerendert — Kategorie-Farbe statt ausgewaschen
Folge der Farbraum-Korrektur im Client (f04b515): gen_thumbs laedt die GLBs
direkt aus ../scopedvr-client/assets/objekte, deshalb zeigen die 293
Military-Thumbs jetzt den gewuenschten Anzeige-Wert (Panzer oliv statt fast
weiss, Waende tan, Kisten holzbraun). Alt-Bestand-Thumbs byte-identisch
(Gegenprobe: genau 293 von 347 PNG geaendert).
2026-10-19 19:08:59 +00:00
Hermes (scopedvr-dev)
ccf1c43f2e D8 (t_ba4278f8): Thumbs neu gerendert (347 Objekte, 27 Bilder neu) nach dem Alt-Bestand-Fusspunkt-Bake
Stichprobe angesehen: baum_kiefer_1, stein_mittel_2, box_018, tank_001 - Objekt vollstaendig und mittig im Bild.
2026-10-19 19:04:34 +00:00
Hermes (scopedvr-dev)
43636c840f Bibliothek: 96-px-Kacheln, Kategorie-Gruppen, klappbar + gemerkt, Suche (t_59fc0f21) 2026-10-19 18:49:26 +00:00
Hermes (scopedvr-dev)
91e2b03843 PROBE entfernt (t_f73a1cc5): Nachweis abgeschlossen 2026-10-19 18:41:56 +00:00
Hermes (scopedvr-dev)
2bd1e671e4 PROBE (t_f73a1cc5): lose-Datei-Kollision nachstellen — wird nach dem Beweis entfernt 2026-10-19 18:41:27 +00:00
Hermes (scopedvr-dev)
90c3c7fdc7 Selbst-Update: lose/geänderte Dateien werden automatisch beiseitegelegt (t_f73a1cc5)
EditorUpdater.pull: vor dem pull --ff-only git stash push -u (erfasst
GEÄNDERT + UNTRACKED — genau die losen .import-Dateien, die am 19.10.
den Update-Knopf blockierten). Nach dem Pull zurückholen (apply+drop);
kollidiert das Zurückholen: reset --hard gegen halb gepoppte Zustände,
Stash bleibt als Sicherung, ehrliche Klartext-Meldung in der Statuszeile
statt roher Git-Zeile. Alte Auto-Stashes gleicher Kennung werden beim
Anlegen verworfen (keine Anhäufung). main.gd zeigt die Klartext-Info.

tests/test_updater.gd: Negativprobe (nackter pull bricht an untracked
vs. getrackt ab), Fix-Probe (läuft durch), geänderte Datei bleibt
erhalten, Konflikt-Fall mit Sicherungs-Stash, Anti-Anhäufung. 69 checks.
2026-10-19 18:41:19 +00:00
Hermes (scopedvr-dev)
3e07e91f5a Revert "Pack-Texturen (t_9675e48d): Editor-Viewport + Thumbs texturiert"
This reverts commit c0cbe4bb0c.
2026-10-19 18:33:15 +00:00
Hermes (godot-vr)
785a858ae0 Thumb-.import vervollstaendigt (30 Fahrzeug-Thumbs, t_9675e48d Nachziehen) 2026-10-19 18:31:42 +00:00
Hermes (godot-vr)
c0cbe4bb0c Pack-Texturen (t_9675e48d): Editor-Viewport + Thumbs texturiert
GlbMeshStore setzt fuer texturlose Military-GLBs die Pack-Palette-Textur
(Atlas per absolutem Pfad aus dem Client-Mesh-Lager, NEAREST + Mipmaps),
gen_thumbs nutzt denselben Helfer → Thumbs zeigen die echten Palette-
Farben statt weisser Silhouetten. 261 Thumbs neu gerendert (317 gesamt).
Beweis: tests/probe_pack_texturen.gd headless — 7/7 Flaechen texturiert.
2026-10-19 18:26:47 +00:00
Hermes (scopedvr-dev)
e1e07e57b0 D6 (t_31e800f2): Editor-Sync — 30 Fahrzeug-Thumbs + Map-Builder-Nachweis
- library/objects.json = 347 Objekte (Merge-Stand Match-Server)
- library/thumbs/: 30 neue Thumbs (tank/apc/air_defense/antenna), in
  Kategorie-Farbe Oliv (D0-Muster) — uebrige 317 Thumbs byte-identisch
- tests/probe_d6_mapbuilder.gd: 3 Fahrzeuge ueber den ECHTEN Map Builder
  platziert (Panzer + Turm exakt auf dem Turm-Ring 1,5929 m + Transportpanzer,
  dazu die Kanone auf 2,1273 m), Kollisionsboxen als Drahtgitter,
  PNG 1600x1000 — PROBE_D6_MAPBUILDER_OK
2026-10-19 18:25:15 +00:00
scopedvr-dev
8f598dc67d Test-Fix (t_03d0f442): test_library-Katalog-Check datengetrieben statt harte 54
Die harte Erwartung lib.size() == 54 (15 Basis + 39 Natur-Update) brach bei
JEDER Kategorie-Karte: schon der A6-Stand hatte 56 (2 Beispiel-Objekte), nach
D1-D5 sind es 317. Der Check ist jetzt datengetrieben:

- Basis-Bestand bleibt garantiert: lib.size() >= 54 (Merge-Fundament A6) —
  der Katalog darf nur wachsen, schrumpfen failt weiterhin.
- Neu: Registry <-> Thumbnails deckungsgleich (je OID ein 128px-Thumb, keine
  verwaisten Thumbs) — fängt vergessene/abgebrochene sync_objects-Läufe.
- Neu: OID-Vergleich gegen die gemergte Match-Server-Registry
  (../scopedvr-match-server, Quelle der Wahrheit aus objects.d/*), wenn das
  Sibling-Repo liegt; ohne Sibling verkürzt sich der Test statt zu failen.
- Testgruppe Registry-Fußpunkt: "alle 54 Objekte" -> dynamische Zahl im Titel.

Suite grün auf main: 95/95 checks (ist 317). Beweis Drift-Erkennung: eine
injizierte Fremd-OID in der MS-Registry failt den neuen Vergleich gezielt.
2026-10-19 18:23:56 +00:00
Hermes (scopedvr-dev)
6572f1e10c D0 (t_47f05319): 317 Thumbs mit Kategorie-Farben neu gerendert + Thumb-Licht entschärft
- gen_thumbs: ambient 0.7->0.35, sun 1.1->0.85 — helle Albedos überstrahlten
  sonst zu Weiss im 128px-Thumb
- alle 317 Thumbs neu (263 Military-Desert-GLBs mit baseColorFactor-Farben)
2026-10-19 18:22:32 +00:00
Hermes (scopedvr-dev)
6f81add8f4 D7 (t_f77bc681): Editor-Notiz wiederhergestellt + .import-Dateien der Thumbs vervollstaendigt
* library/objects.json: D4's Sync (6c65b13) hatte die Editor-eigene `note` mit
  der Server-Notiz ueberschrieben (c92d35d/29b1668/0f86182 tragen alle die
  Editor-Notiz). Registry-Inhalt unveraendert: 317 Objekte, Merge --check gruen.
* library/thumbs: 147 fehlende .import-Dateien nachgezogen (88 Natur-Thumbs aus
  D5 + 59 Prop-Thumbs aus D4) — Godot regeneriert sie sonst bei jedem Lauf neu
  und hinterlaesst ungetrackte Dateien. Die Thumb-PNGs selbst sind unveraendert
  (317 PNG, Thumb-Zahl == Objekt-Zahl).
2026-10-19 18:15:22 +00:00
Hermes (scopedvr-dev)
6c65b13a8f D4 (t_691b51ce): Editor-Sync — 59 neue Prop-Thumbs + Map-Builder-Probe (3 Props mit Kollisionsboxen) 2026-10-19 18:11:07 +00:00
Hermes (scopedvr-dev)
b288e8a1e8 D3 (t_6690c59c): .uid der Render-Probe nachgezogen 2026-10-19 18:09:34 +00:00
Hermes (scopedvr-dev)
c92d35dca2 D3 (t_6690c59c): Render-Probe — Gebaeude in einer Reihe, Kamera naeher (Sichtpruefung Fusspunkt/Groesse) 2026-10-19 18:08:40 +00:00
Hermes (scopedvr-dev)
29b1668f57 D3 (t_6690c59c): Gebaeude in der Editor-Bibliothek (93 Objekte, 37 neue Thumbs) + Map-Builder-Render-Probe 2026-10-19 18:08:05 +00:00
Hermes (scopedvr-dev)
56a808f052 D1 (t_78746c97): Waende in der Editor-Bibliothek (Registry-Kopie + 50 Thumbs) + Render-Probe 2026-10-19 18:07:02 +00:00
Hermes (scopedvr-dev)
c0d426152e D2 (t_33250710): Editor-Bibliothek 83 Objekte + 27 Absperrungs-Thumbs + Renderprobe
- library/objects.json: Editor-Kopie (note behalten), 83 Objekte.
- library/thumbs: 27 neue Thumbs (echte GLB-Meshes), Alt-Thumbs unveraendert.
- tests/probe_d2_barriers_render.gd: Map-Builder-Beweis — 3 Objekte ueber
  map_view_builder platziert, PNG gerendert, Fußpunkt/Groesse gemessen.
2026-10-19 18:06:55 +00:00
Hermes (scopedvr-dev)
ebe3dd276b D5 (t_e81aed01): Render-Nachweis tools/probe_d5_render.gd (Fels/Kaktus/Stein/Decal platziert) 2026-10-19 18:06:39 +00:00
Hermes (scopedvr-dev)
0f86182a2c D5 (t_e81aed01): Editor-Sync — Bibliothek 144 Objekte + 88 Natur-Thumbs (gen_thumbs) 2026-10-19 18:05:41 +00:00
Hermes (scopedvr-dev)
310aa695e6 A6 (t_c5a27ca7): Editor loest Kategorie-GLBs aus der Registry auf (GlbMeshStore + gen_thumbs), Thumbs fuer Beispiel-Objekte 2026-10-19 18:02:12 +00:00
Hermes (scopedvr-dev)
76f8143540 A6 (t_c5a27ca7): Merge-Script ortsunabhaengig, Editor-Sync importiert vor Thumbs, Client-Probe belegt Registry-GLB 2026-10-19 18:01:08 +00:00
Hermes (scopedvr-dev)
9dca88e10e A6 Fundament (t_c5a27ca7): Fragment-System + Merge + Editor-Sync fuer Baukasten-Objekte
library/objects.json ist generiert: library/objects.d/*.json (ein Fragment pro
Kategorie) + tools/merge_object_library.py (idempotent, --check, --aabb-Messung,
--new-fragment). base.json = Alt-Bestand (54 Objekte) unveraendert. Beispiel-
Kategorie beispiel_fass_01/02 beweist die Kette end-to-end. Doku:
docs/objekt_fragmente.md.
2026-10-19 18:00:24 +00:00
Slyphax-Hermes
1cfc4e8c3a Update-Test-Zeile aus dem H-Overlay entfernt — Selbst-Update-Kette und Restart-Fix live verifiziert (17.10., 2 Zyklen) 2026-10-17 21:56:43 +00:00
Slyphax-Hermes
f6f5ef6c90 Selbst-Update-Test Zyklus 2: Restart-Verifikation — soll die APP selbst neu öffnen (Fix f99d1e2 im Speicher) 2026-10-17 21:54:23 +00:00
Slyphax-Hermes
f99d1e2a28 Fix Selbst-Restart (Live-Befund 17.10.): Update-Restart ging auf den Godot-Editor statt die Map-Editor-APP — self_restart_args ohne --editor (die App startet über main.tscn mit nacktem --path), Kommentar + test_updater angepasst 2026-10-17 21:53:37 +00:00
Slyphax-Hermes
23f7e1d39c Selbst-Update-Test 17.10: sichtbare Test-Zeile im H-Overlay — Sly soll Update-Check + Jetzt-updaten live ziehen 2026-10-17 21:50:40 +00:00
Hermes (godot-vr)
4c960e2587 test_testrun: Stale-Export-Falle behoben (t_2a90ceab): _cleanup purgt user://testrun KOMPLETT statt nur Testmap.svmap — eine Stale-Export-Datei fremden Owners (Root-Testlauf mit HOME=/home/slyphax erzeugt root-gehoerige Dateien in slyphax-Appdata; ein slyphax-Lauf kann sie nicht ueberschreiben) liess save_to_file still mit Datei-nicht-schreibbar (Fehler 12) scheitern, waehrend Temp-Datei-existiert + Roundtrip dank der alten Datei faelschlich gruen blieben — darum wirkte der 1/14-FAIL pre-existing ohne jeden Code-Bezug. Der Ordner-Owner darf unlinken, der Purge raeumt darum auch root-Dateien weg; zusaetzlich druckt der Export-Check jetzt die save-Fehlerliste (Diagnose ohne Probe-Script). Verifikation: Suite als slyphax UND root je 14/14 ALL OK; Fehlermeldung per Probe als slyphax bestaetigt. 2026-10-17 22:55:05 +02:00
slyphax
73847ed1ce Map-Editor Selbst-Update gehärtet (t_1457e935): KEIN Credential-Dialog mehr — git-Läufe nie interaktiv — Root-Cause: der systemd-User-Manager exportiert SSH_ASKPASS (ksshaskpass); ein nackter git-Spawn im Editor hat damit mitten in der Update-Prüfung das KDE-Login-Fenster geöffnet, und der Credential-Store (~/.git-credentials) war leer (0 Bytes). Fix (Belt AND Braces, EditorUpdater.spawn_argv): JEDE git-Ausführung läuft über /usr/bin/env mit GIT_TERMINAL_PROMPT=0 + GIT_ASKPASS=/bin/true + HOME aus der eigenen Umgebung (kein Hardcode), dazu -c credential.helper= (Helper-Reset, sonst öffnet z. B. der Windows-Credential-Manager sein GUI) + -c credential.helper=store + core.askPass=/bin/true (Askpass-Fallback tot, auch ohne env-Wrapper). Askpass-Präzedenz am PC verifiziert: core.askPass/GIT_ASKPASS schlagen geerbtes SSH_ASKPASS — Fake-Askpass startet nicht, sauberes Authentication failed Exit 128 statt GUI. Neuer Status auth_failed mit klarem UI-Text („Updates nicht prüfbar — Zugangsdaten fehlen (kein Login-Dialog geöffnet)“), Auth-Erkennung pure (looks_like_auth_error: could-not-read-Username / Authentication failed / terminal prompts disabled / Unauthorized — Connection refused ist KEIN Auth-Fehler); pull-Fehler bekommen denselben Zusatz-Hinweis. Credential-Store am PC befüllt (Forgejo-Token für 127.0.0.1:13020, chmod 600) — ls-remote/pull/ls-remote-push laufen stimmlos. Tests: test_updater +10 Checks (auth-Erkennung 4, spawn_argv-Zusammensetzung 6) → 47/47 ALL OK; komplette Suite 12/13 grün (test_testrun: 1/14 FAILED — PRE-EXISTING, per git-stash-Verfahren verifiziert, Ursache unabhängig von diesem Diff, Follow-up-Karte folgt). docs/tasten.md: Abschnitt Updates um „Zugangsdaten fehlen“ ergänzt. 2026-10-17 22:49:42 +02:00
Hermes (godot-vr)
f2b4f6a824 Map-Editor Selbst-Update (t_d95f087a): Start-Check + Banner + Jetzt-updaten + Pruef-Button — EditorUpdater (src/editor/updater.gd): stiller Check beim Start (git ls-remote origin/main vs. lokaler HEAD, keep-alive-leicht), Bernstei-Banner „🔄 Update verfügbar: alt → neu“ mit Knopf „Jetzt updaten“, Datei-Menü-ID 7 „🔄 Updates prüfen“ mit Statuszeilen-Ergebnis (Update verfügbar / auf dem neusten Stand / Nicht erreichbar). Update-Ausführung: Dirty-Schutz via _request_lossy (derselbe deutsche Dialog wie Strg+O/N, Abbrechen bricht ab), dann git pull --ff-only (Tunnel + credential store) → Godot-Import-Lauf (--headless --import) → Neustart. Import-Ausgabe wird geparst: Parse-/SCRIPT-Fehler trotz Exit-Code 0 = Abbruch mit Meldung, KEIN Neustart (Lektion 17.10. class_name-Cache). Restart plattformneutral: cfg [update] restart_command > systemd-User-Unit map-editor auto-erkennung (systemd-run --on-active=3s Kick im eigenen Scope, überlebt den Unit-Neustart) > Selbst-Restart (dieselbe Godot-Binary, --editor --path). Keine Linux-Pfade hartkodiert; git_command aus cfg möglich. Offline: kein Banner, kein Fehler-Geballer. Ein-Hintergrund-Thread je Richtung (check/update) mit Busy-Guard, Thread-join über call_deferred-Ordnung. Tests: neue Suite test_updater (37 Checks: Check divergiert/gleich/unreachable/kein-Repo, Banner-Texte, Pull gegen echte Temp-Klone, Import-Abbruch-Pfad inkl. Exit-0-Falle, Restart-Auflösung), Suite 13/13 ALL OK (997 Checks). docs/tasten.md: Abschnitt „Updates“; H-Overlay-Zeile. 2026-10-17 19:38:07 +00:00
Hermes (godot-vr)
2065ef2252 Map-Editor R3 (t_c863b93b): Kartengröße-Dialog + Menü + Undo-Verdrahtung — neuer Dialog „Kartengröße ändern“ (Datei-Menü, Stil wie new_map_dialog): aktuelle Größe/Auflösung, Freifelder X/Z (256–4096 clamp) + Preset-Buttons 256/512/1024/2048, Live-Preview aus resize_preview (R2): Verlust-Liste beim Verkleinern (Objekte/Bauteile/Spawn-Punkte draußen) mit Entfernen-Schalter, V9-Warnung (Drop aus = Map nicht speicherbar), Spawn-Speicher-Warnung (< 4 Punkte/Team, V11), Warnband beim Vergrößern über ~1024 m (Dichte-Deckel 513 → m/Punkt, Quest-Perf). Ausführen: resize_apply → doc IN-PLACE ersetzt (History-Referenz bleibt gültig) als EINE Undo-Einheit (kompletter doc-Snapshot), Dirty-Flag, Terrain/Viewport-Rebuild + Kamera-Refit. ActionHistory: snapshot/_restore_block vertragen jetzt skalare Ober-Keys (format_version/weather) → Ganz-doc-Snapshots möglich. Tests: neue Suite test_resize_dialog (40 Checks: Dialog-Aufbau/Clamp/Preview-Texte, Menü-Verdrahtung, Undo-Roundtrip BYTE-IDENTISCH, Redo, Drop-aus-Pfad), komplette Suite 12/12 ALL OK (960 Checks). docs/tasten.md: Abschnitt „Kartengröße ändern“. 2026-10-17 19:01:15 +00:00
Hermes (godot-vr)
116acd63ed Map-Editor R2 (t_27ba6c37): Verkleinern + Out-of-Bounds-Liste + Filter — resize_preview() listet je Kategorie (Objekte nach Anker-Regel, Bauteile nach Fußabdruck-"punkte" mit Rotation, Spawn-Punkte nach Punkt-Regel; Kante exakt = drin, Rand-Überlapper bleiben unange tastet), resize_apply(doc, x, z, drop_outside) resampelt wie R1 und entfernt bei Bedarf genau die gelisteten Einträge (Eingabe-doc bleibt rein). Shrink 512→256: Terrain an Weltposition 1:1 (±0.05 m), res bleibt 129 (2 m/Punkt). Tests: 19 neue Checks in test_terrain (Preview/Filter/Roundtrip), 11 Suiten ALL OK (920 Checks). 2026-10-17 18:56:10 +00:00
Hermes (godot-vr)
2d8b9822f0 Map-Editor R1 (t_0d436687): Resize-Kern + Vergrößern — SvmapDocument.resize_terrain(), Anker Mitte/Rand flach, Dichte-Leiter 129→257→513.
Semantik aus PLAN.md „Map-Größe ändern" (17.10., Sly-geankert):
- Anker Mitte: Karte bleibt am Ursprung zentriert, bestehendes Terrain behält
  seine WELTPOSITION (bilinear resampeln, kein Stretch).
- Neue Randzone: flach auf min_height + Material 0 (Gras), wie create_new.
- Dichte-Regel: würde m/Punkt über ~2 sinken, res hoch 129→257→513
  (Format-Limit 513 hart — darüber Warnband, Anzeige in R3).
- size clamp 256–4096 m (MIN_SIZE_M/MAX_SIZE_M), bounds auf neue Größe
  (Y-Grenzen unverändert).
- R1-Schnitt: spawn_zones/objects/gebaeude unverändert übernommen (Filter +
  Out-of-Bounds-Dialog kommen in R2). Reine Funktion — Eingabe-doc bleibt
  unangetastet (Undo-Snapshot), alte Prüfsumme verworfen (save signiert neu).

Tests (test_terrain.gd, 22 neue Checks → Suite 134/134, 11 Suiten ALL OK):
Grow 256→512 — alte Gridpunkte ±0.01 m an Weltposition, Rand flach/Gras,
Material resampelt, res-Bump 129→257 (bei 2048 m gedeckelt auf 513),
spawn/object-Erhalt, svmap-v2-Roundtrip, Clamp.
2026-10-17 18:48:21 +00:00
Hermes godot-vr
b85e3bed52 Map-Editor: Höhenlinien nur im Terrain-Werkzeug (t_89c0ec95)
Sly-Wunsch 17.10.: "die Dauersicht stört" — die Konturen sind genial, aber
nur im Terrain-Werkzeug (F1) gewünscht.

Kopplung am Werkzeug-Wechselpunkt: _set_tool_mode, _after_structure_change
und _recheck_terrain_mode eichen die Konturen NACH (effektiv = Nutzer-Fix
ODER Terrain-Werkzeug). Kein _process-Poll — die Umschaltung passiert
synchron im selben Aufruf, hart ohne Fade (billig und deterministisch).

NEU: Taste L (Action editor_contours_toggle; L war sonst unbesetzt —
Kollisions-Prüfung im Test) und der Button „≈ Höhenlinien" fixieren die
Linien als NUTZER-Wahl dauerhaft in jedem Werkzeug. Default = Fix AUS,
Linien erscheinen also nur bei F1.

Regression aus dem Fundus gefixt: _after_structure_change baute
terrain_view neu OHNE Kontur-Nacheich (das Gitter hatte ihn, die Linien
nicht) — nach jedem Strg+Z kehrte die Dauersicht zurück. Der Rebuild
bekommt jetzt denselben Nacheich wie das Gitter.

Das Relief-Shading (Hillshade + Höhen-Kurve) bleibt davon unberührt und
immer an. Auf flachen Maps (< 2 m Relief) schaltet der Shader weiter
selbst auf aus.

Doku: docs/tasten.md (L-Zeile in Werkzeuge + Kopplungs-Absatz) und
H-Overlay-Zeile (help_text.gd).

Tests: test_shortcuts +5 Checks (Action registriert, physische Taste L,
Kollisions-Freiheit, L ≠ F1/H). test_gui_smoke +21 Checks am ECHTEN main:
Start AUS · Undo/Rebuild bleibt AUS (Root-Cause-Regression) · F1 ein →
F2/F3/F4 aus (synchron, 0 Frames Poll) · L-Fix an/aus inkl. Button-Sync
und Statusmeldungen · Button-Weg ditto.
Suite 11/11 ALL OK: ux 137, shortcuts 203, editor_svmap 31, terrain 112,
library 94, glb_view 28, spawns 52, testrun 14, bauen 87, gui_smoke 89,
rmb_vorrang 32.
2026-10-17 18:24:45 +00:00
Hermes (godot-vr)
d6a0fe2196 Relief-Nachreich (t_3ebe4086): relief_color klammaert Kanaele auf 0..1 — der Mesh-Speicher ARRAY_COLOR/RGBA8 clamp't genauso, der Helfer muss die Render-Wahrheit liefern, sonst liegt der Probe-Vergleich um >0.05 daneben. Live-Material-Test t_55ed041a erwartet jetzt dieselbe relief_color-Formel statt der alten 0.72..1.15-Konstanten. Suite: test_terrain 112/112, 11 Suiten ALL OK. 2026-10-17 18:06:39 +00:00
Hermes (godot-vr)
ffb27645d4 Terrain-Relief lesbar machen (t_3ebe4086) — Sly-Befund 17.10. "Erhoehungen/Vertiefungen kaum erkennbar, alles knallgruen und hell".
1) Hoehen-Faktor kontrastreicher kalibriert: S-Kurve t*t*(3-2t) statt linear, Spanne 0.72..1.15 -> 0.58..1.28 (Senken deutlich dunkler, Huegel deutlich heller).
2) Pseudo-Licht (Hillshade) in den Vertex-Farben: Terrain-Normale x feste Sonnenrichtung RELIEF_SUN=(0.45,0.75,0.5) (seitlich-hinten fuer den Default-Blick), Licht-Faktor 0.62..1.22, Gesamtklemme 0.40..1.35. Formel/Konstanten IDENTISCH zum Match-Client (svmap_terrain.gd im scopedvr-client-Repo) — relief_color() static, Material-Farben unangetastet.
3) Editor-only Extra: Hoehenlinien alle 1 m + kraeftige 5-m-Indexlinie (terrain_contour.gdshader, UV2.x = Vertex-Hoehe, fwidth-stabil, unshaded). Der Quest-3-Vertrag "kein Custom-Shader" gilt fuer das SPIEL — der Editor darf teurer sein (DECISIONS-Konvention); der Match-Client bleibt StandardMaterial3D. Flach-Map-Schutz: Linien erst ab 2 m Gesamtrelief, UI-Toggle "≈ Hoehenlinien" (Default AN, nach jedem Rebuild re-synchronisiert).
Material unshaded: das gebackene Shading ist exakt das sichtbare (kein Doppel-Shading mit dem Viewport-Sonnenlicht), deterministisch fuer die Render-Probe.

Tests: test_terrain +19 Relief-Checks (99 gesamt): Tal/Huegel-Delta > 0.10 UND > 1.2x alte Kalibrierung, Hang-zur-Sonne heller, Stufen-Kanten-Delta > 0.08 UND > 1.2x alt, Materialfarben (Gras/Sand/Fels) unterscheidbar, Alpha=1.0, Kontur-Toggle + Flach-Map-Schutz. Suite 11 Suiten ALL OK: test_ux 137, shortcuts 198, editor_svmap 31, terrain 99, library 94, glb_view 28, spawns 52, testrun 14, bauen 87, gui_smoke 68, rmb_vorrang 32. (Hinweis: gui_smoke-F5 schlug in DIESEM Workspace nur wegen des scopedvr-client-Symlinks neben dem Editor auf — TestRun.find_client findet den Client dann wirklich; ohne Symlink = Standard-Layout: 68/68.)
Renderprobe (xvfb, opengl3): tests/probe_terrain_relief_render.gd — Stufen-Delta 0.149 (>0.10), Hoehenlinien Pixel-Diff 0.167, Huegel/Senke-Fenster-Delta 0.225 — ALL OK 3/3; PNGs im Scratch (terrain_relief_stufe.png, terrain_relief_demo.png).
Housekeeping: fehlende .png.import-Sidecars der M2-Natur-Thumbs nachgereicht.
2026-10-17 18:05:20 +00:00
Hermes (godot-vr)
e1c737df61 Terrain: Material-Farben bleiben LIVE während Heben/Senk-Stroke (t_55ed041a) — Live-Rebuild bekommt die Material-Schicht mit
Sly-Befund: Beim Heben/Senken über gemalten Sand/Fels verschwanden die
Material-Farben live (alles Gras), erst am Stroke-Ende kehrten sie mit
dem Vollrebuild zurück.

Root-Cause: terrain_tool rief im Live-Pfad (Heben/Senken/Glätten)
update_terrain(_heights) OHNE Material-Schicht auf — _mats wurde nur im
Malen-Modus dekodiert — und terrain_mesh fällt bei leerer Schicht auf
alles-Gras zurück (Legacy-Fallback).

Fix (zwei Ebenen):
- terrain_tool._refresh_from_doc dekodiert die Material-Schicht immer,
  der Live-Stroke übergibt sie mit (Heben/Senken/Glätten wie Malen).
- terrain_mesh cached die zuletzt gesehene Schicht: Live-Updates ohne
  mats-Param nutzen denselben Farb-Pfad wie der Idle-Rebuild; Legacy-v1
  (nie gesehene Schicht) bleibt alles Gras — kein Phantom-Cache.

Test: tests/test_terrain.gd neue Gruppe „Live-Material-Farben" —
per-Frame-Check (5 Frames Heben über Sand-Zone, nicht nur Endzustand),
terrain_tool-Integration (Heben-Stroke übergibt Sand-Schicht, Stroke-Ende
= EIN Commit), Legacy-Gras ohne Phantom-Cache. Suite: 99 checks ALL OK;
alle weiteren Headless-Suiten grün (test_glb_view 12/22 FAIL ist
vorbestehend auf origin/main — GLB-Quellen fehlen im Workspace).
2026-10-17 17:51:07 +00:00
Hermes (godot-vr)
db6bcec971 Flugmodus-Tasten: Leertaste=hoch, Shift=runter, Strg=schneller (Sly 17.10., Shooter-Konvention)
- InputMap (shortcut_keys.gd): editor_fly_up=Space, editor_fly_down=Shift,
  editor_fly_fast=Ctrl (vorher E/Q, Shift=fast). E/Q sauber entfernt, keine
  Aliasse (Sly-Freispruch). Alt=langsamer unveraendert, Mausrad bleibt
  Grundtempo.
- Konflikt-Check (verpflichtend): Space/Shift/Ctrl allein triggern KEINE
  Werkzeug-Aktion — neue Testgruppe prueft InputMap-Matches (kein Undo/Save/
  Redo/Pinsel/Freelook-Toggle ohne Combo-Partner) + echtes Terrain-Werkzeug:
  Tasten-Events starten keinen Strich. Strg+LMB-Senken bleibt LMB-gebunden,
  Entf/Strg+Z unangetastet.
- orbit_camera._enter_fly gibt den GUI-Fokus frei, damit Leertaste keinen
  fokussierten Button ausloest (Space=ui_accept, Bibliothek=Platzierung).
- Livetest: Leertaste hebt/Shift senkt die Kamera im Flug (orbit_camera).
- Docs: docs/tasten.md, H-Overlay (help_text.gd), README, Status-/Hilfe-
  Texte der Kamera aktualisiert.
- Test-Hygiene: _test_camera_fly_roundtrip ist jetzt Coroutine (await),
  view_yaw-Orbit-Toleranz 0.01 Grad (float32-Rundtrip-Drift ~2e-4 Grad/Exit).
- Suite: 11 Suiten gruen (test_shortcuts 198 checks).
2026-10-17 17:45:37 +00:00
Hermes (godot-vr)
d6c472603f Build-Tool: ray_pick ohne 500-m-Cap (t_555e33da) — Umschalt+Klick/Baustapel-Pick funktioniert jetzt auch im Default-Framing (~614 m Kamera-Distanz); Workaround-Zoom im RMB-Test entfernt. 2026-10-17 17:39:30 +00:00
Hermes (godot-vr)
7521db7f8d Map-Editor: RMB = Flug mit absolutem Vorrang (t_2470de89) — Terrain-Senken auf Strg+LMB, Hand-Objekt-Abwurf auf Esc.
- Terrain-Pinsel: Rechtsklick startet keinen Strich mehr; Strg+LMB invertiert
  (Heben↔Senken, Blender/Photoshop-Konvention; Malen: Strg+Klick = Gras zurück).
- Platzieren: RMB bricht nicht mehr ab — Objekt bleibt in der Hand, Abwurf nur
  über Esc. build_tool/spawn_tool: RMB-Deselect/Werkzeugwechsel entfernt
  (Auswahl-Deselect = Esc / LMB auf Leere).
- orbit_camera: rmb_filter entfernt — Flug startet in JEDEM Werkzeugzustand.
- docs/tasten.md + H-Overlay + Status-/Paneltexte aktualisiert.
- Neuer Test tests/test_rmb_vorrang.gd (32 Checks, echte Main-UI headless);
  in tools/run_tests.sh aufgenommen. Suite grün (test_glb_view: 12/22 FAIL auch
  auf origin/main — fehlende GLB-Quellen im Scratch-Workspace, umgebungsbedingt).
2026-10-17 17:37:09 +00:00
slyphax
cda6f48b01 GLB-View (t_9b50b231): echte Modelle im Editor-Viewport — Platzhalter nur als Fallback.
- GlbMeshStore (neu): laedt OID+.glb aus konfigurierbaren Quellen
  (ProjectSettings scopedvr/editor/glb_quellen, Voreinstellung =
  Sibling-Repos, wie gen_thumbs; plattformneutral, relative Pfade),
  backt ALLE MeshInstance3D der Szene in EIN normalisiertes ArrayMesh
  (Fusspunkt y=0, XZ zentriert — wie scopedvr_export.normalisiere),
  Materialien/Texturen bleiben je Oberflaeche erhalten.
- Cache pro OID (Parse nur einmal; fehlende Datei ungcached, Parse-Fail
  negativ gecached).
- map_view_builder: echtes GLB vor Platzhalter; kein half_height-Lift
  (schon Fusspunkt-Koordinaten), Scale ueber Node-Scale + AABB-Metas
  mal Scale; Auswahl-Orange nur als Override im Auswahl-Zustand
  (Texturen bleiben sichtbar). fehlende_glbs-Meldung an die Stats-Zeile.
- interaction (Ghost-Preview): nutzt denselben echten Mesh-Pfad;
  Platzier-Status haengt „(Platzhalter — echte GLB fehlt)“ an.
- tests/test_glb_view.gd (neu, in run_tests.sh): Moosfelsen-Probe
  (echtes GLB > Platzhalter-Vertices), stein_mittel_1 1026>>36,
  Normalisierung y=0/XZ-zentriert, Cache-Identitaet, Viewport-Rebuild
  Fusspunkt == Terrainhoehe (b20dff2), Fallback + fehlende_glbs.
  Suite: 773 checks, alle gruen.
2026-10-17 17:23:56 +00:00
Hermes (godot-vr)
afcbda8dd0 Shortcuts 3/3 (t_11b5c25b): Strg+O/N/Umschalt+S + Redo-Wege dokumentiert, F1-F4 Werkzeugwechsel, H-Kurzhilfe-Overlay, F5 Karte testen, Dirty-Schutz vor Neu/Oeffnen, permanente Werkzeug-Anzeige in der Statuszeile. 2026-10-16 17:58:34 +00:00
Hermes (godot-vr)
08a75ac430 Shortcuts 2/3 (t_55257792): R-Drehen + Pfeil-Raster + PgUp/Down + Strg+Bild↑ + Greifen + Terrain-Pinsel-Tasten.
- R = Auswahl 90° drehen (Szene um Auswahl-Mitte, Bauen-Bauteil um Fußpunkt, Gruppe komplett; Umschalt+R = -90°), Mausrad-Feindrehen 15° bleibt
- Pfeiltasten = 2-m-Raster-Verschiebung, Richtung = Kamera-Blickrichtung auf nächste Weltachse gerastet (MoveMath.nudge_delta, pure)
- Bild↑/↓ = ±0,25 m auf der GANZEN Auswahl (EIN Undo), Strg+Bild↑ = Fußpunkt aufs Terrain/Boden absetzen
- G = Greifen (Blender-Semantik): Auswahl hängt an der Maus, LMB setzt mit Raster-Snap, Esc rollt byte-genau zurück (ShortcutPolicy cancel_grab)
- Terrain-Pinsel: [ / ] = Preset-Zyklus Klein/Mittel/Groß (Slider/Label/Preset-Buttons wandern mit), 1-4 = Heben/Senken/Glätten/Malen (PAINT-Clamp-Fix, Palette mit Malen sichtbar); Ziffern NUR im Terrain-Werkzeug
- Strg halten = temporärer Snap-Zwang beim Bewegen/Greifen/Platzieren
- Tests: test_shortcuts.gd 157 Checks (R byte-genau, Pfeil-Raster, Lift-Stufen, Boden-Snap, Greifen+Esc, Pinsel-Zyklus, History-Eintrag pro Operation), test_gui_smoke.gd erweitert (38 Checks) und in run_tests.sh aufgenommen; F-Regression seit Karte 1 damit abgedeckt
- Suite 9/9 grün (696 Checks), Builder-Export 21/0, Parse-Check 11 Dateien grün
2026-10-16 17:53:10 +00:00
Hermes (godot-vr)
095af5ff3c Shortcuts 1/3 (t_c46d31c2): Freelook-Flug + F-Focus + Esc-Policy + Entf-Mehrfachauswahl + Strg+D (+2 m X) + Strg+LMB-Mehrfachauswahl.
- InputMap-Actions mit physischen KEY_*-Codes (shortcut_keys.gd, idempotent) — DE-Layout/Windows-unabhängig
- Freelook: RMT halten = Umsehen+Flug (WASD/EQ, Shift x3, Alt x0.3, Rad = Grundtempo dauerhaft), Shift+F toggle; Orbit bleibt Default, Flug-Exit ohne Kamera-Sprung (FlyMath pure)
- F zentriert Auswahl (SelectionMath.focus_center); Esc = pure Prioritätskette (ShortcutPolicy)
- Strg+LMB + Baum SELECT_MULTI: Mehrfachauswahl synchron Szene↔Baum; Entf/Strg+D wirken auf die ganze Auswahl (EIN Undo-Schritt)
- docs/tasten.md = deutsche Source of truth (Karten 2/3 ergänzen)
- Tests: test_shortcuts.gd NEU 70 Checks, Suite 571 grün, Builder-Export 21/0, Parse-Check grün
2026-10-16 17:43:17 +00:00
Hermes (godot-vr)
b20dff2b5d Terrain C (t_4092e3a0): Objekt-Platzierung == Ghost-Preview — EIN Platzier-Pfad, Fusspunkt auf Terrainhoehe.
Root-Cause: Ghost-Preview und Platzierung nutzten zwei unterschiedliche
Pfade/Strukturen — der Ghost-Root trug den halben Mesh-Lift selbst
(Root=Y+half), das platzierte Objekt hebt nur das Kind-Mesh; die
Platzierung rastete X/Z und hob Y auf terrain_height_at des UNGERASTETEN
Punkts. Bei Raster-Snap und an Haengen wich die platzierte Position von
der Vorschau ab.

Fix — EIN Pfad (ObjectOps.placement_foot): Raster-Snap auf X/Z, dann
Y = Terrainhoehe am GERASTETEN Punkt (SvmapDocument.terrain_height_at).
Ghost-Root = Fusspunkt, Kind-Mesh um half_height gehoben — identische
Struktur wie map_view_builder. Objekt-Cursor trifft jetzt das GELAENDE
(TerrainField.raycast, gleicher Pfad wie Terrain-/Bauen-/Spawn-Werkzeug)
statt der flachen Basis-Ebene; Hoehen-Cache dekodiert einmal pro
Struktur-Aenderung (refresh_terrain_cache aus _after_structure_change).

Tests (test_library 40->94 Checks): Platzier-Fusspunkt (Rampe 0..20 m:
Fusspunkt == Terrainhoehe, Ghost-Pfad == Platzierungs-Pfad, Raster-Snap-Y
am gerasteten Punkt), Registry-Fusspunkt (alle 54 Objekte: Mesh-Unterseite
== pos, kein Schweben/Eingraben), Ghost-Preview == platziertes Objekt mit
simulierter Kamera (Treffer-Zelle == Cursor-Zelle, <5 cm). Editor-Suiten
501 Checks gruen, Builder-Export 21/0, GUI-Smoke 20 gruen.
2026-10-16 17:36:30 +00:00
Hermes (godot-vr)
e505329a42 Terrain B (t_234490cf): Pinsel-Position == Cursor-Position — Ray→Terrain-Mapping gefixt
Root-Cause 1 (Spiegelung): terrain_mesh.gd positionierte die Mesh-Vertices
gespiegelt (+0.5 − index·cell in X UND Z), während ALLE anderen Verbraucher
der Heightmap der Dokument-Konvention folgen (Index 0 = −X/−Z-Ecke):
SvmapDocument.terrain_height_at, TerrainField.height_at/apply_brush und der
Match-Client (svmap_terrain.gd). Der Renderer war der einzige Ausreißer —
der Pinsel-Strahl traf das gerenderte Mesh punktgespiegelt um die Map-Mitte
(Sly-Befund: Erhöhung erschien an ganz anderer Stelle). Fix: Vertices auf
−size*0.5 + index*cell (identisch zum Client), Normalen-Vorzeichen angepasst
(n ∝ (−∂h/∂x, 1, −∂h/∂z)); Winding bleibt gültig (180°-Rotation erhält die
Orientierung, Kreuzprodukt-Normale weiter nach unten = Frontseite).

Root-Cause 2 (Tangenten-Graze): Ein Strahl, der exakt auf eine Hügelkuppe
zielt, grast die gewölbte Oberfläche — f(t) bleibt minimal knapp über 0 und
steigt danach wieder, obwohl weiter hinten (VERDECKT hinter dem Hügel) noch
ein Vorzeichenwechsel an der Basis-Ebene lag. Der March lief durch und der
Pinsel sprang auf den verdeckten Basis-Punkt meterweit weg. Fix in
TerrainField.raycast: Regel 'erster sichtbarer Oberflächen-Kontakt gewinnt' —
kommt f innerhalb des Bandes (½ March-Schritt) an die Oberfläche und entfernt
sich der Strahl wieder, wird das Graze-Minimum lokal per Ternärsuche
verfeinert und als Treffer zurückgegeben. Echter Treffer (f ≤ 0) gewinnt
weiterhin immer; betrifft auch Bauen-Werkzeug und Spawn-Zonen (gleiche
Raycast-Routine).

Tests (test_terrain.gd, 64 → 86 Checks): Neue Gruppe 'Ray→Terrain-Mapping'
mit simulierter Kamera (SubViewport, schief + steil): exakter Screen-Punkt
(unproject) → project_ray → raycast → asserted Treffer-Zelle == Cursor-Zelle
(< 1 Zelle) auf Mitte/Innenraum/Corner/Kanten; Brush-Stroke am Treffer mit
Höhen-Änderungs-Zentrum == erwarteter Zelle; erhabenes Terrain (Strahl trifft
NEUE Oberfläche, y = Hügelhöhe); Mesh-Vertices komplett gegen die
Dokument-Konvention asserted (Root-Cause-Regression). Hügel-Normalen-Checks
auf die korrigierte Konvention umgestellt (lehnt jetzt −X/−Z).

Suite: 7 Editor-Suiten 463 Checks grün + Export 21/0 + GUI-Smoke 20 grün.
2026-10-16 17:27:19 +00:00
Hermes (godot-vr)
b7b160d3bc Terrain-Darstellung (t_7ac70014): Winding-Order geflippt — Terrain bei h=0 sofort als Rasen-Platte sichtbar.
Root-Cause für Slys Gerätesymptom (dunkle Platte + Gitter, Terrain „wacht erst nach langem Drücken auf“): terrain_mesh.gd baute alle Dreiecke von oben CCW = Backface → komplette Deckfläche wurde von oben ge-cullt; sichtbar war nur der dunkle Sky-Boden (0.1/0.1/0.12) plus Gitter-Overlay. Gehobene Flanken waren nur sichtbar, wenn sie über die unsichtbare Deckfläche ragten (Rückenansicht) — daher „wirkt der Pinsel erst nach langem Drücken“.

- terrain_mesh.gd: Dreiecks-Indices gespiegelt [i, i+1, i+res, i+1, i+res+1, i+res] (CW von oben = Frontseite, Godot-Konvention); Normalen-Gradient korrigiert (x-Index läuft gegen Welt-X: n ∝ ((hx_r-hx_l)·cell_z, 2·cell_x·cell_z, (hz_u-hz_d)·cell_x)) — Hänge waren vorher falsch beleuchtet. MeshInstance lazily erzeugt (update_heights sicher vor _ready).
- map_view_builder.gd: Gitter default AUS über sichtbarem Terrain (grid_node), ohne Terrain-Block als Boden-Referenz sichtbar; set_grid_visible()/grid_is_visible() ohne Rebuild.
- main.gd: Toggle-Knopf „⊞ Gitter“ in der Top-Bar; Nutzer-Wahl überlebt jeden Viewport-Rebuild.
- Erster Pinselkontakt: mit sichtbarem Terrain wirkt der Live-Pfad ab Frame 1 (code-seitig korrekt, war nur unsichtbar).

Tests: test_terrain 64 Checks (NEU: Winding aller Dreiecke = Frontseite von oben, Normale h=0 = (0,1,0), X/Z-Hügel-Normale nicht invertiert, erster RAISE-Druck verschiebt Vertex messbar im Mesh, Gitter-Default/Toggle/Boden-Referenz). Suite 441 Checks + Export-Suite 21 grün; --import ohne Parse-Error.
2026-10-16 17:14:54 +00:00
Hermes (godot-vr)
adb585d30e Vereinigung main+feature/builder-export (t_695d57ee): Bauen-Modus + Bibliothek-Export, EIN Vendor-Verzeichnis addons/modular_builder.
feature/builder-export (b3fcb35) in main (e2954a3) gemerged; Doppel-Vendor
aufgelöst: addons/modular_builder_en gelöscht, Export-Kern nach
addons/modular_builder portiert (Pfad-Referenzen angepasst):
- scopedvr_export.gd (Kern: slugify, normalisiere, trimesh_vertices, glb_bytes)
- scopedvr_export_ui.gd (Name-Dialog + Objekt-Pipeline + Thumbnail)
- editor_actions/UI: Export-Modus 'In ScopedVR-Bibliothek (Objekt-Pipeline)'
- tests/test_builder_export.gd (21 Checks, Pitfall ARRAY_INDEX=NULL)
- gen_thumbs: Trimesh-Objekte mit vorhandenem echten Thumbnail überspringen
  (harmlos kombiniert mit dem GLB-Echt-Mesh-Rendering aus t_7cf80244)
- project.godot: Plugin 'res://addons/modular_builder/plugin.cfg'
2026-10-16 16:49:45 +00:00
godot-vr (Hermes)
b3fcb355d6 Map-Builder-Export (t_82d2a8dd): Modular-Builder v0.2.0 vendored (addons/modular_builder_en, self-contained) + ScopedVR-Bibliothek-Export im Bauen-Modus. scopedvr_export.gd (Kern, statisch testbar): slugify (Umlaute aufgeloest, fuehrende Ziffern/Unterstriche weg, max. 48 Zeichen), normalisiere (Fusspunkt y=0, XZ zentriert, Material bleibt), trimesh_vertices (Dreiecks-Suppe, Doppel-Dreieck-Dedup + 1-mm-Quantisierung — Achtung: ARRAY_INDEX ist NULL bei nicht-indizierten Oberflaechen, direkter Cast bricht die Funktion ab), glb_bytes (GLTFDocument, Texturen eingebettet, null-Mesh → leere Bytes). scopedvr_export_ui.gd: Name-Dialog → Pipeline (mesh --budget 20000 → registry trimesh --vertices → client → editor) → Thumbnail aus dem ECHTEN Mesh (await, 128 px). editor_actions: „Struktur exportieren" im Dock. project.godot: Plugin enabled. gen_thumbs: Trimesh-Objekte mit vorhandenem Thumbnail ueberspringen. tests/test_builder_export.gd 21/0. --import ohne Parse-/Script-Error (Klassen-Kollision der Root-Duplikate beseitigt — nur modular_builder_en bleibt); Editor-Suite 354 Checks gruen. 2026-10-15 23:22:43 +00:00
Hermes
e2954a38ed M2-Karte t_97ea59c5: Modular Builder fest integriert — Bauen-Modus im App-Skelett. Addon CC0-Code unter addons/modular_builder (Demo + nicht-CC0-Texturen entfernt, ADDON_LIZENZ.txt), Plugin im Godot-Editor aktivierbar; App bekommt eigenen Bauen-Modus (build_tool.gd) auf derselben Runtime-Geometrie (modular_piece_geometry/_detail) — keine Godot-Editor-API im App-UI. 10 Bauteile (Fundament/Boden/Wand/Tür/Fenster/Giebel×2/Dach×2/Treppe), 2-m-Raster (ecke/mitte), Klicken zum Stapeln (Ray-Pick auf gebackenen Flächen), 90°-Drehen, Anheben 0,2 m. UNDO: 1 ActionHistory-Eintrag pro Bauteil (Snapshot Block gebaeude). Speichern: Bauteile als optionale Sektion gebaeude in .svmap (checksum-neutral, Legacy-Maps laden weiter); Baken deterministisch aus Teilen (Editor-Ansicht + Match-Export in Karte 2). Statuszeile clip_text (langer Text sprengte Layout → rechtes Panel weg). Tests: test_bauen.gd 87 Checks (Katalog, Meshes, Snap, Baken/Ray, Undo, Save/Laden, Legacy), Suite 421 Checks grün; GUI-Smoke --bauen-demo (Screenshots + Save/Laden-Flow). 2026-10-15 23:12:06 +00:00
Slyphax
905edc021a M2 Natur-Update t_7cf80244: Bibliothek 54 Objekte in Gruppen bäume/felsen/pflanzen/gras/pilze. library_browser: 39 deutsche Namen, alte Gruppe 'natur' thematisch aufgelöst (tree_pine->bäume, rock_large/boulder_small->felsen, bush_small->pflanzen). mesh_shapes: 'none' = flache Fußpunkt-Scheibe (Deko ohne Kollider bleibt sichtbar/wählbar). gen_thumbs: rendert jetzt ECHTE GLB-Meshes aus dem Mesh-Lager (Sibling-Repo, GLTFDocument ohne Import-System), Fallback weiter Platzhalter — alle 54 Thumbnails neu gerendert. test_library: 15->54 + Gruppen-Checks, Suite 56 Checks grün (alle 6 Editor-Suiten OK). 2026-10-15 23:08:24 +00:00
Hermes godot-vr
87e7a96fd5 Fix GUI-Start-Fehler (Karte t_d3b93793): Terrain-Preset-Crash + command_pressed-Spam.
1. main.gd _build_right_panel: terrain_size_btn_medium.button_pressed=true
   feuerte toggled SOFORT synchron — der Handler griff auf
   terrain_radius_slider zu, der erst spaeter erzeugt wird → SCRIPT ERROR
   in _on_terrain_size_preset bei jedem GUI-Start. Fix:
   set_pressed_no_signal(true) (Slider startet unten passend mit 12 m) +
   Null-Check als Schutznetz im Handler.

2. main.gd _unhandled_key_input: event.command_pressed existiert an
   InputEventKey nicht (Godot-3-API-Relikt) → SCRIPT ERROR bei JEDER Taste,
   Shortcuts tot. Fix: is_command_or_control_pressed() (Ctrl auf
   Linux/Windows, Cmd auf macOS).

3. Neuer Regressionstest tests/test_gui_smoke.gd: instanziert die ECHTE
   Main-UI headless (deckt Runtime-UI-Pfade ab, die die logik-only-Suite
   nie sah): Panel-Bau ueberlebt _ready, Presets initial/klickbar
   (Slider+Label), Strg+Z/Strg+Y wirken end-to-end, leere History =
   freundliche Meldung. Suite: 334 + 20 Checks gruen.
2026-10-15 21:14:05 +00:00
Slyphax
5dd37e32d7 M2-Karte t_d8be9ed7: Struktur-Objekte im Editor. Bibliothek +15 Objekte: bruecke_holz (Bruecke Holz), hoehleneingang, felsbogen — deutsche Namen, Gruppe 'bauwerke', Thumbnails gerendert. mesh_shapes: SHAPE_COMPOUND-Platzhalter — EIN Mesh aus allen Verbund-Teilen, in Fusspunkt-Koordinaten gebacken (pos-Versatz + rot_deg), so sind Rampe/Durchgaenge im Editor sichtbar wie die echte Kollision; neu center_height() fuer das Drehzentrum der Auswahl (map_view_builder nutzt es). test_library: 15 Objekte, Undo/Redo-Zaehler dynamisiert -> Suite 334 Checks gruen. 2026-10-15 19:21:25 +00:00
Hermes godot-vr
cddebb8c26 M2-Karte t_9117ee32: Objekt-Pipeline 'Fass' — Registry-Sync + Thumbnail; Tests: 12 Objekte, Namens-Fallback ohne Quellcode-Touch 2026-10-15 16:07:25 +00:00
Hermes godot-vr
23ede45efd M2-Karte t_603ec1a8: Terrain-Texturen (Gras/Sand/Fels) + Pinselgroessen. svmap v2: optionale Material-Schicht materials_r8_b64 (uint8/res², 0=Gras/1=Sand/2=Fels), V15-Validierung (Laenge+ID-Range), Legacy-v1 laedt mit Default Gras, gespeichert wird immer v2. TerrainField: Mode.PAINT + apply_material_brush (Kreis, crisp, Hoehe unangetastet). terrain_tool: 4. Pinsel-Modus Malen (Palette Gras/Sand/Fels, Rechtsklick malt Gras zurueck), Ring in Materialfarbe; ganzer Strich = EIN ActionHistory-Eintrag (terrain-Block-Snapshot deckt Hoehe+Material ab). main.gd: Palette-Panel (nur im Malen-Modus), drei Pinselgroessen-Presets Klein(4m)/Mittel(12m)/Gross(32m) + freier Slider, deutsche Labels. terrain_mesh: Vertex-Farben aus Material-Schicht x Hoehe-Shading (KEIN Custom-Shader, Quest-3-Perf-Vertrag — Entscheidung gegen Splattmap in DECISIONS.md). Tests: test_terrain +3 Gruppen (Material-Pinsel, Mal-Stroke-Undo byte-identisch, v2/V15+Legacy) -> 53 Checks; Suite 231 Checks gruen. 2026-10-15 15:42:05 +00:00
Hermes godot-vr
423b223454 Tree-Auswahl: content_tree.get_root() statt .root — Godot 4.7 hat kein root-Property (Karte t_f6f29d86)
Desktop-Smoke zeigte SCRIPT ERROR "Invalid access to property or key
root on a base object of type Tree" bei jeder Auswahl-Markierung im
Baum — die Baum-Mitmarkierung lief nie. Empirisch belegt: Tree hat in
Godot 4.7 nur get_root(), kein root-Property.
2026-10-15 15:37:28 +00:00
hermes-godot-vr
0eae5f56ad M2-Karte t_b84af40f: '▶ Karte testen' (Editor-Seite). Großer deutsch beschrifteter Button oben + Menü-Eintrag: exportiert die AKTUELLE Map (auch ungespeichert) nach user://testrun/<name>.svmap über dieselbe SvmapIO-Validierung wie das Speichern, sucht den scopedvr-client (Klon neben dem Editor ODER client_dir in user://scopedvr_editor.cfg, dokumentiert im README-Abschnitt 'Karte testen') und startet ihn als EIGENEN Prozess (godot --path <client> -- --testmap <map> --mock-master) — Editor bleibt offen, Spiel beendet sich selbst sauber per ESC. Ungültige Map → Klartext-Block statt Start. TestRun-Klasse komplett static + headless testbar. Tests: tests/test_testrun.gd (14 Checks: Temp-Export-Roundtrip, ungültige Map blockiert, Client-Suche inkl. Konfig-Vorrang, Godot-Findung, exakte Argumentliste); run_tests.sh + parse_check.sh um test_testrun/test_run.gd erweitert — volle Suite 220 Checks grün. GODOT-Pfad-Default in tests/run_testmap_e2e.sh des Clients jetzt auto-erkennend (Sly-PC-Pfad → ~/bin/godot4 → PATH). 2026-10-15 15:36:24 +00:00
Hermes godot-vr
595d1217d9 test_ux: Undo-Ketten-Zählung korrigiert — 4 Commits brauchen 4 Undos (Karte t_f6f29d86)
Der Off-by-one im Test erwartete nach 2 Undos den Zustand nach move1,
obwohl 3 Züge davorliegen (Zug, Kanten-Clamp, zurück ins Feld). Der
Code war korrekt (EINE Aktion = EIN Undo-Schritt), der Test zählte
falsch. Jetzt wird jeder Schritt einzeln verifiziert inkl. geclamptem
Kanten-Zustand. Suite: 253 Checks grün.
2026-10-15 15:34:46 +00:00
Hermes godot-vr
849129d67f Auswahl-System: Mehrfachauswahl (Umschalt+Klick, Rubberband) + Gruppieren + Gruppen-Verschieben (Karte t_f6f29d86)
- Mehrfachauswahl: Umschalt+Klick addiert/entfernt, Rahmen-Aufziehen über
  den Viewport wählt alles im Rahmen (SelectionMath, headless getestet);
  alle Markierten leuchten orange, Panel listet sie namentlich.
- Gruppieren/Lösen: Buttons im Auswahl-Panel + Strg+G / Strg+Umschalt+G;
  Gruppe zieht und dreht GEMEINSAM um die eigene Mitte, Anordnung bleibt.
- ENTSCHIEDUNG Persistenz: optionales Objekt-Feld "group" (int >= 0)
  direkt im svmap-Format — Loader-Kompatibilität nachgewiesen
  (Server-Loader prüft nur Pflichtfelder; Prüfsumme läuft über das ganze
  Dict), alte Maps bleiben ladbar, format_version bleibt 1, kein
  Server-Loader-Change nötig. Gruppen überleben Save/Load UND Undo/Redo.
- Gruppen-Verschieben: Terrain-Clamp pro Objekt, EINE Aktion = EIN
  ActionHistory-Schritt (auch Drehen/Mehrfach-Löschen).
- Tests: test_ux.gd auf 52 Checks erweitert (Gruppen, Undo-Ein-Schritt,
  svmap-Roundtrip + Alte-Map-Kompatibilität, Rubberband-Projektion);
  run_tests.sh komplett grün (206 Checks).
- Desktop-Smoke: --groups-demo [ordner] [--groups-demo-quit]
2026-10-15 15:34:46 +00:00
Hermes (godot-vr)
eca11bbf83 Objekt-Ops: Duplizieren (Strg+D) + Anheben/Absenken (Bild hoch/runter) + Spiegeln (X/Z)
Karte t_7567a06f. Hoehe wohnt direkt in object.pos.y — KEIN svmap-Format-
Feld noetig (V9 erlaubt freie y-Werte in bounds; Server-Loader geprueft).
Absenken stoppt am Terrain (nichts begraben), Anheben deckelt auf
bounds.max. Spiegeln am Objekt selbst (Position bleibt): X → −rot,
Z → 180°−rot selbstinvers. Alles über ActionHistory (Merge-Tags für
Lift-Serien), deutsche UI + Klartext-Status, dragt behält Terrain-Abstand.
test_ux.gd: 90 Checks (42 neu), Suite 5/5 grün, xvfb-Smoke UI-Bau ok.
2026-10-15 15:32:24 +00:00
Hermes godot-vr
ce1dc4130d run_tests.sh: test_spawns in Regression-Liste (nach Merge t_c4162c5d) 2026-10-15 13:37:36 +00:00
slyphax
684b4cfd08 M2-Karte t_c4162c5d: Spawn-Zonen malen + Live-Validierung + Map-Fertig-Check (Export-Signierung unverändert über SvmapIO) 2026-10-15 12:45:54 +00:00
Hermes (godot-vr)
3a5c9d44bb Objekt-Bibliothek: Gruppen-Browser mit Thumbnails, Snap-to-Ground, Raster-Snap, Tests
- Bibliothek-Browser: 11 etablierte Objekte in deutschen Klapp-Gruppen
  (Kisten & Barrieren / Bauwerke / Natur, neue IDs wandern nach Sonstiges)
- 128-px-Thumbnails einmalig gerendert (tools/gen_thumbs.gd, xvfb) und
  committed; fehlendes PNG = sauberer Text-Fallback (LibraryBrowser)
- Snap to Ground: ein Klick setzt ALLE schwebenden Objekte auf Terrain-Höhe,
  EIN Undo-Eintrag (ObjectOps.snap_to_ground)
- Raster-Snap (1 m): Toggle — rastet Platzieren und Ziehen auf ganze Meter
- Deutsche Namen nach LibraryBrowser verschoben (headless testbar)
- tests/test_library.gd: 47 Checks (Bibliothek, Gruppen, Thumbnails, Snap,
  CRUD+Undo über alle Objekte, svmap-Roundtrip byte-identisch, Poly-Budget)
- Desktop-Smoke: --library-demo erzeugt Screenshots (lokal via xvfb verifiziert)
- UI-Layout: linke Spalte min 909→797 px, Statusleiste wieder vollständig sichtbar
2026-10-15 12:18:11 +00:00
681 changed files with 57315 additions and 3341 deletions

8
.gitignore vendored
View file

@ -2,5 +2,13 @@
.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 Normal file
View file

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

16
ADDON_LIZENZ.txt Normal file
View file

@ -0,0 +1,16 @@
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.

277
README.md
View file

@ -48,17 +48,18 @@ 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 (`src/editor/action_history.gd`):** zentrale ActionHistory über - **Undo/Redo (`core/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 (`src/editor/interaction.gd`):** - **Objekt-Platzierung mit Ghost-Preview (`core/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, Rechtsklick/ESC bricht ab, (Höhe kommt aus dem Terrain), Klick setzt, ESC bricht ab (Rechte
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
@ -89,6 +90,270 @@ trotz grünem Test-Lauf (der ja selbst mit importiertem Cache lief).
Registry aus dem Match-Server-Repo — neue Objekte DORT registrieren und hier Registry aus dem Match-Server-Repo — neue Objekte DORT registrieren und hier
synchron ziehen (IDs sind der Vertrag, Entscheidung 20). synchron ziehen (IDs sind der Vertrag, Entscheidung 20).
**Noch offen (folgende Karten der MVP-Kette):** Terrain-Werkzeuge ## Status (M2, Karte t_3a998ae4 — Objekt-Bibliothek FERTIG)
(heben/senken/glätten — nutzen ActionHistory mit `["terrain"]`),
Spawn-Zonen-Editor, Performance-Check-UI, Export-Workflow. Auf dem Terrain-Werkzeug (t_9aba04f1) aufgebaut, nach dem Chef-Grundsatz:
- **Bibliothek-Browser mit Gruppen (`core/editor/library_browser.gd`):**
*(Stand 25.10.2026, Military-Pack-Umstellung)* Die Bibliothek ist das
komplette Military-Desert-Pack (418 Modelle + 5 Kombis = 423 Objekte aus
`library/objects.json`), sortiert in 19 deutsche Kategorien aus dem
Manifest-Feld `kategorie` (Panzer, Radfahrzeuge, Flugzeuge & Hubschrauber,
Geschütze & Stellungen, Fahrzeugteile, Gebäude & Hangars, Zelte, Türme &
Masten, Mauern & Wände, Sperren & Zäune, Kisten/Fässer/Container, Technik &
Funk, Ausrüstung, Training, Bäume, Pflanzen/Kakteen/Gras, Felsen & Steine,
Boden/Straßen/Dünen, Boden-Decals). Fahrzeuge sind **fertig zusammengebaut**
(Panzer mit Turm und Kanone, Hummer mit Rädern, Hubschrauber mit Rotoren);
die Einzelteile stehen zusätzlich unter „Fahrzeugteile“. Unbekannte
Kategorien (z. B. spätere Bauteil-Module) bekommen automatisch eine eigene
Gruppe. Jeder Button zeigt ein 128-px-Thumbnail (`core/thumbs/*.png` —
erzeugt von `tools/sync_objects.sh`/`tools/gen_thumbs.gd` und committed;
fehlt ein PNG, fällt der Button aufs Text-Layout zurück). Deutsche Namen
kommen aus `name_de` im Manifest (ohne UI → headless testbar). Kollision:
Gebäude/Zelte/Türme exakter Trimesh (betretbar), Fahrzeuge Verbund aus
Boxen, Bäume nur Stamm, Dekor ohne Kollision — erzeugt vom Match-Server-Repo
(`tools/pack_registry.py`).
- **Snap to Ground (`core/editor/object_ops.gd`):** Ein Klick auf
„⚡ Alles auf den Boden setzen" holt ALLE schwebenden Objekte auf die
Terrain-Höhe (z. B. nach dem Terrain-Formen) — EIN Undo-Eintrag für die
ganze Aktion. Objekte, die schon auf dem Boden stehen, bleiben unangetastet.
- **Raster-Snap (1 m):** CheckButton in den Objekt-Werkzeugen — beim
Platzieren UND Ziehen rasten X/Z auf ganze Meter (Höhe folgt weiter dem
Terrain). AUS = freies Platzieren wie bisher.
- **svmap-Roundtrip:** Objekte (Bibliotheks-ID + Pos/Rot/Scale) überleben
Speichern/Laden identisch, save→load→save bleibt byte-identisch
(`tests/test_library.gd`).
- **Tests:** `tests/test_library.gd` (Bibliothek laden, Gruppen komplett/
überschneidungsfrei, Thumbnails 128×128, Raster-Snap, Snap-to-Ground + je
Undo für setzen/drehen/verschieben/löschen über ALLE 11 Objekte,
svmap-Roundtrip mit Objekten + Poly-Budget aus dem Katalog).
- **Desktop-Smoke:** `godot … -- --library-demo [ordner] [--library-demo-quit]`
fährt Bibliothek/Platzierung/Schweben/Snap programmatisch durch und
speichert Screenshots.
**Noch offen (folgende Karten der MVP-Kette):** 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`).

85
README_INSTALL.md Normal file
View file

@ -0,0 +1,85 @@
# 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

@ -0,0 +1,19 @@
# 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

@ -0,0 +1,125 @@
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

@ -0,0 +1,37 @@
# 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

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 315 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 195 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 315 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 259 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 265 B

View file

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

@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 404 B

View file

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

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 298 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 291 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 286 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 285 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 262 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 336 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 276 B

View file

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

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 293 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 460 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 351 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 275 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 257 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 250 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 257 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 306 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 236 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 329 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 293 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 355 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 267 B

View file

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

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 222 B

View file

@ -0,0 +1,43 @@
[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

@ -0,0 +1,614 @@
@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

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

View file

@ -0,0 +1,430 @@
@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

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

View file

@ -0,0 +1,544 @@
@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

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

View file

@ -0,0 +1,727 @@
@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

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

View file

@ -0,0 +1,637 @@
@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

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

View file

@ -0,0 +1,166 @@
@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

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

View file

@ -0,0 +1,55 @@
@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

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

View file

@ -0,0 +1,61 @@
@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

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

View file

@ -0,0 +1,34 @@
@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

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

View file

@ -0,0 +1,72 @@
@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

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

View file

@ -0,0 +1,32 @@
@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

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

View file

@ -0,0 +1,7 @@
[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

@ -0,0 +1,162 @@
@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

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

View file

@ -0,0 +1,250 @@
@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

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

View file

Binary file not shown.

1
core Submodule

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

76
docs/bauen.md Normal file
View file

@ -0,0 +1,76 @@
# 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.

51
docs/server-sektion.md Normal file
View file

@ -0,0 +1,51 @@
# 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).

211
docs/tasten.md Normal file
View file

@ -0,0 +1,211 @@
# 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.

29
export_presets.cfg Normal file
View file

@ -0,0 +1,29 @@
[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

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