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
37
README.md
|
|
@ -89,6 +89,37 @@ 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 (`src/editor/library_browser.gd`):** Die
|
||||||
|
11 etablierten Objekte aus `library/objects.json`, sortiert in deutsche
|
||||||
|
Klapp-Gruppen (Kisten & Barrieren / Bauwerke / Natur + automatisch
|
||||||
|
„Sonstiges" für neue, noch ungruppierte IDs). Jeder Button zeigt ein
|
||||||
|
128-px-Thumbnail (`library/thumbs/*.png` — einmalig mit
|
||||||
|
`xvfb-run -a godot4 --rendering-driver opengl3 --path . --script
|
||||||
|
res://tools/gen_thumbs.gd` gerendert und committed; fehlt ein PNG, fällt
|
||||||
|
der Button sauber aufs Text-Layout zurück). Deutsche Namen wohnen jetzt in
|
||||||
|
`LibraryBrowser` (ohne UI → headless testbar).
|
||||||
|
- **Snap to Ground (`src/editor/object_ops.gd`):** Ein Klick auf
|
||||||
|
„⚡ Alles auf den Boden setzen" holt ALLE schwebenden Objekte auf die
|
||||||
|
Terrain-Höhe (z. B. nach dem Terrain-Formen) — EIN Undo-Eintrag für die
|
||||||
|
ganze Aktion. Objekte, die schon auf dem Boden stehen, bleiben unangetastet.
|
||||||
|
- **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):** Spawn-Zonen-Editor
|
||||||
|
(t_c4162c5d — Spawn-Zonen malen, Live-Validierung, Export-Signierung),
|
||||||
|
Performance-Check-UI, Export-Workflow.
|
||||||
|
|
|
||||||
BIN
library/thumbs/barrier_sandbag.png
Normal file
|
After Width: | Height: | Size: 848 B |
40
library/thumbs/barrier_sandbag.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://bqkcpy3du3fly"
|
||||||
|
path="res://.godot/imported/barrier_sandbag.png-eed61205bec18ca97dd54d2a61a4e153.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://library/thumbs/barrier_sandbag.png"
|
||||||
|
dest_files=["res://.godot/imported/barrier_sandbag.png-eed61205bec18ca97dd54d2a61a4e153.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
|
||||||
BIN
library/thumbs/boulder_small.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
40
library/thumbs/boulder_small.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://der1lfrcvoeb8"
|
||||||
|
path="res://.godot/imported/boulder_small.png-c4fad8ba15711e8ff7d084f1741e1559.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://library/thumbs/boulder_small.png"
|
||||||
|
dest_files=["res://.godot/imported/boulder_small.png-c4fad8ba15711e8ff7d084f1741e1559.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
|
||||||
BIN
library/thumbs/bunker_concrete.png
Normal file
|
After Width: | Height: | Size: 979 B |
40
library/thumbs/bunker_concrete.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://b3ec84bcadfrp"
|
||||||
|
path="res://.godot/imported/bunker_concrete.png-3b8f1b4a66c169614840fc15438260a4.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://library/thumbs/bunker_concrete.png"
|
||||||
|
dest_files=["res://.godot/imported/bunker_concrete.png-3b8f1b4a66c169614840fc15438260a4.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
|
||||||
BIN
library/thumbs/bush_small.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
40
library/thumbs/bush_small.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://rc4qmlu30oe0"
|
||||||
|
path="res://.godot/imported/bush_small.png-8dd2b0f2820d02d8f683642ea139221a.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://library/thumbs/bush_small.png"
|
||||||
|
dest_files=["res://.godot/imported/bush_small.png-8dd2b0f2820d02d8f683642ea139221a.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
|
||||||
BIN
library/thumbs/crate_metal.png
Normal file
|
After Width: | Height: | Size: 920 B |
40
library/thumbs/crate_metal.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://crq5m52c3kfp6"
|
||||||
|
path="res://.godot/imported/crate_metal.png-057fe3985c46074b7a8e051aeac6d28a.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://library/thumbs/crate_metal.png"
|
||||||
|
dest_files=["res://.godot/imported/crate_metal.png-057fe3985c46074b7a8e051aeac6d28a.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
|
||||||
BIN
library/thumbs/crate_wood.png
Normal file
|
After Width: | Height: | Size: 911 B |
40
library/thumbs/crate_wood.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://bu3hndyrkw2xu"
|
||||||
|
path="res://.godot/imported/crate_wood.png-fb22d40e14834f0dfc565b88b4ca0928.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://library/thumbs/crate_wood.png"
|
||||||
|
dest_files=["res://.godot/imported/crate_wood.png-fb22d40e14834f0dfc565b88b4ca0928.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
|
||||||
BIN
library/thumbs/fence_metal.png
Normal file
|
After Width: | Height: | Size: 737 B |
40
library/thumbs/fence_metal.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://kul1hamegxs0"
|
||||||
|
path="res://.godot/imported/fence_metal.png-eb6d7564dcec24bf8e9b4d5ec9d52a5a.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://library/thumbs/fence_metal.png"
|
||||||
|
dest_files=["res://.godot/imported/fence_metal.png-eb6d7564dcec24bf8e9b4d5ec9d52a5a.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
|
||||||
BIN
library/thumbs/rock_large.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
40
library/thumbs/rock_large.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dlntu6vdbvxoh"
|
||||||
|
path="res://.godot/imported/rock_large.png-8a86d273f49f5cb46d939adc5de6b13b.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://library/thumbs/rock_large.png"
|
||||||
|
dest_files=["res://.godot/imported/rock_large.png-8a86d273f49f5cb46d939adc5de6b13b.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
|
||||||
BIN
library/thumbs/tree_pine.png
Normal file
|
After Width: | Height: | Size: 606 B |
40
library/thumbs/tree_pine.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://d3x487vku5uc7"
|
||||||
|
path="res://.godot/imported/tree_pine.png-a6c8426a9685830a95298331fa9d351e.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://library/thumbs/tree_pine.png"
|
||||||
|
dest_files=["res://.godot/imported/tree_pine.png-a6c8426a9685830a95298331fa9d351e.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
|
||||||
BIN
library/thumbs/wall_concrete.png
Normal file
|
After Width: | Height: | Size: 775 B |
40
library/thumbs/wall_concrete.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dhl43w3l7s05c"
|
||||||
|
path="res://.godot/imported/wall_concrete.png-aba74f819605dd2ba26846285fc5e701.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://library/thumbs/wall_concrete.png"
|
||||||
|
dest_files=["res://.godot/imported/wall_concrete.png-aba74f819605dd2ba26846285fc5e701.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
|
||||||
BIN
library/thumbs/watchtower_wood.png
Normal file
|
After Width: | Height: | Size: 773 B |
40
library/thumbs/watchtower_wood.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cr8f85kohbqx5"
|
||||||
|
path="res://.godot/imported/watchtower_wood.png-63e27757c535c90df21765e4d9ed21d5.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://library/thumbs/watchtower_wood.png"
|
||||||
|
dest_files=["res://.godot/imported/watchtower_wood.png-63e27757c535c90df21765e4d9ed21d5.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
|
||||||
|
|
@ -12,6 +12,7 @@ extends Node3D
|
||||||
|
|
||||||
const ROT_STEP_DEG := 15.0
|
const ROT_STEP_DEG := 15.0
|
||||||
const DRAG_THRESHOLD_PX := 4.0
|
const DRAG_THRESHOLD_PX := 4.0
|
||||||
|
const GRID_M := 1.0 # Raster-Snap-Weite (Objekt-Werkzeuge links, Karte t_3a998ae4)
|
||||||
|
|
||||||
var main # Hauptfenster (script-erweitert, Variant — Referenzen per setup())
|
var main # Hauptfenster (script-erweitert, Variant — Referenzen per setup())
|
||||||
var camera: Camera3D
|
var camera: Camera3D
|
||||||
|
|
@ -148,6 +149,16 @@ func _ensure_ghost_visible() -> void:
|
||||||
_ghost.visible = true
|
_ghost.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
## Raster-Snap (wenn in main angeschaltet): X/Z auf ganze Meter rasten —
|
||||||
|
## Objekte passen dann nebeneinander wie Bauklötze. Höhe bleibt Terrain.
|
||||||
|
func _apply_grid(pos: Vector3) -> Vector3:
|
||||||
|
if not bool(main.get("grid_snap")):
|
||||||
|
return pos
|
||||||
|
pos.x = ObjectOps.snap_coord(pos.x, true, GRID_M)
|
||||||
|
pos.z = ObjectOps.snap_coord(pos.z, true, GRID_M)
|
||||||
|
return pos
|
||||||
|
|
||||||
|
|
||||||
func _ghost_update(viewport_pos: Vector2) -> void:
|
func _ghost_update(viewport_pos: Vector2) -> void:
|
||||||
var p: Variant = _ground_point(viewport_pos)
|
var p: Variant = _ground_point(viewport_pos)
|
||||||
if p == null:
|
if p == null:
|
||||||
|
|
@ -155,7 +166,7 @@ func _ghost_update(viewport_pos: Vector2) -> void:
|
||||||
_ghost.visible = false
|
_ghost.visible = false
|
||||||
return
|
return
|
||||||
_ghost_active = true
|
_ghost_active = true
|
||||||
var pos := p as Vector3
|
var pos := _apply_grid(p as Vector3)
|
||||||
var half := _terrain_half()
|
var half := _terrain_half()
|
||||||
pos.x = clampf(pos.x, -half.x, half.x)
|
pos.x = clampf(pos.x, -half.x, half.x)
|
||||||
pos.z = clampf(pos.z, -half.y, half.y)
|
pos.z = clampf(pos.z, -half.y, half.y)
|
||||||
|
|
@ -169,7 +180,7 @@ func _place_object(viewport_pos: Vector2) -> void:
|
||||||
if p == null:
|
if p == null:
|
||||||
main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
|
main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
|
||||||
return
|
return
|
||||||
var pos := p as Vector3
|
var pos: Vector3 = _apply_grid(p as Vector3)
|
||||||
var half := _terrain_half()
|
var half := _terrain_half()
|
||||||
if absf(pos.x) > half.x or absf(pos.z) > half.y:
|
if absf(pos.x) > half.x or absf(pos.z) > half.y:
|
||||||
main.call("_set_status", "Das wäre außerhalb der Map gelandet — klicke innerhalb der grünen Fläche.", true)
|
main.call("_set_status", "Das wäre außerhalb der Map gelandet — klicke innerhalb der grünen Fläche.", true)
|
||||||
|
|
@ -274,7 +285,7 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||||
var p: Variant = _ground_point(mm.position)
|
var p: Variant = _ground_point(mm.position)
|
||||||
if p == null:
|
if p == null:
|
||||||
return
|
return
|
||||||
var new_pos: Vector3 = (p as Vector3) + _grab_offset
|
var new_pos: Vector3 = _apply_grid((p as Vector3) + _grab_offset)
|
||||||
var half := _terrain_half()
|
var half := _terrain_half()
|
||||||
new_pos.x = clampf(new_pos.x, -half.x, half.x)
|
new_pos.x = clampf(new_pos.x, -half.x, half.x)
|
||||||
new_pos.z = clampf(new_pos.z, -half.y, half.y)
|
new_pos.z = clampf(new_pos.z, -half.y, half.y)
|
||||||
|
|
|
||||||
94
src/editor/library_browser.gd
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
extends RefCounted
|
||||||
|
class_name LibraryBrowser
|
||||||
|
## Objekt-Bibliothek des Editors (Karte t_3a998ae4): deutscher Name, Gruppen
|
||||||
|
## für den Bibliothek-Browser und Thumbnail-Laden (128-px-PNGs, einmalig per
|
||||||
|
## Headless-Render erzeugt — tools/gen_thumbs.gd).
|
||||||
|
##
|
||||||
|
## Bewusst OHNE UI: reine Daten/Funktionen, damit die Tests (tests/test_library.gd)
|
||||||
|
## headless alles prüfen können. Die IDs sind der Vertrag (Entscheidung 20) —
|
||||||
|
## Gruppen/Namen leben HIER im Editor (die objects.json bleibt 1:1 die Kopie
|
||||||
|
## der Server-Registry, damit ein Sync sie nicht verwüstet).
|
||||||
|
|
||||||
|
## Deutsche Anzeige-Namen für Bibliotheks-IDs (IDs sind der Vertrag, Entscheidung 20)
|
||||||
|
const NAMES := {
|
||||||
|
"crate_wood": "Holzkiste",
|
||||||
|
"crate_metal": "Metallkiste",
|
||||||
|
"wall_concrete": "Betonmauer",
|
||||||
|
"barrier_sandbag": "Sandsack-Sperre",
|
||||||
|
"bunker_concrete": "Bunker",
|
||||||
|
"watchtower_wood": "Wachturm",
|
||||||
|
"fence_metal": "Zaun",
|
||||||
|
"rock_large": "Großer Felsen",
|
||||||
|
"boulder_small": "Kleiner Felsen",
|
||||||
|
"tree_pine": "Nadelbaum",
|
||||||
|
"bush_small": "Strauch",
|
||||||
|
}
|
||||||
|
|
||||||
|
## Gruppen für den Bibliothek-Browser (Anzeige-Reihenfolge = Reihenfolge hier).
|
||||||
|
const GROUP_ORDER := ["kisten", "bauwerke", "natur", "sonstiges"]
|
||||||
|
const GROUP_LABELS := {
|
||||||
|
"kisten": "Kisten & Barrieren",
|
||||||
|
"bauwerke": "Bauwerke",
|
||||||
|
"natur": "Natur",
|
||||||
|
"sonstiges": "Sonstiges",
|
||||||
|
}
|
||||||
|
const GROUP_IDS := {
|
||||||
|
"kisten": ["crate_wood", "crate_metal", "barrier_sandbag", "wall_concrete"],
|
||||||
|
"bauwerke": ["bunker_concrete", "watchtower_wood", "fence_metal"],
|
||||||
|
"natur": ["tree_pine", "bush_small", "rock_large", "boulder_small"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func object_label(oid: String) -> String:
|
||||||
|
return NAMES.get(oid, oid.capitalize())
|
||||||
|
|
||||||
|
|
||||||
|
## Gruppe einer ID (Objekte, die keine festen Gruppe haben → "sonstiges").
|
||||||
|
static func group_of(oid: String) -> String:
|
||||||
|
for key in GROUP_IDS:
|
||||||
|
if (GROUP_IDS[key] as Array).has(oid):
|
||||||
|
return str(key)
|
||||||
|
return "sonstiges"
|
||||||
|
|
||||||
|
|
||||||
|
## Gruppen für den Browser, geprüft gegen das Manifest: nur IDs, die es in
|
||||||
|
## der objects.json WIRKLICH gibt, landen in der Liste (und unbekannte,
|
||||||
|
## noch nicht gruppierte IDs wandern nach "Sonstiges" — neue Bibliotheks-
|
||||||
|
## Einträge brechen die UI also nicht).
|
||||||
|
static func groups_in_order(manifest: Dictionary) -> Array:
|
||||||
|
var lib: Dictionary = manifest.get("objects", {})
|
||||||
|
var by_group := {}
|
||||||
|
for oid in lib.keys():
|
||||||
|
var key := group_of(str(oid))
|
||||||
|
if not by_group.has(key):
|
||||||
|
by_group[key] = []
|
||||||
|
(by_group[key] as Array).append(str(oid))
|
||||||
|
var out: Array = []
|
||||||
|
for key in GROUP_ORDER:
|
||||||
|
var ids: Array = (by_group.get(key, []) as Array)
|
||||||
|
ids.sort()
|
||||||
|
if ids.is_empty() and key != "sonstiges":
|
||||||
|
continue
|
||||||
|
out.append({"key": key, "label": GROUP_LABELS[key], "ids": ids})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## Thumbnail-Pfad (128-px-PNG, tools/gen_thumbs.gd erzeugt sie einmalig).
|
||||||
|
static func thumb_path(oid: String) -> String:
|
||||||
|
return "res://library/thumbs/%s.png" % oid
|
||||||
|
|
||||||
|
|
||||||
|
## Thumbnail als Textur laden — direkt über Image (kein Import-System nötig,
|
||||||
|
## geht damit auch frisch nach git pull ohne Import), auf Anzeige-Größe
|
||||||
|
## runtergerechnet. null = noch kein Thumbnail → Button fällt aufs Text-Layout
|
||||||
|
## zurück (Fehler sieht man nicht, man sieht einfach nur kein Bild).
|
||||||
|
static func load_thumb(oid: String, size_px := 40) -> Texture2D:
|
||||||
|
var path := thumb_path(oid)
|
||||||
|
if not FileAccess.file_exists(path):
|
||||||
|
return null
|
||||||
|
var img := Image.load_from_file(path)
|
||||||
|
if img == null:
|
||||||
|
return null
|
||||||
|
if img.get_width() != size_px or img.get_height() != size_px:
|
||||||
|
img.resize(size_px, size_px, Image.INTERPOLATE_LANCZOS)
|
||||||
|
return ImageTexture.create_from_image(img)
|
||||||
1
src/editor/library_browser.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://o3jwxk87gtw
|
||||||
34
src/editor/object_ops.gd
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
extends RefCounted
|
||||||
|
class_name ObjectOps
|
||||||
|
## Objekt-Operationen des Editors (Karte t_3a998ae4) — bewusst pure Funktionen
|
||||||
|
## (kein UI, kein Knoten): Snap-to-Ground und Raster-Snap. main.gd/interaction.gd
|
||||||
|
## rufen auf, tests/test_library.gd prüft headless.
|
||||||
|
|
||||||
|
const GRID_M := 1.0 # Raster-Snap-Weite (1 m — handhabbar für Objekte)
|
||||||
|
const FLOAT_EPSILON := 0.01 # ab dieser Abweichung gilt ein Objekt als "schwebend"
|
||||||
|
|
||||||
|
|
||||||
|
## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen
|
||||||
|
## z. B. nach dem Terrain-Formen in der Luft — ein Klick holt sie runter.
|
||||||
|
## Rückgabe: Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden).
|
||||||
|
static func snap_to_ground(doc: Dictionary) -> int:
|
||||||
|
var changed := 0
|
||||||
|
var objects: Array = doc.get("objects", [])
|
||||||
|
for obj in objects:
|
||||||
|
if not (obj is Dictionary):
|
||||||
|
continue
|
||||||
|
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
||||||
|
if (pos as Array).size() != 3:
|
||||||
|
continue
|
||||||
|
var h := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2]))
|
||||||
|
if absf(float(pos[1]) - h) > FLOAT_EPSILON:
|
||||||
|
obj["pos"] = [float(pos[0]), h, float(pos[2])]
|
||||||
|
changed += 1
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
## Eine Koordinate aufs Raster legen (Raster-Snap). grid_m <= 0 oder aus = no-op.
|
||||||
|
static func snap_coord(value: float, enabled: bool, grid_m := GRID_M) -> float:
|
||||||
|
if not enabled or grid_m <= 0.0:
|
||||||
|
return value
|
||||||
|
return roundf(value / grid_m) * grid_m
|
||||||
1
src/editor/object_ops.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dnocqoteya1h3
|
||||||
161
src/ui/main.gd
|
|
@ -17,25 +17,11 @@ extends Control
|
||||||
|
|
||||||
const APP_TITLE := "ScopedVR Map-Editor"
|
const APP_TITLE := "ScopedVR Map-Editor"
|
||||||
|
|
||||||
## Deutsche Anzeige-Namen für Bibliotheks-IDs (IDs sind der Vertrag, Entscheidung 20)
|
|
||||||
const GERMAN_NAMES := {
|
|
||||||
"crate_wood": "Holzkiste",
|
|
||||||
"crate_metal": "Metallkiste",
|
|
||||||
"wall_concrete": "Betonmauer",
|
|
||||||
"barrier_sandbag": "Sandsack-Sperre",
|
|
||||||
"bunker_concrete": "Bunker",
|
|
||||||
"watchtower_wood": "Wachturm",
|
|
||||||
"fence_metal": "Zaun",
|
|
||||||
"rock_large": "Großer Felsen",
|
|
||||||
"boulder_small": "Kleiner Felsen",
|
|
||||||
"tree_pine": "Nadelbaum",
|
|
||||||
"bush_small": "Strauch",
|
|
||||||
}
|
|
||||||
|
|
||||||
var manifest: Dictionary = {}
|
var manifest: Dictionary = {}
|
||||||
var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format)
|
var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format)
|
||||||
var current_path := "" # leer = noch nie gespeichert
|
var current_path := "" # leer = noch nie gespeichert
|
||||||
var dirty := false
|
var dirty := false
|
||||||
|
var grid_snap := false # Raster-Snap (1 m) an/aus — Objekt-Werkzeuge links
|
||||||
|
|
||||||
var history := ActionHistory.new()
|
var history := ActionHistory.new()
|
||||||
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
|
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
|
||||||
|
|
@ -242,15 +228,15 @@ func _build_left_panel() -> PanelContainer:
|
||||||
var panel := PanelContainer.new()
|
var panel := PanelContainer.new()
|
||||||
panel.custom_minimum_size = Vector2(280, 0)
|
panel.custom_minimum_size = Vector2(280, 0)
|
||||||
var vbox := VBoxContainer.new()
|
var vbox := VBoxContainer.new()
|
||||||
vbox.add_theme_constant_override("separation", 8)
|
vbox.add_theme_constant_override("separation", 6)
|
||||||
panel.add_child(vbox)
|
panel.add_child(vbox)
|
||||||
|
|
||||||
vbox.add_child(_header_label("Objekt-Bibliothek"))
|
vbox.add_child(_header_label("Objekt-Bibliothek"))
|
||||||
vbox.add_child(_muted_label("Objekt anklicken, dann in die Karte klicken.\nDer Platzier-Modus bleibt an, bis du Rechtsklick/ESC drückst — so setzt du mehrere hintereinander."))
|
vbox.add_child(_muted_label("Objekt anklicken, dann in die Karte klicken — der Modus bleibt an bis Rechtsklick/ESC."))
|
||||||
|
|
||||||
var scroll := ScrollContainer.new()
|
var scroll := ScrollContainer.new()
|
||||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
scroll.custom_minimum_size = Vector2(0, 160)
|
scroll.custom_minimum_size = Vector2(0, 280)
|
||||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
vbox.add_child(scroll)
|
vbox.add_child(scroll)
|
||||||
library_box = VBoxContainer.new()
|
library_box = VBoxContainer.new()
|
||||||
|
|
@ -259,12 +245,30 @@ func _build_left_panel() -> PanelContainer:
|
||||||
scroll.add_child(library_box)
|
scroll.add_child(library_box)
|
||||||
_fill_library()
|
_fill_library()
|
||||||
|
|
||||||
|
vbox.add_child(_separator())
|
||||||
|
vbox.add_child(_header_label("Objekt-Werkzeuge"))
|
||||||
|
|
||||||
|
var snap_btn := Button.new()
|
||||||
|
snap_btn.text = "⚡ Alles auf den Boden setzen"
|
||||||
|
snap_btn.custom_minimum_size = Vector2(0, 44)
|
||||||
|
snap_btn.tooltip_text = "Setzt ALLE Objekte auf die Terrain-Höhe — z. B. nach dem Terrain-Formen, wenn etwas in der Luft hängt"
|
||||||
|
snap_btn.pressed.connect(_on_snap_to_ground)
|
||||||
|
vbox.add_child(snap_btn)
|
||||||
|
|
||||||
|
var grid_check := CheckButton.new()
|
||||||
|
grid_check.text = "Raster-Snap (1 m)"
|
||||||
|
grid_check.custom_minimum_size = Vector2(0, 40)
|
||||||
|
grid_check.tooltip_text = "Neue und verschobene Objekte rasten auf ein 1-Meter-Raster ein"
|
||||||
|
grid_check.toggled.connect(_on_grid_snap_changed)
|
||||||
|
vbox.add_child(grid_check)
|
||||||
|
vbox.add_child(_muted_label("Raster-Snap rastet Platzieren und Ziehen auf ganze Meter."))
|
||||||
|
|
||||||
vbox.add_child(_separator())
|
vbox.add_child(_separator())
|
||||||
vbox.add_child(_header_label("Map-Inhalt"))
|
vbox.add_child(_header_label("Map-Inhalt"))
|
||||||
|
|
||||||
content_tree = Tree.new()
|
content_tree = Tree.new()
|
||||||
content_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
content_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
content_tree.custom_minimum_size = Vector2(0, 200)
|
content_tree.custom_minimum_size = Vector2(0, 160)
|
||||||
content_tree.allow_reselect = true
|
content_tree.allow_reselect = true
|
||||||
content_tree.hide_root = true
|
content_tree.hide_root = true
|
||||||
content_tree.item_selected.connect(_on_tree_selected)
|
content_tree.item_selected.connect(_on_tree_selected)
|
||||||
|
|
@ -274,19 +278,44 @@ func _build_left_panel() -> PanelContainer:
|
||||||
|
|
||||||
func _fill_library() -> void:
|
func _fill_library() -> void:
|
||||||
library_buttons.clear()
|
library_buttons.clear()
|
||||||
|
# Gruppen-Browser (Karte t_3a998ae4): Klapp-Überschriften je Gruppe,
|
||||||
|
# darunter die Objekte mit Thumbnail (128-px-Render, runterskaliert).
|
||||||
|
for group in LibraryBrowser.groups_in_order(manifest):
|
||||||
|
var g_label: String = group["label"]
|
||||||
|
var g_ids: Array = group["ids"]
|
||||||
|
if g_ids.is_empty():
|
||||||
|
continue
|
||||||
|
var header := Button.new()
|
||||||
|
header.text = "▾ %s (%d)" % [g_label, g_ids.size()]
|
||||||
|
header.toggle_mode = true
|
||||||
|
header.button_pressed = true
|
||||||
|
header.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||||
|
header.custom_minimum_size = Vector2(240, 40)
|
||||||
|
library_box.add_child(header)
|
||||||
|
var group_box := VBoxContainer.new()
|
||||||
|
group_box.add_theme_constant_override("separation", 4)
|
||||||
|
library_box.add_child(group_box)
|
||||||
|
header.toggled.connect(func(on: bool) -> void:
|
||||||
|
group_box.visible = on
|
||||||
|
header.text = "%s %s (%d)" % ["▾" if on else "▸", g_label, g_ids.size()])
|
||||||
|
for oid in g_ids:
|
||||||
|
_add_library_button(str(oid), group_box)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_library_button(oid: String, parent: Container) -> void:
|
||||||
var lib: Dictionary = manifest.get("objects", {})
|
var lib: Dictionary = manifest.get("objects", {})
|
||||||
for oid in lib.keys():
|
var entry: Dictionary = lib.get(oid, {})
|
||||||
var entry: Dictionary = lib[oid]
|
|
||||||
var poly := int(entry.get("poly", 0))
|
var poly := int(entry.get("poly", 0))
|
||||||
var btn := Button.new()
|
var btn := Button.new()
|
||||||
btn.text = "%s · %d Polys" % [object_label(str(oid)), poly]
|
btn.text = "%s · %d Polys" % [LibraryBrowser.object_label(oid), poly]
|
||||||
btn.custom_minimum_size = Vector2(240, 40)
|
btn.icon = LibraryBrowser.load_thumb(oid)
|
||||||
|
btn.custom_minimum_size = Vector2(240, 44)
|
||||||
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||||
btn.toggle_mode = true
|
btn.toggle_mode = true
|
||||||
btn.button_pressed = false
|
btn.button_pressed = false
|
||||||
btn.pressed.connect(_on_library_pressed.bind(str(oid)))
|
btn.pressed.connect(_on_library_pressed.bind(oid))
|
||||||
library_box.add_child(btn)
|
parent.add_child(btn)
|
||||||
library_buttons[str(oid)] = btn
|
library_buttons[oid] = btn
|
||||||
|
|
||||||
|
|
||||||
func _build_center_viewport() -> SubViewportContainer:
|
func _build_center_viewport() -> SubViewportContainer:
|
||||||
|
|
@ -701,7 +730,32 @@ func _highlight_library(oid: String) -> void:
|
||||||
|
|
||||||
|
|
||||||
func object_label(oid: String) -> String:
|
func object_label(oid: String) -> String:
|
||||||
return GERMAN_NAMES.get(oid, oid.capitalize())
|
return LibraryBrowser.object_label(oid)
|
||||||
|
|
||||||
|
|
||||||
|
## Snap to Ground (1 Klick): ALLE Objekte auf die Terrain-Höhe setzen —
|
||||||
|
## EIN Undo-Eintrag für die ganze Aktion.
|
||||||
|
func _on_snap_to_ground() -> void:
|
||||||
|
var objects: Array = doc.get("objects", [])
|
||||||
|
if objects.is_empty():
|
||||||
|
_set_status("Es gibt noch keine Objekte — platziere zuerst eins aus der Bibliothek links.")
|
||||||
|
return
|
||||||
|
var before: Dictionary = history.snapshot(["objects"])
|
||||||
|
var changed := ObjectOps.snap_to_ground(doc)
|
||||||
|
if changed == 0:
|
||||||
|
_set_status("Alle %d Objekte stehen schon auf dem Boden — nichts zu tun." % objects.size())
|
||||||
|
return
|
||||||
|
history.commit("Alle Objekte auf Boden gesetzt (%d)" % changed,
|
||||||
|
before, history.snapshot(["objects"]))
|
||||||
|
_after_structure_change()
|
||||||
|
_set_status("%d von %d Objekten auf den Boden gesetzt — Strg+Z nimmt es zurück." % [changed, objects.size()])
|
||||||
|
|
||||||
|
|
||||||
|
func _on_grid_snap_changed(pressed: bool) -> void:
|
||||||
|
grid_snap = pressed
|
||||||
|
_set_status("Raster-Snap %s — Objekte landen jetzt %s." % [
|
||||||
|
"AN" if pressed else "AUS",
|
||||||
|
"auf ganzen Metern (wie Bauklötze nebeneinander)" if pressed else "dort, wo du hinklickst"])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------- Werkzeug-Status ------
|
# ---------------------------------------------------- Werkzeug-Status ------
|
||||||
|
|
@ -1052,6 +1106,9 @@ func _maybe_start_demo_mode() -> void:
|
||||||
if args.has("--terrain-demo"):
|
if args.has("--terrain-demo"):
|
||||||
_start_terrain_demo(args)
|
_start_terrain_demo(args)
|
||||||
return
|
return
|
||||||
|
if args.has("--library-demo"):
|
||||||
|
_start_library_demo(args)
|
||||||
|
return
|
||||||
if not args.has("--ghost-demo"):
|
if not args.has("--ghost-demo"):
|
||||||
return
|
return
|
||||||
var oid := "crate_wood"
|
var oid := "crate_wood"
|
||||||
|
|
@ -1165,3 +1222,53 @@ func _demo_shot(path: String) -> void:
|
||||||
var aabb: AABB = mi.mesh.get_aabb()
|
var aabb: AABB = mi.mesh.get_aabb()
|
||||||
print("DEMO_AABB: pos=%s size=%s tris=%d" % [aabb.position, aabb.size, tv.triangle_count()])
|
print("DEMO_AABB: pos=%s size=%s tris=%d" % [aabb.position, aabb.size, tv.triangle_count()])
|
||||||
print("DEMO_CAM: cam=%s target=%s" % [camera.global_position, camera.target])
|
print("DEMO_CAM: cam=%s target=%s" % [camera.global_position, camera.target])
|
||||||
|
|
||||||
|
|
||||||
|
## `godot ... -- --library-demo [ordner]` — Desktop-Smoke (Karte t_3a998ae4):
|
||||||
|
## Gruppen-Bibliothek + platzierte Objekte + Snap-to-Ground programmatisch
|
||||||
|
## durchspielen, PNGs speichern. Die App bleibt offen.
|
||||||
|
func _start_library_demo(args: PackedStringArray) -> void:
|
||||||
|
var out_dir := "/tmp/scopedvr_library"
|
||||||
|
var idx := args.find("--library-demo")
|
||||||
|
if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"):
|
||||||
|
out_dir = args[idx + 1]
|
||||||
|
DirAccess.make_dir_recursive_absolute(out_dir)
|
||||||
|
|
||||||
|
# 1) Bibliothek-Browser: Gruppen sichtbar, Ghost-Preview aktiv.
|
||||||
|
_set_status("Demo 1/3: Bibliothek in Gruppen — ein Objekt anklicken setzt eine Vorschau in die Karte.")
|
||||||
|
interaction.demo_park_ghost(Vector3(10.0, SvmapDocument.terrain_height_at(doc, 10.0, 6.0), 6.0), "watchtower_wood")
|
||||||
|
_demo_camera(Vector3(0.0, 2.0, 0.0), 90.0, 35.0, 40.0)
|
||||||
|
await _demo_shot(out_dir + "/library_browser.png")
|
||||||
|
|
||||||
|
# 2) Objekte platziert (versch. Gruppen), eins orange ausgewählt.
|
||||||
|
var before: Dictionary = history.snapshot(["objects"])
|
||||||
|
(doc["objects"] as Array).append({"id": "watchtower_wood", "pos": [10.0, 0.0, 6.0], "scale": 1.0, "rot_y_deg": 30.0})
|
||||||
|
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [-8.0, 0.0, -4.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||||||
|
(doc["objects"] as Array).append({"id": "tree_pine", "pos": [-20.0, 0.0, 18.0], "scale": 1.0, "rot_y_deg": 75.0})
|
||||||
|
(doc["objects"] as Array).append({"id": "rock_large", "pos": [24.0, 0.0, -18.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||||||
|
history.commit("Demo: Objekte gesetzt", before, history.snapshot(["objects"]))
|
||||||
|
interaction.call("cancel_placement")
|
||||||
|
_clear_edit_state()
|
||||||
|
_refresh_all()
|
||||||
|
interaction.select_object(0, true)
|
||||||
|
_demo_camera(Vector3(0.0, 2.0, 0.0), 110.0, 40.0, -35.0)
|
||||||
|
_set_status("Demo 2/3: Objekte platziert (Wachturm orange ausgewählt) — ziehen, drehen, löschen wie gehabt.")
|
||||||
|
await _demo_shot(out_dir + "/library_placed.png")
|
||||||
|
|
||||||
|
# 3) Snap to Ground: Objekte künstlich in die Luft hängen, dann 1 Klick.
|
||||||
|
var float_before: Dictionary = history.snapshot(["objects"])
|
||||||
|
for obj in (doc["objects"] as Array):
|
||||||
|
var pos: Array = obj["pos"]
|
||||||
|
obj["pos"] = [float(pos[0]), float(pos[1]) + 7.0, float(pos[2])]
|
||||||
|
history.commit("Demo: Objekte schweben", float_before, history.snapshot(["objects"]))
|
||||||
|
_refresh_all()
|
||||||
|
_demo_camera(Vector3(0.0, 6.0, 0.0), 110.0, 30.0, -35.0)
|
||||||
|
_set_status("Demo 3/3a: Objekte SCHWEBEN nach dem Terrain-Formen — ein Klick auf „Alles auf den Boden setzen\" …")
|
||||||
|
await _demo_shot(out_dir + "/library_floating.png")
|
||||||
|
_on_snap_to_ground()
|
||||||
|
_demo_camera(Vector3(0.0, 4.0, 0.0), 110.0, 38.0, -35.0)
|
||||||
|
_set_status("Demo 3/3b: … und schon stehen ALLE wieder auf dem Boden (Strg+Z nimmt den ganzen Klick zurück).")
|
||||||
|
await _demo_shot(out_dir + "/library_after_snap.png")
|
||||||
|
print("LIBRARY_DEMO_OK: 4 Screenshots in %s" % out_dir)
|
||||||
|
if args.has("--library-demo-quit"):
|
||||||
|
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
|
||||||
307
tests/test_library.gd
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
extends SceneTree
|
||||||
|
## Editor-Tests (headless): Karte t_3a998ae4 — Objekt-Bibliothek, Snap-to-Ground,
|
||||||
|
## Raster-Snap, svmap-Roundtrip mit Objekten.
|
||||||
|
## Ausführen: godot --headless --path . --script res://tests/test_library.gd
|
||||||
|
## Exit 0 = alles grün. Läuft OHNE UI (LibraryBrowser/ObjectOps/SvmapIO/SvmapDocument).
|
||||||
|
## UI-Funktionen (Gruppen-Aufklappen, Thumbnail-Anzeige) werden im Desktop-Smoke
|
||||||
|
## verifiziert (--library-demo), nicht hier.
|
||||||
|
|
||||||
|
var failures := 0
|
||||||
|
var checks := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run_tests()
|
||||||
|
if failures == 0:
|
||||||
|
print("ALL OK: %d checks" % checks)
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("FAILED: %d/%d checks" % [failures, checks])
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(cond: bool, label: String) -> void:
|
||||||
|
checks += 1
|
||||||
|
if cond:
|
||||||
|
print(" ok: %s" % label)
|
||||||
|
else:
|
||||||
|
failures += 1
|
||||||
|
print(" FAIL: %s" % label)
|
||||||
|
|
||||||
|
|
||||||
|
func _run_tests() -> void:
|
||||||
|
_test_manifest_load()
|
||||||
|
_test_groups()
|
||||||
|
_test_thumbnails()
|
||||||
|
_test_grid_snap()
|
||||||
|
_test_snap_to_ground()
|
||||||
|
_test_snap_to_ground_undo()
|
||||||
|
_test_crud_undo_all_objects()
|
||||||
|
_test_svmap_roundtrip_objects()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- Bibliothek laden ----
|
||||||
|
|
||||||
|
func _load_manifest() -> Dictionary:
|
||||||
|
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH))
|
||||||
|
return parsed if parsed is Dictionary else {}
|
||||||
|
|
||||||
|
|
||||||
|
func _test_manifest_load() -> void:
|
||||||
|
print("Testgruppe: Bibliothek laden (library/objects.json)")
|
||||||
|
var manifest := _load_manifest()
|
||||||
|
_check(not manifest.is_empty(), "Manifest lädt und ist ein Objekt")
|
||||||
|
var lib: Dictionary = manifest.get("objects", {})
|
||||||
|
_check(lib.size() == 11, "Katalog hat die 11 etablierten Objekte (ist %d)" % lib.size())
|
||||||
|
var all_ok := true
|
||||||
|
for oid in lib.keys():
|
||||||
|
var entry: Dictionary = lib[oid]
|
||||||
|
if int(entry.get("poly", 0)) <= 0:
|
||||||
|
all_ok = false
|
||||||
|
var col: Dictionary = entry.get("collision", {})
|
||||||
|
if str(col.get("shape", "")).is_empty():
|
||||||
|
all_ok = false
|
||||||
|
_check(all_ok, "jedes Objekt hat poly > 0 + Kollider-Eintrag")
|
||||||
|
# Deutsche Namen decken ALLE IDs ab (Bibliothek-Browser zeigt nichts Englisches).
|
||||||
|
var names_ok := lib.keys().all(func(oid) -> bool: return LibraryBrowser.NAMES.has(oid))
|
||||||
|
_check(names_ok, "deutsche Namen für alle IDs vorhanden")
|
||||||
|
_check(LibraryBrowser.object_label("crate_wood") == "Holzkiste", "object_label: crate_wood → Holzkiste")
|
||||||
|
_check(LibraryBrowser.object_label("gibts_nicht") == "Gibts Nicht", "object_label: unbekannte ID fällt auf capitalize zurück")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_groups() -> void:
|
||||||
|
print("Testgruppe: Gruppen (Gruppe/Einzelobjekt im Browser)")
|
||||||
|
var manifest := _load_manifest()
|
||||||
|
var lib: Dictionary = manifest.get("objects", {})
|
||||||
|
var groups := LibraryBrowser.groups_in_order(manifest)
|
||||||
|
var seen := {}
|
||||||
|
var all_in := true
|
||||||
|
for group in groups:
|
||||||
|
_check(not str(group["label"]).is_empty(), "Gruppe „%s“ hat Label" % str(group["key"]))
|
||||||
|
for oid in (group["ids"] as Array):
|
||||||
|
if seen.has(oid):
|
||||||
|
all_in = false
|
||||||
|
seen[oid] = true
|
||||||
|
_check(all_in, "keine ID in zwei Gruppen")
|
||||||
|
_check(seen.size() == lib.size(), "alle %d IDs landen in einer Gruppe (ist %d)" % [lib.size(), seen.size()])
|
||||||
|
_check(LibraryBrowser.group_of("tree_pine") == "natur", "group_of: tree_pine → natur")
|
||||||
|
_check(LibraryBrowser.group_of("crate_wood") == "kisten", "group_of: crate_wood → kisten")
|
||||||
|
_check(LibraryBrowser.group_of("fremdes_ding") == "sonstiges", "group_of: unbekannte ID → sonstiges")
|
||||||
|
# Gruppen, die im Katalog leer sind, werden nicht angezeigt (außer Sonstiges).
|
||||||
|
var labels: Array = []
|
||||||
|
for group in groups:
|
||||||
|
labels.append(str(group["label"]))
|
||||||
|
var no_empty := true
|
||||||
|
for group in groups:
|
||||||
|
if str(group["key"]) != "sonstiges" and (group["ids"] as Array).is_empty():
|
||||||
|
no_empty = false
|
||||||
|
_check(no_empty, "leere feste Gruppen tauchen nicht auf")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_thumbnails() -> void:
|
||||||
|
print("Testgruppe: Thumbnails (128 px, einmalig gerendert + committed)")
|
||||||
|
var manifest := _load_manifest()
|
||||||
|
var lib: Dictionary = manifest.get("objects", {})
|
||||||
|
var missing: Array = []
|
||||||
|
var wrong_size := false
|
||||||
|
for oid in lib.keys():
|
||||||
|
var path := LibraryBrowser.thumb_path(str(oid))
|
||||||
|
if not FileAccess.file_exists(path):
|
||||||
|
missing.append(str(oid))
|
||||||
|
continue
|
||||||
|
var img := Image.load_from_file(path)
|
||||||
|
if img == null or img.get_width() != 128 or img.get_height() != 128:
|
||||||
|
wrong_size = true
|
||||||
|
_check(missing.is_empty(), "Thumbnail für jedes Objekt vorhanden (fehlend: %s)" % str(missing))
|
||||||
|
_check(not wrong_size, "alle Thumbnails sind 128×128")
|
||||||
|
var tex := LibraryBrowser.load_thumb("crate_wood", 40)
|
||||||
|
_check(tex != null, "load_thumb liefert Textur")
|
||||||
|
var fallback := LibraryBrowser.load_thumb("gibts_nicht")
|
||||||
|
_check(fallback == null, "load_thumb: fehlendes Thumbnail → null (Button fällt aufs Text-Layout zurück)")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Raster-Snap ----
|
||||||
|
|
||||||
|
func _test_grid_snap() -> void:
|
||||||
|
print("Testgruppe: Raster-Snap (1 m)")
|
||||||
|
_check(ObjectOps.snap_coord(10.4, true) == 10.0, "10.4 rastet auf 10")
|
||||||
|
_check(ObjectOps.snap_coord(10.6, true) == 11.0, "10.6 rastet auf 11")
|
||||||
|
_check(ObjectOps.snap_coord(-0.6, true) == -1.0, "-0.6 rastet auf -1")
|
||||||
|
_check(ObjectOps.snap_coord(-0.4, true) == 0.0, "-0.4 rastet auf 0")
|
||||||
|
_check(ObjectOps.snap_coord(10.4, false) == 10.4, "aus = unverändert")
|
||||||
|
_check(ObjectOps.snap_coord(10.4, true, 4.0) == 12.0, "4-m-Raster: 10.4 → 12")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- Snap to Ground ---
|
||||||
|
|
||||||
|
## Alle Heightmap-Samples auf einen 16-bit-Wert setzen → Terrain überall
|
||||||
|
## gleich hoch (h = min + v/65535 × Spannweite). Für Snap-Tests ohne
|
||||||
|
## Terrain-Werkzeug. uint16 little-endian (Low-Byte zuerst, map_format.md).
|
||||||
|
func _flatten_terrain(doc: Dictionary, v: int) -> void:
|
||||||
|
var terrain: Dictionary = doc["terrain"]
|
||||||
|
var res := int(terrain["resolution"])
|
||||||
|
var bytes := PackedByteArray()
|
||||||
|
bytes.resize(res * res * 2)
|
||||||
|
for i in res * res:
|
||||||
|
bytes[i * 2] = v & 0xFF
|
||||||
|
bytes[i * 2 + 1] = (v >> 8) & 0xFF
|
||||||
|
terrain["heightmap_r16_b64"] = Marshalls.raw_to_base64(bytes)
|
||||||
|
|
||||||
|
|
||||||
|
func _test_snap_to_ground() -> void:
|
||||||
|
print("Testgruppe: Snap to Ground (alle schwebenden Objekte auf Terrain-Höhe)")
|
||||||
|
var doc := SvmapDocument.create_new("Snap-Test", 1024.0, "mittag")
|
||||||
|
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||||||
|
(doc["objects"] as Array).append({"id": "tree_pine", "pos": [-5.0, 0.0, 3.0], "scale": 1.0, "rot_y_deg": 45.0})
|
||||||
|
_check(ObjectOps.snap_to_ground(doc) == 0, "flaches Terrain: alles steht schon (0 Änderungen)")
|
||||||
|
_flatten_terrain(doc, 65535) # Terrain überall auf max_height (50 m)
|
||||||
|
var h := SvmapDocument.terrain_height_at(doc, 10.0, 10.0)
|
||||||
|
_check(absf(h - 50.0) < 0.01, "Terrain-Höhe nach Heben: %.1f m" % h)
|
||||||
|
var changed := ObjectOps.snap_to_ground(doc)
|
||||||
|
_check(changed == 2, "2 schwebende Objekte erkannt")
|
||||||
|
var objects: Array = doc["objects"]
|
||||||
|
_check(absf(float(objects[0]["pos"][1]) - 50.0) < 0.01, "Objekt 1 steht auf dem Boden")
|
||||||
|
_check(absf(float(objects[1]["pos"][1]) - 50.0) < 0.01, "Objekt 2 steht auf dem Boden")
|
||||||
|
_check(ObjectOps.snap_to_ground(doc) == 0, "zweiter Klick: nichts mehr zu tun")
|
||||||
|
# Teilweise schwebend: nur das schwebende wird erfasst.
|
||||||
|
objects[0]["pos"][1] = 50.0
|
||||||
|
objects[1]["pos"][1] = 60.0
|
||||||
|
_check(ObjectOps.snap_to_ground(doc) == 1, "nur das schwebende Objekt wird gesnapt")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_snap_to_ground_undo() -> void:
|
||||||
|
print("Testgruppe: Snap to Ground = EIN Undo-Eintrag")
|
||||||
|
var doc := SvmapDocument.create_new("Snap-Undo", 1024.0, "mittag")
|
||||||
|
var hist := ActionHistory.new()
|
||||||
|
hist.begin(doc)
|
||||||
|
(doc["objects"] as Array).append({"id": "bunker_concrete", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
|
||||||
|
(doc["objects"] as Array).append({"id": "fence_metal", "pos": [4.0, 0.0, -2.0], "scale": 1.0, "rot_y_deg": 90.0})
|
||||||
|
_flatten_terrain(doc, 32768) # Terrain auf ~25 m
|
||||||
|
var before: Dictionary = hist.snapshot(["objects"])
|
||||||
|
var changed := ObjectOps.snap_to_ground(doc)
|
||||||
|
hist.commit("Alle Objekte auf Boden gesetzt (%d)" % changed, before, hist.snapshot(["objects"]))
|
||||||
|
var objects: Array = doc["objects"]
|
||||||
|
_check(changed == 2, "beide Objekte gesnapt")
|
||||||
|
_check(absf(float(objects[0]["pos"][1]) - 25.0) < 0.05, "Objekt 1 auf Terrain-Höhe")
|
||||||
|
hist.undo()
|
||||||
|
_check(absf(float(objects[0]["pos"][1]) - 0.0) < 0.0001, "undo: beide wieder schwebend (1 Schritt)")
|
||||||
|
_check(absf(float(objects[1]["pos"][1]) - 0.0) < 0.0001, "undo: Objekt 2 auch")
|
||||||
|
hist.redo()
|
||||||
|
_check(absf(float(objects[0]["pos"][1]) - 25.0) < 0.05 and absf(float(objects[1]["pos"][1]) - 25.0) < 0.05, "redo: wieder auf dem Boden")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- setzen/drehen/verschieben/löschen —
|
||||||
|
|
||||||
|
func _test_crud_undo_all_objects() -> void:
|
||||||
|
print("Testgruppe: JEDES Bibliotheks-Objekt — setzen/drehen/verschieben/löschen + je Undo")
|
||||||
|
var manifest := _load_manifest()
|
||||||
|
var lib: Dictionary = manifest.get("objects", {})
|
||||||
|
var doc := SvmapDocument.create_new("CRUD-Test", 1024.0, "mittag")
|
||||||
|
var hist := ActionHistory.new()
|
||||||
|
hist.begin(doc)
|
||||||
|
var all_ok := true
|
||||||
|
var first := true
|
||||||
|
for oid in lib.keys():
|
||||||
|
var x := randf() * 100.0 - 50.0
|
||||||
|
var z := randf() * 100.0 - 50.0
|
||||||
|
var y := SvmapDocument.terrain_height_at(doc, x, z)
|
||||||
|
# setzen (+ Undo)
|
||||||
|
var before := hist.snapshot(["objects"])
|
||||||
|
var obj := {"id": oid, "pos": [x, y, z], "scale": 1.0, "rot_y_deg": 0.0}
|
||||||
|
(doc["objects"] as Array).append(obj)
|
||||||
|
hist.commit("Test: %s platzieren" % oid, before, hist.snapshot(["objects"]))
|
||||||
|
var objects: Array = doc["objects"]
|
||||||
|
if objects.size() != 1 or absf(float(objects[0]["pos"][1]) - y) > 0.0001:
|
||||||
|
all_ok = false
|
||||||
|
hist.undo()
|
||||||
|
if not (doc["objects"] as Array).is_empty():
|
||||||
|
all_ok = false
|
||||||
|
hist.redo()
|
||||||
|
if (doc["objects"] as Array).size() != 1:
|
||||||
|
all_ok = false
|
||||||
|
# drehen (+ Undo)
|
||||||
|
before = hist.snapshot(["objects"])
|
||||||
|
objects[0]["rot_y_deg"] = 90.0
|
||||||
|
hist.commit("Test: drehen", before, hist.snapshot(["objects"]), "rot:0")
|
||||||
|
hist.undo()
|
||||||
|
if absf(float(objects[0]["rot_y_deg"]) - 0.0) > 0.0001:
|
||||||
|
all_ok = false
|
||||||
|
hist.redo()
|
||||||
|
if absf(float(objects[0]["rot_y_deg"]) - 90.0) > 0.0001:
|
||||||
|
all_ok = false
|
||||||
|
# verschieben (+ Undo)
|
||||||
|
before = hist.snapshot(["objects"])
|
||||||
|
objects[0]["pos"] = [x + 3.0, y, z + 3.0]
|
||||||
|
hist.commit("Test: verschieben", before, hist.snapshot(["objects"]), "move:0")
|
||||||
|
hist.undo()
|
||||||
|
if absf(float(objects[0]["pos"][0]) - x) > 0.0001:
|
||||||
|
all_ok = false
|
||||||
|
hist.redo()
|
||||||
|
if absf(float(objects[0]["pos"][0]) - (x + 3.0)) > 0.0001:
|
||||||
|
all_ok = false
|
||||||
|
# löschen (+ Undo)
|
||||||
|
before = hist.snapshot(["objects"])
|
||||||
|
(doc["objects"] as Array).remove_at(0)
|
||||||
|
hist.commit("Test: löschen", before, hist.snapshot(["objects"]))
|
||||||
|
hist.undo()
|
||||||
|
if (doc["objects"] as Array).size() != 1:
|
||||||
|
all_ok = false
|
||||||
|
hist.redo()
|
||||||
|
if not (doc["objects"] as Array).is_empty():
|
||||||
|
all_ok = false
|
||||||
|
if first:
|
||||||
|
first = false
|
||||||
|
_check(all_ok, "alle 11 Objekte: setzen/drehen/verschieben/löschen je mit Undo+Redo sauber")
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------- svmap-Roundtrip ----
|
||||||
|
|
||||||
|
func _test_svmap_roundtrip_objects() -> void:
|
||||||
|
print("Testgruppe: svmap-Roundtrip — Objekte (ID + Pos/Rot/Scale) überleben Speichern/Laden")
|
||||||
|
var doc := SvmapDocument.create_new("Roundtrip", 1024.0, "abend")
|
||||||
|
_flatten_terrain(doc, 20000) # Terrain leicht erhöht (~15 m)
|
||||||
|
var objects: Array = doc["objects"]
|
||||||
|
objects.append({"id": "watchtower_wood", "pos": [10.0, SvmapDocument.terrain_height_at(doc, 10.0, 6.0), 6.0], "scale": 1.0, "rot_y_deg": 30.0})
|
||||||
|
objects.append({"id": "crate_wood", "pos": [-8.0, SvmapDocument.terrain_height_at(doc, -8.0, -4.0), -4.0], "scale": 2.0, "rot_y_deg": 0.0})
|
||||||
|
objects.append({"id": "tree_pine", "pos": [-20.0, SvmapDocument.terrain_height_at(doc, -20.0, 18.0), 18.0], "scale": 1.0, "rot_y_deg": 75.0})
|
||||||
|
objects.append({"id": "rock_large", "pos": [24.0, SvmapDocument.terrain_height_at(doc, 24.0, -18.0), -18.0], "scale": 0.5, "rot_y_deg": 180.0})
|
||||||
|
objects.append({"id": "bunker_concrete", "pos": [0.0, SvmapDocument.terrain_height_at(doc, 0.0, 40.0), 40.0], "scale": 1.0, "rot_y_deg": 270.0})
|
||||||
|
var manifest := _load_manifest()
|
||||||
|
SvmapIO.sign_map(doc) # Prüfsumme setzen → validate_map (inkl. V13) prüfbar
|
||||||
|
_check(SvmapIO.validate_map(doc, manifest).is_empty(), "Map mit 5 Objekten validiert sauber (V1–V14)")
|
||||||
|
|
||||||
|
var save1 := SvmapIO.save_to_file("user://test_library_roundtrip.svmap", doc)
|
||||||
|
_check(save1["ok"], "save_to_file ok: %s" % str(save1["errors"]))
|
||||||
|
var load1 := SvmapIO.load_from_file("user://test_library_roundtrip.svmap")
|
||||||
|
_check(load1["ok"], "load_from_file ok: %s" % str(load1["errors"]))
|
||||||
|
var doc2: Dictionary = load1["data"]
|
||||||
|
var objs2: Array = doc2["objects"]
|
||||||
|
var same := objs2.size() == objects.size()
|
||||||
|
if same:
|
||||||
|
for i in objects.size():
|
||||||
|
var a: Dictionary = objects[i]
|
||||||
|
var b: Dictionary = objs2[i]
|
||||||
|
if str(a["id"]) != str(b["id"]):
|
||||||
|
same = false
|
||||||
|
for k in 3:
|
||||||
|
if absf(float(a["pos"][k]) - float(b["pos"][k])) > 0.0001:
|
||||||
|
same = false
|
||||||
|
if absf(float(a["rot_y_deg"]) - float(b["rot_y_deg"])) > 0.0001:
|
||||||
|
same = false
|
||||||
|
if absf(float(a["scale"]) - float(b["scale"])) > 0.0001:
|
||||||
|
same = false
|
||||||
|
_check(same, "alle Objekte identisch zurück (ID, Pos, Rot, Scale)")
|
||||||
|
_check(SvmapIO.validate_map(doc2, manifest).is_empty(), "geladene Map validiert erneut sauber")
|
||||||
|
var save2 := SvmapIO.save_to_file("user://test_library_roundtrip2.svmap", doc2)
|
||||||
|
_check(save2["ok"], "zweites Speichern ok")
|
||||||
|
var text1 := FileAccess.get_file_as_string("user://test_library_roundtrip.svmap")
|
||||||
|
var text2 := FileAccess.get_file_as_string("user://test_library_roundtrip2.svmap")
|
||||||
|
_check(text1 == text2, "save→load→save byte-identisch")
|
||||||
|
# Poly-Budget-Rechnung kommt aus dem Katalog (Σ poly × scale²).
|
||||||
|
var expected := 0.0
|
||||||
|
for o in objects:
|
||||||
|
expected += float(manifest["objects"][str(o["id"])]["poly"]) * float(o["scale"]) * float(o["scale"])
|
||||||
|
_check(absf(SvmapDocument.poly_usage(doc, manifest) - expected) < 0.01,
|
||||||
|
"Poly-Budget aus Katalog: %.0f (erwartet %.0f)" % [SvmapDocument.poly_usage(doc, manifest), expected])
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip.svmap"))
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip2.svmap"))
|
||||||
1
tests/test_library.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://gcjtf6ekk4ub
|
||||||
100
tools/gen_thumbs.gd
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
extends SceneTree
|
||||||
|
## Thumbnail-Generator für die Objekt-Bibliothek (Karte t_3a998ae4).
|
||||||
|
## Rendert JEDESM Bibliotheks-Objekt einmalig als 128-px-PNG nach
|
||||||
|
## library/thumbs/<id>.png — die Bilder werden committed, der Editor lädt sie
|
||||||
|
## dann einfach aus der Datei (LibraryBrowser.load_thumb).
|
||||||
|
##
|
||||||
|
## Ausführen (braucht echtes Rendering, NICHT --headless):
|
||||||
|
## xvfb-run -a godot4 --rendering-driver opengl3 --path . --script res://tools/gen_thumbs.gd
|
||||||
|
## (auf Slys PC auch ohne xvfb: godot4 --rendering-driver opengl3 --path . --script ...)
|
||||||
|
##
|
||||||
|
## Exit 0 = alle Thumbnails geschrieben.
|
||||||
|
|
||||||
|
const SIZE := 128
|
||||||
|
const BG_COLOR := Color(0.16, 0.18, 0.21)
|
||||||
|
|
||||||
|
var manifest: Dictionary = {}
|
||||||
|
var ids: Array = []
|
||||||
|
var idx := -1
|
||||||
|
var settle_frames := 0
|
||||||
|
var vp: SubViewport
|
||||||
|
var mi: MeshInstance3D
|
||||||
|
var cam: Camera3D
|
||||||
|
var out_dir := ""
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH))
|
||||||
|
if parsed is Dictionary:
|
||||||
|
manifest = parsed
|
||||||
|
ids = (manifest.get("objects", {}) as Dictionary).keys()
|
||||||
|
if ids.is_empty():
|
||||||
|
push_error("gen_thumbs: Bibliothek leer — nichts zu rendern.")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
out_dir = ProjectSettings.globalize_path("res://library/thumbs")
|
||||||
|
DirAccess.make_dir_recursive_absolute(out_dir)
|
||||||
|
|
||||||
|
vp = SubViewport.new()
|
||||||
|
vp.size = Vector2i(SIZE, SIZE)
|
||||||
|
vp.own_world_3d = true
|
||||||
|
vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||||
|
root.add_child(vp)
|
||||||
|
|
||||||
|
var env := Environment.new()
|
||||||
|
env.background_mode = Environment.BG_COLOR
|
||||||
|
env.background_color = BG_COLOR
|
||||||
|
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)
|
||||||
|
|
||||||
|
mi = MeshInstance3D.new()
|
||||||
|
vp.add_child(mi)
|
||||||
|
cam = Camera3D.new()
|
||||||
|
cam.current = true
|
||||||
|
vp.add_child(cam)
|
||||||
|
idx = -1 # erst im ersten _process starten — look_at braucht eingetretene Knoten
|
||||||
|
|
||||||
|
|
||||||
|
func _next() -> void:
|
||||||
|
idx += 1
|
||||||
|
if idx >= ids.size():
|
||||||
|
print("GEN_THUMBS_OK: %d Thumbnails in %s" % [ids.size(), out_dir])
|
||||||
|
quit(0)
|
||||||
|
return
|
||||||
|
var oid := str(ids[idx])
|
||||||
|
var entry: Dictionary = (manifest["objects"] as Dictionary)[oid]
|
||||||
|
var col: Dictionary = entry.get("collision", {})
|
||||||
|
var mesh := MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0)
|
||||||
|
mi.mesh = mesh
|
||||||
|
# Kamera diagonal aufs Objekt, passend zur Mesh-Größe (Kiste nah, Turm weit).
|
||||||
|
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)
|
||||||
|
settle_frames = 4
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
if idx < 0:
|
||||||
|
_next()
|
||||||
|
return false
|
||||||
|
if settle_frames > 0:
|
||||||
|
settle_frames -= 1
|
||||||
|
return false
|
||||||
|
var img := vp.get_texture().get_image()
|
||||||
|
var oid := str(ids[idx])
|
||||||
|
img.save_png(out_dir + "/" + oid + ".png")
|
||||||
|
print(" thumb: %s (%dx%d)" % [oid, img.get_width(), img.get_height()])
|
||||||
|
_next()
|
||||||
|
return false
|
||||||
1
tools/gen_thumbs.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b37674pu4v4nc
|
||||||
15
tools/parse_check.sh
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Parse-Check über alle geänderten/neuen Scripts (Karte t_3a998ae4)
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
GODOT=~/bin/godot4
|
||||||
|
fail=0
|
||||||
|
for f in src/ui/main.gd src/editor/library_browser.gd src/editor/object_ops.gd \
|
||||||
|
src/editor/interaction.gd tools/gen_thumbs.gd tests/test_library.gd; do
|
||||||
|
out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true)
|
||||||
|
if [ -n "$out" ]; then
|
||||||
|
echo "FAIL $f:"; echo "$out"; fail=1
|
||||||
|
else
|
||||||
|
echo "OK $f"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $fail
|
||||||
13
tools/run_tests.sh
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Regression: alle Editor-Testsuiten (Karte t_3a998ae4)
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
GODOT=~/bin/godot4
|
||||||
|
rc=0
|
||||||
|
for t in test_ux test_editor_svmap test_terrain test_library; do
|
||||||
|
"$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1
|
||||||
|
code=$?
|
||||||
|
line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)
|
||||||
|
echo "$t: exit=$code $line"
|
||||||
|
[ $code -ne 0 ] && rc=1
|
||||||
|
done
|
||||||
|
exit $rc
|
||||||