From 07047c6ee4fb5b7fff5fb29beea9ffca605e2346 Mon Sep 17 00:00:00 2001 From: Lucas Hahmann Date: Wed, 4 Nov 2026 13:36:01 +0100 Subject: [PATCH] first commit --- .gitignore | 32 ++ CLAUDE.md | 65 +++ README.md | 190 +++++++ build.gradle | 92 ++++ gradle.properties | 21 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++++++++ gradlew.bat | 93 ++++ ressources/Another castle.litematic | Bin 0 -> 29090 bytes ressources/mods-atm10.txt | 464 ++++++++++++++++++ settings.gradle | 12 + .../de/lucas/litematica/NeoLitematica.java | 69 +++ .../de/lucas/litematica/config/ModConfig.java | 54 ++ .../lucas/litematica/config/RenderMode.java | 27 + .../de/lucas/litematica/dev/DevSmokeTest.java | 285 +++++++++++ .../de/lucas/litematica/gui/HudOverlay.java | 64 +++ .../de/lucas/litematica/gui/MainScreen.java | 290 +++++++++++ .../lucas/litematica/gui/PlacementScreen.java | 247 ++++++++++ .../gui/SchematicBrowserScreen.java | 385 +++++++++++++++ .../lucas/litematica/gui/TextInputScreen.java | 68 +++ .../lucas/litematica/input/InputHandler.java | 140 ++++++ .../lucas/litematica/input/KeyBindings.java | 65 +++ .../lucas/litematica/input/TransformText.java | 19 + .../litematica/mixin/LevelRendererMixin.java | 22 + .../placement/PlacementManager.java | 206 ++++++++ .../placement/PlacementStorage.java | 150 ++++++ .../placement/PlacementTransform.java | 116 +++++ .../litematica/placement/PlacementView.java | 125 +++++ .../placement/SchematicPlacement.java | 154 ++++++ .../placement/VerificationStats.java | 15 + .../litematica/placement/VerticalFlip.java | 64 +++ .../lucas/litematica/render/BlockStatus.java | 66 +++ .../litematica/render/GhostBufferSource.java | 62 +++ .../litematica/render/PlacementRenderer.java | 260 ++++++++++ .../litematica/render/RenderSection.java | 138 ++++++ .../render/SchematicBlockGetter.java | 86 ++++ .../litematica/render/SchematicRenderer.java | 463 +++++++++++++++++ .../litematica/render/SectionMeshBuilder.java | 243 +++++++++ .../schematic/LitematicBitArray.java | 45 ++ .../litematica/schematic/LitematicReader.java | 237 +++++++++ .../schematic/LitematicSchematic.java | 107 ++++ .../litematica/schematic/SchematicFiles.java | 110 +++++ .../schematic/SchematicLoadException.java | 12 + .../schematic/SchematicManager.java | 92 ++++ .../schematic/SchematicMetadata.java | 19 + .../litematica/schematic/SchematicRegion.java | 51 ++ .../assets/neolitematica/lang/de_de.json | 134 +++++ .../assets/neolitematica/lang/en_us.json | 134 +++++ src/main/resources/neolitematica.mixins.json | 12 + .../templates/META-INF/neoforge.mods.toml | 30 ++ .../placement/PlacementTransformTest.java | 100 ++++ .../schematic/LitematicReaderTest.java | 77 +++ 53 files changed, 6267 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 ressources/Another castle.litematic create mode 100644 ressources/mods-atm10.txt create mode 100644 settings.gradle create mode 100644 src/main/java/de/lucas/litematica/NeoLitematica.java create mode 100644 src/main/java/de/lucas/litematica/config/ModConfig.java create mode 100644 src/main/java/de/lucas/litematica/config/RenderMode.java create mode 100644 src/main/java/de/lucas/litematica/dev/DevSmokeTest.java create mode 100644 src/main/java/de/lucas/litematica/gui/HudOverlay.java create mode 100644 src/main/java/de/lucas/litematica/gui/MainScreen.java create mode 100644 src/main/java/de/lucas/litematica/gui/PlacementScreen.java create mode 100644 src/main/java/de/lucas/litematica/gui/SchematicBrowserScreen.java create mode 100644 src/main/java/de/lucas/litematica/gui/TextInputScreen.java create mode 100644 src/main/java/de/lucas/litematica/input/InputHandler.java create mode 100644 src/main/java/de/lucas/litematica/input/KeyBindings.java create mode 100644 src/main/java/de/lucas/litematica/input/TransformText.java create mode 100644 src/main/java/de/lucas/litematica/mixin/LevelRendererMixin.java create mode 100644 src/main/java/de/lucas/litematica/placement/PlacementManager.java create mode 100644 src/main/java/de/lucas/litematica/placement/PlacementStorage.java create mode 100644 src/main/java/de/lucas/litematica/placement/PlacementTransform.java create mode 100644 src/main/java/de/lucas/litematica/placement/PlacementView.java create mode 100644 src/main/java/de/lucas/litematica/placement/SchematicPlacement.java create mode 100644 src/main/java/de/lucas/litematica/placement/VerificationStats.java create mode 100644 src/main/java/de/lucas/litematica/placement/VerticalFlip.java create mode 100644 src/main/java/de/lucas/litematica/render/BlockStatus.java create mode 100644 src/main/java/de/lucas/litematica/render/GhostBufferSource.java create mode 100644 src/main/java/de/lucas/litematica/render/PlacementRenderer.java create mode 100644 src/main/java/de/lucas/litematica/render/RenderSection.java create mode 100644 src/main/java/de/lucas/litematica/render/SchematicBlockGetter.java create mode 100644 src/main/java/de/lucas/litematica/render/SchematicRenderer.java create mode 100644 src/main/java/de/lucas/litematica/render/SectionMeshBuilder.java create mode 100644 src/main/java/de/lucas/litematica/schematic/LitematicBitArray.java create mode 100644 src/main/java/de/lucas/litematica/schematic/LitematicReader.java create mode 100644 src/main/java/de/lucas/litematica/schematic/LitematicSchematic.java create mode 100644 src/main/java/de/lucas/litematica/schematic/SchematicFiles.java create mode 100644 src/main/java/de/lucas/litematica/schematic/SchematicLoadException.java create mode 100644 src/main/java/de/lucas/litematica/schematic/SchematicManager.java create mode 100644 src/main/java/de/lucas/litematica/schematic/SchematicMetadata.java create mode 100644 src/main/java/de/lucas/litematica/schematic/SchematicRegion.java create mode 100644 src/main/resources/assets/neolitematica/lang/de_de.json create mode 100644 src/main/resources/assets/neolitematica/lang/en_us.json create mode 100644 src/main/resources/neolitematica.mixins.json create mode 100644 src/main/templates/META-INF/neoforge.mods.toml create mode 100644 src/test/java/de/lucas/litematica/placement/PlacementTransformTest.java create mode 100644 src/test/java/de/lucas/litematica/schematic/LitematicReaderTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..074c127 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Gradle +.gradle/ +build/ +out/ +!gradle/wrapper/gradle-wrapper.jar + +# Minecraft dev runs (worlds, logs, screenshots, configs) +run/ +run-server/ +runs/ + +# IDEs +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +.settings/ +.project +.classpath +bin/ + +# Third-party binaries used only as reference (large, not ours to redistribute) +ressources/*.jar + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Logs +*.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..daced9b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4d6fbb --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# NeoLitematica + +Client-seitiger Schematic-Viewer und Bauassistent für **Minecraft 1.21.1 / NeoForge 21.1.265**. Er liest das +originale **`.litematic`-Format** von Litematica, zeigt Blueprints als transparente Vorschau in der Welt und +vergleicht sie mit dem tatsächlich Gebauten. Er ist eine eigenständige Neuimplementierung: Fabric, Malilib und +Code von Litematica werden nicht verwendet. + +## Installation + +1. `build/libs/neolitematica-1.21.1-1.0.0.jar` in den `mods`-Ordner der ATM10-Instanz kopieren. +2. Minecraft starten. **Auf dem Server muss nichts installiert werden**, auch nicht auf Multiplayer-Servern. + +Schematics liegen in `.minecraft/schematics/` (bzw. `/schematics/`). Das ist derselbe Ordner wie bei +Litematica, vorhandene Sammlungen funktionieren also direkt. + +## Schnellstart + +1. In einer Welt **Alt+L** drücken (oder **Alt+M** → „Schematic laden…"). +2. **Importieren…** wählen (Windows-Dateidialog) oder `.litematic`-Dateien ins Minecraft-Fenster ziehen. +3. Schematic auswählen → **Hier platzieren** (oder Doppelklick). Die minimale Ecke liegt an deiner Position. +4. Verschieben: **Pfeiltasten** (relativ zur Blickrichtung), **Bild↑/Bild↓** für hoch/runter, + mit **Strg** in 10er-Schritten. Exakte Koordinaten: **Alt+E** (Placement-Editor). +5. Drehen mit **Alt+R**, Spiegeln im Placement-Editor. +6. Bauen: Im Standardmodus „Nur fehlende" verschwinden korrekt gesetzte Blöcke sofort aus der Vorschau. + Falsche Blöcke werden rot markiert, falsche Ausrichtung/Zustand orange. + +## Tastenbelegung + +Alle Tasten lassen sich unter Optionen → Steuerung → „NeoLitematica" ändern (auch mit dem ATM10-Mod *Controlling*). +Die Standardbelegung nutzt Alt-Kombinationen, weil `M` in ATM10 bereits von FTB Chunks belegt ist. + +| Taste | Funktion | +|---|---| +| Alt+M | Hauptmenü (Placements, Rendering-Einstellungen) | +| Alt+L | Schematic-Übersicht (Import, Laden, Umbenennen, Löschen) | +| Alt+E | Ausgewähltes Placement bearbeiten | +| Pfeiltasten | Placement vor/zurück/links/rechts (relativ zur Blickrichtung), Strg = ×10 | +| Bild↑ / Bild↓ | Placement hoch / runter | +| Alt+R | Im Uhrzeigersinn drehen | +| Alt+P | Placement an Spielerposition setzen | +| Alt+N | Nächstes Placement auswählen | +| Alt+H | Alle Schematics ein-/ausblenden | +| Alt+V | Rendering-Modus wechseln | +| *(nicht belegt)* | Gegen Uhrzeigersinn drehen, Spiegeln X/Y/Z, Placement ein-/ausblenden, Position zurücksetzen | + +## Oberfläche + +- **Hauptmenü (Alt+M):** Liste aller Placements der aktuellen Dimension mit Position, Größe und Drehung. + Bearbeiten, ein-/ausblenden, an eigene Position setzen, entfernen. Dazu Rendering an/aus, Modus, + Deckkraft, Render-Distanz sowie Links zu den Einstellungen und zur Steuerung. +- **Schematic-Übersicht (Alt+L):** alle `.litematic`-Dateien (auch in Unterordnern) mit Name, Autor, + Abmessungen, Blockanzahl, Volumen, Regionen, Version und Dateigröße. Import per Dialog oder Drag & Drop, + Ordner öffnen, umbenennen, löschen. +- **Placement-Editor (Alt+E):** X/Y/Z-Felder mit −10/−1/+1/+10 (Umschalt = ×10), „Zu meiner Position", + „Position zurücksetzen", „Als Ursprung merken", Drehung 0/90/180/270°, Spiegeln X/Z/Y, Sichtbarkeit, + Blockvergleich. Die Welt wird dabei weder pausiert noch weichgezeichnet, Änderungen sind live sichtbar. +- **HUD (oben links):** ausgewähltes Placement, Position, Größe, Drehung/Spiegelung, fehlende/falsche/korrekte Blöcke. +- **Einstellungen:** Mods → NeoLitematica → Konfiguration, oder Hauptmenü → „Einstellungen…". + Gespeichert in `config/neolitematica-client.toml`. + +## Rendering-Modi und Farben + +| Modus | Verhalten | +|---|---| +| Alle Blöcke | Alle Blöcke als Geisterblöcke, ohne Vergleich mit der Welt | +| Nur fehlende *(Standard)* | Geisterblöcke nur für fehlende Blöcke; korrekte Blöcke werden ausgeblendet | +| Prüfen | Wie „Nur fehlende", dazu korrekt = grün, überzählig = magenta | + +Rot = falscher Block, orange = richtiger Block mit falschem Zustand (z. B. Treppenrichtung), violett = Block-Typ +nicht installiert. Ersetzbare Blöcke wie Gras, Schneeschicht oder Wasser zählen als „fehlend", nicht als „falsch". + +## Placements und Speicherung + +- Beliebig viele Placements gleichzeitig, jedes unabhängig positionierbar und ausblendbar. +- Gespeichert pro Welt bzw. Server in `config/neolitematica/placements/.json`, inklusive Dimension, + Position, ursprünglicher Position, Drehung, Spiegelung und Sichtbarkeit. Nach einem Neustart wird alles + wiederhergestellt. +- Die `.litematic`-Datei wird direkt verwendet, es gibt keine Konvertierung. + +## Technische Umsetzung + +``` +de.lucas.litematica +├── NeoLitematica Einstieg, @Mod(dist = CLIENT), Event-Registrierung +├── schematic/ .litematic lesen (NBT, Bit-Array, Paletten, DataFixer), Dateiverwaltung, Cache +├── placement/ Placements, Transformation (Drehung/Spiegelung), Persistenz, Vergleichsstatistik +├── render/ Chunk-basiertes Mesh-Rendering, Blockvergleich, Block-Entity-Geister +├── gui/ Hauptmenü, Schematic-Übersicht, Placement-Editor, HUD +├── input/ Tastenbelegung und Hotkey-Verarbeitung +├── config/ NeoForge ModConfigSpec (Client) +├── mixin/ genau ein Mixin (siehe unten) +└── dev/ automatischer Ingame-Test, nur mit System-Property aktiv +``` + +**Dateiformat.** `.litematic` ist gzip-komprimiertes NBT: `Version`, `MinecraftDataVersion`, `Metadata`, und +`Regions` mit je `Position`, `Size` (auch negativ), `BlockStatePalette`, `BlockStates` (dicht gepacktes +`long[]`, Einträge können über Long-Grenzen reichen, `max(2, ceil(log2(Palettengröße)))` Bit) und +`TileEntities`. Index = `(y·sizeZ + z)·sizeX + x`. Dateien älterer Minecraft-Versionen werden über +Minecrafts eigenen DataFixer aktualisiert. Unbekannte Blöcke werden erkannt, gezählt, angezeigt und violett +markiert. Ungültige oder beschädigte Dateien liefern eine verständliche Fehlermeldung, kein Absturz. + +**Rendering.** Ein Placement wird in 16×16×16-Welt-Sektionen zerlegt. Jede Sektion wird **einmal auf +Hintergrund-Threads** zu GPU-Vertex-Buffern gemesht (vanillas `BlockRenderDispatcher` mit echten Blockmodellen, +Biom-Farben, Flüssigkeiten) und nur neu gebaut, wenn sich das Placement oder ein Block in genau dieser +Sektion ändert. Pro Frame werden nur noch gecachte Buffer gebunden, plus Frustum-Culling und Render-Distanz. +Flächen zwischen benachbarten Schematic-Blöcken werden weggelassen. Connected Textures (CTM, Fusion, +Connected Glass) verbinden sich innerhalb des Schematics. Die Weltdaten für den Vergleich werden auf dem +Main-Thread kopiert, sodass Worker nie auf live Chunk-Daten zugreifen (wie vanillas Chunk-Builder). +Gezeichnet wird in `RenderLevelStageEvent.AFTER_TRANSLUCENT_BLOCKS` mit vanilla-Shadern +(`rendertype_cutout` + Alpha-Blending über `ColorModulator`). Geisterblöcke sind voll beleuchtet und damit +auch nachts und unter Tage sichtbar. + +**Warum ein Mixin?** NeoForge hat kein clientseitiges Event für Blockänderungen. Damit die Vorschau sofort +reagiert, wenn ein Block gesetzt oder abgebaut wird, hängt sich ein einzelner `@Inject` an +`LevelRenderer#setSectionDirty`, über den vanilla alle Neuzeichnungs-Anforderungen schickt. Das Mixin ist +mit `require = 0` markiert: Ersetzt ein anderer Mod die Methode, startet das Spiel trotzdem, und Updates +kommen dann nur noch über `ChunkEvent.Load`. + +**Client-only.** `@Mod(dist = Dist.CLIENT)` (die Mod-Klasse wird auf dedizierten Servern nie geladen), +`displayTest = "IGNORE_ALL_VERSION"`, das Mixin nur im `client`-Abschnitt, keine Netzwerk-Payloads, und alle +Abhängigkeiten in der `neoforge.mods.toml` mit `side = "CLIENT"`. Außer NeoForge gibt es keine Abhängigkeiten. + +**Transformation.** Reihenfolge: Spiegeln Y → X → Z → Drehen um Y, wie bei vanillas Strukturplatzierung. +Blockzustände nutzen vanillas `BlockState.mirror/rotate`, das alle Vanilla- und korrekt implementierten +Mod-Blöcke unterstützt (Treppen, Türen, Öfen, Truhen, Schienen, …). Ein Unit-Test prüft für alle 32 +Kombinationen, dass Positions- und Zustandstransformation übereinstimmen. + +## Kompatibilität mit ATM10 + +Grundlage ist eine Analyse von `mods-atm10.txt` (464 Mods): + +| Mod(s) | Bewertung | +|---|---| +| Sodium / Embeddium / Iris / Oculus | **Nicht in ATM10.** Damit läuft die Welt über vanillas Renderer, dessen Schnittstellen hier genutzt werden. | +| ImmediatelyFast, ModernFix, FerriteCore | Unkritisch: Es werden nur öffentliche Render-APIs genutzt, Blockmodelle lädt der Mod wie vanilla. | +| CTM, Fusion, Connected Glass, Chisel, Rechiseled, Chipped | Unterstützt: Verbindungen werden aus den Schematic-Nachbarn berechnet. | +| Macaw's (mcw-*), Supplementaries, viele Deko-Mods | Normale Blockmodelle, werden korrekt dargestellt und gedreht. | +| FramedBlocks, Mekanism Covers | **Einschränkung:** Das Tarn-Aussehen hängt an Block-Entity-Daten der echten Welt; in der Vorschau erscheint der leere Rahmen. Der Blockvergleich funktioniert trotzdem. | +| Create (+Addons), AE2, Refined Storage, Mekanism, Pipez, LaserIO | **Einschränkung:** Teile, die über Block-Entity-Renderer oder Flywheel gezeichnet werden (Wellen, Zahnräder, Kabelverbindungen), fehlen in der Vorschau teilweise. Der Grundblock und der Vergleich funktionieren. | +| FTB Chunks, JourneyMap | Belegen `M` bzw. `J` → Standardtasten von NeoLitematica nutzen Alt+… | +| Building Gadgets 2, Construction Sticks, Mining Gadgets | Zeichnen eigene Vorschauen, ohne Konflikt. | +| Controlling, KeybindsPurger | Zum Umbelegen der Tasten nutzbar. | + +Kein Mod wird verändert, und Serverkommunikation findet nicht statt. Wer Embeddium oder Oculus nachinstalliert: +Das Rendering sollte weiter funktionieren, aber mit aktiven Shaderpacks sind Darstellungsfehler möglich, und +Embeddium ersetzt eventuell die Methode des Mixins (dann Updates nur bei Chunk-Ladevorgängen). Getestet ist +das nicht. + +## Bekannte Einschränkungen + +- **Drehung um X- und Z-Achse wird nicht angeboten.** Minecraft-Blockzustände können „seitlich gekippte" + Treppen, Türen usw. nicht abbilden; eine solche Drehung würde zwangsläufig falsche Blöcke erzeugen. + Angeboten werden Drehung um Y in 90°-Schritten sowie Spiegeln an X, Y und Z. +- **Spiegeln an Y ist eine Annäherung:** Halbe Blöcke, Stufen, Türen, oben/unten-Ausrichtung, + Boden/Decke-Befestigung und Tropfstein werden umgedreht. Blöcke ohne vertikales Gegenstück (z. B. + hängende Laternen) behalten ihren Zustand. +- **Entities** (Gemälde, Rahmen, Rüstungsständer) im Schematic werden nicht angezeigt. +- **Block-Entities** werden für Vanilla-Blöcke (Truhen, Schilder, Betten, Banner, Köpfe, Töpfe, …) transparent + dargestellt. Mod-Block-Entities bekommen absichtlich keine Welt-Referenz; schlägt ihr Renderer fehl, wird + der Typ für die Sitzung übersprungen und geloggt. +- **Blockvergleich** gilt nur für Sektionen innerhalb der Render-Distanz und für geladene Chunks. Die + Statistik weist „In der Nähe" aus, solange nicht alles geprüft ist. +- **Umbenennen oder Löschen** einer Schematic-Datei trennt bestehende Placements dieser Datei. Beim nächsten + Betreten der Welt erscheint eine Fehlermeldung; neu platzieren behebt das. +- Kein Speichern neuer Schematics aus der Welt, keine Materialliste und kein automatisches Platzieren + (Litematicas „Easy Place"). Die Architektur ist darauf vorbereitet; die Funktionen fehlen aber noch. + +## Aus dem Quellcode bauen + +Voraussetzung: JDK 21. + +``` +gradlew build # Kompilieren + Unit-Tests → build/libs/neolitematica-1.21.1-1.0.0.jar +gradlew test # Nur Unit-Tests (Reader gegen "Another castle", Transformationen) +gradlew runClient # Entwicklungs-Client +gradlew runSmokeTest # Automatischer Ingame-Test, siehe unten +gradlew runServer # Dedizierter Server (vorher EULA in run-server/eula.txt selbst akzeptieren) +``` + +**Automatischer Ingame-Test** (`runSmokeTest`): erstellt eine Flachwelt, platziert +`ressources/Another castle.litematic`, baut über den integrierten Server einen Teil korrekt, falsch und +überzählig nach, prüft die Vergleichszahlen in allen Modi, dreht das Placement, öffnet alle Bildschirme, +speichert Screenshots nach `run/screenshots/smoke_*.png` und beendet das Spiel. Letztes Ergebnis: +`correct=3452 missing=11355 wrongBlock=78 wrongState=20 extra=25` bei 14905 Blöcken, was genau den gesetzten +Blöcken entspricht. + +## Lizenz und Herkunft + +MIT. Litematica (LGPLv3, masa) diente nur als funktionale Referenz und für das Dateiformat; es wurde kein +Code übernommen. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..3572d44 --- /dev/null +++ b/build.gradle @@ -0,0 +1,92 @@ +plugins { + id 'java-library' + id 'net.neoforged.moddev' version '2.0.148' +} + +version = mod_version +group = mod_group_id + +base { + archivesName = "${mod_id}-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +neoForge { + version = project.neo_version + + parchment { + mappingsVersion = project.parchment_mappings_version + minecraftVersion = project.parchment_minecraft_version + } + + runs { + client { + client() + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + // Dedicated server, to verify the client-only mod is harmless there. + server { + server() + programArgument '--nogui' + gameDirectory = project.file('run-server') + } + // Automated smoke test: creates a flat world, loads the sample schematic, + // takes a screenshot and quits. See de.lucas.litematica.dev.DevSmokeTest. + smokeTest { + client() + systemProperty 'neolitematica.smoketest', project.file('ressources/Another castle.litematic').absolutePath + } + } + + mods { + "${mod_id}" { + sourceSet(sourceSets.main) + } + } + + unitTest { + enable() + testedMod = mods."${mod_id}" + } +} + +test { + useJUnitPlatform() + systemProperty 'neolitematica.testSchematic', file('ressources/Another castle.litematic').absolutePath +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) { + var replaceProperties = [ + minecraft_version : minecraft_version, + minecraft_version_range: minecraft_version_range, + neo_version : neo_version, + neo_version_range : neo_version_range, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : mod_version, + mod_authors : mod_authors, + mod_description : mod_description + ] + inputs.properties replaceProperties + expand replaceProperties + from "src/main/templates" + into "build/generated/sources/modMetadata" +} +sourceSets.main.resources.srcDir generateModMetadata +neoForge.ideSyncTask generateModMetadata diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..b8e6924 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=false + +minecraft_version=1.21.1 +minecraft_version_range=[1.21.1] +neo_version=21.1.265 +neo_version_range=[21.1.0,) +loader_version_range=[4,) +parchment_minecraft_version=1.21.1 +parchment_mappings_version=2024.11.17 + +mod_id=neolitematica +mod_name=NeoLitematica +mod_license=MIT +mod_version=1.0.0 +mod_group_id=de.lucas.litematica +mod_authors=Lucas +mod_description=Client-only schematic (.litematic) viewer and building aid for NeoForge. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 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�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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ressources/Another castle.litematic b/ressources/Another castle.litematic new file mode 100644 index 0000000000000000000000000000000000000000..112423ba5e98e4e81a4babc4f2f945be23663e63 GIT binary patch literal 29090 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 S!k_OSa15h?HQbEYX#WEr8zG event.registerAboveAll(id("hud"), new HudOverlay())); + // Block models may change with resource packs; rebuild meshes afterwards. + modBus.addListener((RegisterClientReloadListenersEvent event) -> event.registerReloadListener( + (ResourceManagerReloadListener) resourceManager -> SchematicRenderer.get().markAllDirty())); + + NeoForge.EVENT_BUS.addListener((ClientTickEvent.Post event) -> { + InputHandler.tick(); + PlacementManager.get().tick(); + SchematicRenderer.get().tick(); + }); + NeoForge.EVENT_BUS.addListener((RenderLevelStageEvent event) -> SchematicRenderer.get().render(event)); + NeoForge.EVENT_BUS.addListener((ClientPlayerNetworkEvent.LoggingIn event) -> PlacementManager.get().onLogin()); + NeoForge.EVENT_BUS.addListener((ClientPlayerNetworkEvent.LoggingOut event) -> { + PlacementManager.get().onLogout(); + SchematicRenderer.get().clear(); + }); + NeoForge.EVENT_BUS.addListener((ChunkEvent.Load event) -> { + if (event.getLevel().isClientSide()) { + SchematicRenderer.get().onChunkLoaded(event.getChunk().getPos().x, event.getChunk().getPos().z); + } + }); + + if (DevSmokeTest.isEnabled()) { + DevSmokeTest.register(); + } + } + + public static ResourceLocation id(String path) { + return ResourceLocation.fromNamespaceAndPath(MOD_ID, path); + } +} diff --git a/src/main/java/de/lucas/litematica/config/ModConfig.java b/src/main/java/de/lucas/litematica/config/ModConfig.java new file mode 100644 index 0000000..f317d30 --- /dev/null +++ b/src/main/java/de/lucas/litematica/config/ModConfig.java @@ -0,0 +1,54 @@ +package de.lucas.litematica.config; + +import net.neoforged.neoforge.common.ModConfigSpec; + +/** + * Client configuration, stored by NeoForge in {@code config/neolitematica-client.toml}. Editable in-game through + * the mod's own screens and NeoForge's generic configuration screen (Mods list → NeoLitematica → Config). + */ +public final class ModConfig { + public static final ModConfigSpec SPEC; + + public static final ModConfigSpec.BooleanValue RENDERING_ENABLED; + public static final ModConfigSpec.EnumValue RENDER_MODE; + public static final ModConfigSpec.DoubleValue GHOST_ALPHA; + public static final ModConfigSpec.DoubleValue OVERLAY_ALPHA; + public static final ModConfigSpec.IntValue RENDER_DISTANCE; + public static final ModConfigSpec.BooleanValue RENDER_BLOCK_ENTITIES; + public static final ModConfigSpec.BooleanValue SHOW_BOUNDING_BOX; + public static final ModConfigSpec.BooleanValue SHOW_HUD; + public static final ModConfigSpec.IntValue BUILDER_THREADS; + + static { + ModConfigSpec.Builder b = new ModConfigSpec.Builder(); + RENDERING_ENABLED = b.comment("Show schematic placements in the world.") + .define("renderingEnabled", true); + RENDER_MODE = b.comment("ALL: show every block. MISSING_ONLY: hide correctly placed blocks, mark wrong ones.", + "VERIFY: additionally mark extra and correct blocks.") + .defineEnum("renderMode", RenderMode.MISSING_ONLY); + GHOST_ALPHA = b.comment("Opacity of ghost blocks.") + .defineInRange("ghostAlpha", 0.5, 0.15, 1.0); + OVERLAY_ALPHA = b.comment("Opacity of the coloured verification overlay.") + .defineInRange("overlayAlpha", 0.35, 0.05, 1.0); + RENDER_DISTANCE = b.comment("Schematic render distance in chunks.") + .defineInRange("renderDistance", 12, 2, 48); + RENDER_BLOCK_ENTITIES = b.comment("Render block entities such as chests, signs and beds as ghosts.") + .define("renderBlockEntities", true); + SHOW_BOUNDING_BOX = b.comment("Outline the selected placement.") + .define("showBoundingBox", true); + SHOW_HUD = b.comment("Show placement position and verification summary on screen.") + .define("showHud", true); + BUILDER_THREADS = b.comment("Background threads that build schematic meshes. Requires restart.") + .defineInRange("builderThreads", 2, 1, 8); + SPEC = b.build(); + } + + private ModConfig() { + } + + /** Updates a value and writes the config file. */ + public static void set(ModConfigSpec.ConfigValue value, T newValue) { + value.set(newValue); + value.save(); + } +} diff --git a/src/main/java/de/lucas/litematica/config/RenderMode.java b/src/main/java/de/lucas/litematica/config/RenderMode.java new file mode 100644 index 0000000..c6f0fa2 --- /dev/null +++ b/src/main/java/de/lucas/litematica/config/RenderMode.java @@ -0,0 +1,27 @@ +package de.lucas.litematica.config; + +import net.minecraft.network.chat.Component; +import net.neoforged.neoforge.common.TranslatableEnum; + +/** How placements are compared with the world while rendering. */ +public enum RenderMode implements TranslatableEnum { + /** Every schematic block is drawn as a ghost; no comparison with the world. */ + ALL, + /** Ghosts only where blocks are missing; wrong blocks are highlighted. Default building aid. */ + MISSING_ONLY, + /** Like {@link #MISSING_ONLY}, but also highlights extra and already correct blocks. */ + VERIFY; + + public boolean comparesWorld() { + return this != ALL; + } + + public RenderMode next() { + return values()[(this.ordinal() + 1) % values().length]; + } + + @Override + public Component getTranslatedName() { + return Component.translatable("neolitematica.render_mode." + this.name().toLowerCase()); + } +} diff --git a/src/main/java/de/lucas/litematica/dev/DevSmokeTest.java b/src/main/java/de/lucas/litematica/dev/DevSmokeTest.java new file mode 100644 index 0000000..852697c --- /dev/null +++ b/src/main/java/de/lucas/litematica/dev/DevSmokeTest.java @@ -0,0 +1,285 @@ +package de.lucas.litematica.dev; + +import com.mojang.logging.LogUtils; +import de.lucas.litematica.config.ModConfig; +import de.lucas.litematica.config.RenderMode; +import de.lucas.litematica.gui.MainScreen; +import de.lucas.litematica.gui.PlacementScreen; +import de.lucas.litematica.gui.SchematicBrowserScreen; +import de.lucas.litematica.placement.PlacementManager; +import de.lucas.litematica.placement.PlacementView; +import de.lucas.litematica.placement.SchematicPlacement; +import de.lucas.litematica.render.SchematicRenderer; +import de.lucas.litematica.schematic.SchematicFiles; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.Difficulty; +import net.minecraft.world.level.GameRules; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelSettings; +import net.minecraft.world.level.WorldDataConfiguration; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.WorldOptions; +import net.minecraft.world.level.levelgen.presets.WorldPresets; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.common.NeoForge; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.stream.Stream; + +/** + * Development-only end-to-end test, enabled by the {@code neolitematica.smoketest} system property (Gradle task + * {@code runSmokeTest}). It creates a flat creative world, places the given schematic, edits the world to produce + * correct and wrong blocks, takes screenshots of the rendering and every screen, logs the comparison results and + * quits. Never active in normal play. + */ +public final class DevSmokeTest { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final String PROPERTY = "neolitematica.smoketest"; + private static final String WORLD = "neolitematica_smoketest"; + + private static final int TIMEOUT_TICKS = 20 * 60 * 4; + + private static int totalTicks; + private static boolean worldRequested; + private static int step; + private static int wait; + private static SchematicPlacement placement; + + private DevSmokeTest() { + } + + public static boolean isEnabled() { + return System.getProperty(PROPERTY) != null; + } + + public static void register() { + NeoForge.EVENT_BUS.addListener((ClientTickEvent.Post event) -> { + try { + tick(); + } catch (Exception e) { + LOGGER.error("SMOKETEST FAILED with exception", e); + Minecraft.getInstance().stop(); + } + }); + } + + private static void tick() throws IOException { + Minecraft mc = Minecraft.getInstance(); + if (++totalTicks > TIMEOUT_TICKS && step < 100) { + LOGGER.error("SMOKETEST FAILED: timed out in step {} (screen {}, player {})", step, mc.screen, mc.player); + step = 100; + mc.stop(); + return; + } + if (!worldRequested) { + // The first launch shows the accessibility onboarding screen instead of the title screen. + if (mc.screen != null && mc.level == null && mc.getOverlay() == null) { + worldRequested = true; + createWorld(mc); + } + return; + } + if (mc.player == null || mc.level == null) { + return; + } + if (!mc.player.getAbilities().flying) { + fly(mc); + } + if (wait > 0) { + wait--; + return; + } + PlacementManager manager = PlacementManager.get(); + switch (step) { + case 0 -> { + Path source = Path.of(System.getProperty(PROPERTY)); + Path target = SchematicFiles.directory().resolve(source.getFileName()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + ModConfig.set(ModConfig.RENDER_MODE, RenderMode.MISSING_ONLY); + BlockPos feet = mc.player.blockPosition(); + placement = manager.place(target, feet.offset(8, 0, 8)); + LOGGER.info("SMOKETEST placed {} at {}", target.getFileName(), placement.origin()); + command(mc, "time set day"); + command(mc, "gamerule doDaylightCycle false"); + command(mc, "weather clear"); + BlockPos o = placement.origin(); + fly(mc); + command(mc, String.format("tp @s %d %d %d -45 30", o.getX() - 24, o.getY() + 40, o.getZ() - 24)); + next(60); + } + case 1 -> { + if (placement.schematic() == null || SchematicRenderer.get().pendingSections() > 0) return; + logStats("initial (empty world)"); + screenshot(mc, "smoke_1_missing_only"); + next(5); + } + case 2 -> { + buildPartOfSchematic(mc); + BlockPos o = placement.origin(); + command(mc, String.format("tp @s %d %d %d -45 35", o.getX() - 12, o.getY() + 30, o.getZ() - 12)); + next(40); + } + case 3 -> { + if (SchematicRenderer.get().pendingSections() > 0) return; + logStats("after building part"); + screenshot(mc, "smoke_2_missing_only_partly_built"); + ModConfig.set(ModConfig.RENDER_MODE, RenderMode.VERIFY); + next(40); + } + case 4 -> { + if (SchematicRenderer.get().pendingSections() > 0) return; + logStats("verify mode"); + screenshot(mc, "smoke_3_verify"); + ModConfig.set(ModConfig.RENDER_MODE, RenderMode.ALL); + manager.setTransform(placement, placement.transform().rotatedClockwise()); + next(40); + } + case 5 -> { + if (SchematicRenderer.get().pendingSections() > 0) return; + screenshot(mc, "smoke_4_all_rotated90"); + fly(mc); + command(mc, String.format("tp @s %d %d %d 160 20", placement.origin().getX() + 52, placement.origin().getY() + 22, + placement.origin().getZ() + 96)); + next(60); + } + case 6 -> { + if (SchematicRenderer.get().pendingSections() > 0) return; + screenshot(mc, "smoke_5_closeup"); + mc.setScreen(new MainScreen(null)); + next(20); + } + case 7 -> { + screenshot(mc, "smoke_6_main_screen"); + mc.setScreen(new PlacementScreen(null, placement)); + next(20); + } + case 8 -> { + screenshot(mc, "smoke_7_placement_screen"); + mc.setScreen(new SchematicBrowserScreen(null)); + next(20); + } + case 9 -> { + screenshot(mc, "smoke_8_browser_screen"); + mc.setScreen(null); + LOGGER.info("SMOKETEST PASSED"); + mc.stop(); + step = 100; + } + default -> { + } + } + } + + private static void next(int waitTicks) { + step++; + wait = waitTicks; + } + + /** + * Places the bottom four layers of the schematic correctly through the integrated server, and puts glass where + * the next layer's blocks belong in one quarter, to produce correct and wrong blocks. + */ + private static void buildPartOfSchematic(Minecraft mc) { + PlacementView view = placement.view(); + ServerLevel level = mc.getSingleplayerServer().getLevel(Level.OVERWORLD); + BlockPos.MutableBlockPos scratch = new BlockPos.MutableBlockPos(); + var box = view.box(); + int lowest = box.minY(); + search: + for (; lowest <= box.maxY(); lowest++) { + for (int z = box.minZ(); z <= box.maxZ(); z++) { + for (int x = box.minX(); x <= box.maxX(); x++) { + if (!view.getState(x, lowest, z, scratch).isAir()) break search; + } + } + } + int base = lowest; + mc.getSingleplayerServer().execute(() -> { + int correct = 0, wrong = 0, wrongState = 0, extra = 0; + for (int y = base; y < base + 5; y++) { + for (int z = box.minZ(); z <= box.maxZ(); z++) { + for (int x = box.minX(); x <= box.maxX(); x++) { + BlockPos pos = new BlockPos(x, y, z); + BlockState state = view.getState(x, y, z, scratch); + if (state.isAir()) continue; + if (y < base + 4) { + BlockState rotated = state.rotate(net.minecraft.world.level.block.Rotation.CLOCKWISE_90); + if (wrongState < 20 && rotated != state) { + level.setBlock(pos, rotated, 2 | 16); + wrongState++; + } else { + level.setBlock(pos, state, 2 | 16); + correct++; + } + } else if (x < box.minX() + 32 && z < box.minZ() + 40) { + level.setBlock(pos, Blocks.GLASS.defaultBlockState(), 2 | 16); + wrong++; + } + } + } + } + // Blocks where the schematic has air: a small stone platform below the building. + for (int z = box.minZ(); z < box.minZ() + 5; z++) { + for (int x = box.minX(); x < box.minX() + 5; x++) { + if (view.getState(x, box.minY() + 1, z, scratch).isAir()) { + level.setBlock(new BlockPos(x, box.minY() + 1, z), Blocks.STONE.defaultBlockState(), 2 | 16); + extra++; + } + } + } + LOGGER.info("SMOKETEST built from y={}: {} correct, {} wrong block, {} wrong state, {} extra", base, correct, wrong, wrongState, extra); + }); + } + + private static void logStats(String label) { + var s = placement.stats(); + LOGGER.info("SMOKETEST stats [{}]: correct={} missing={} wrongBlock={} wrongState={} extra={} unknown={} complete={} blocks={}", + label, s.correct(), s.missing(), s.wrongBlock(), s.wrongState(), s.extra(), s.unknown(), s.complete(), + placement.schematic().nonAirBlocks()); + } + + private static void screenshot(Minecraft mc, String name) { + Screenshot.grab(mc.gameDirectory, name + ".png", mc.getMainRenderTarget(), + message -> LOGGER.info("SMOKETEST screenshot {}: {}", name, message.getString())); + } + + /** Starts creative flight and tells the server, so the player does not fall after teleporting. */ + private static void fly(Minecraft mc) { + mc.player.getAbilities().flying = true; + mc.player.onUpdateAbilities(); + } + + private static void command(Minecraft mc, String command) { + mc.player.connection.sendCommand(command); + } + + private static void createWorld(Minecraft mc) throws IOException { + Path dir = mc.gameDirectory.toPath().resolve("saves").resolve(WORLD); + if (Files.exists(dir)) { + try (Stream walk = Files.walk(dir)) { + for (Path p : walk.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(p); + } + } + } + GameRules rules = new GameRules(); + rules.getRule(GameRules.RULE_DOMOBSPAWNING).set(false, null); + LevelSettings settings = new LevelSettings(WORLD, GameType.CREATIVE, false, Difficulty.PEACEFUL, true, rules, WorldDataConfiguration.DEFAULT); + WorldOptions options = new WorldOptions(12345L, false, false); + LOGGER.info("SMOKETEST creating world {}", WORLD); + mc.createWorldOpenFlows().createFreshLevel(WORLD, settings, options, + registries -> registries.registryOrThrow(Registries.WORLD_PRESET).getHolderOrThrow(WorldPresets.FLAT).value().createWorldDimensions(), + mc.screen); + } +} diff --git a/src/main/java/de/lucas/litematica/gui/HudOverlay.java b/src/main/java/de/lucas/litematica/gui/HudOverlay.java new file mode 100644 index 0000000..fd68b8c --- /dev/null +++ b/src/main/java/de/lucas/litematica/gui/HudOverlay.java @@ -0,0 +1,64 @@ +package de.lucas.litematica.gui; + +import de.lucas.litematica.config.ModConfig; +import de.lucas.litematica.input.TransformText; +import de.lucas.litematica.placement.PlacementManager; +import de.lucas.litematica.placement.SchematicPlacement; +import de.lucas.litematica.placement.VerificationStats; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.LayeredDraw; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Vec3i; +import net.minecraft.network.chat.Component; + +import java.util.ArrayList; +import java.util.List; + +/** Small info panel (top left) for the selected placement: position, size, transform and comparison results. */ +public final class HudOverlay implements LayeredDraw.Layer { + @Override + public void render(GuiGraphics graphics, DeltaTracker deltaTracker) { + Minecraft mc = Minecraft.getInstance(); + if (!ModConfig.SHOW_HUD.get() || mc.options.hideGui || mc.getDebugOverlay().showDebugScreen() || mc.level == null) { + return; + } + SchematicPlacement p = PlacementManager.get().selected(); + if (p == null || !p.dimension().equals(mc.level.dimension().location())) { + return; + } + + List lines = new ArrayList<>(); + lines.add(Component.translatable("neolitematica.hud.title", p.name()).withStyle(s -> s.withColor(0x7FD8FF))); + if (!ModConfig.RENDERING_ENABLED.get()) { + lines.add(Component.translatable("neolitematica.hud.hidden").withStyle(s -> s.withColor(0xAAAAAA))); + } else if (!p.enabled()) { + lines.add(Component.translatable("neolitematica.hud.disabled").withStyle(s -> s.withColor(0xAAAAAA))); + } + if (p.loadError() != null) { + lines.add(Component.literal(p.loadError()).withStyle(s -> s.withColor(0xFF5555))); + } else if (p.schematic() == null) { + lines.add(Component.translatable("neolitematica.hud.loading")); + } else { + BlockPos o = p.origin(); + Vec3i size = p.placedSize(); + lines.add(Component.translatable("neolitematica.hud.position", o.getX(), o.getY(), o.getZ())); + lines.add(Component.translatable("neolitematica.hud.size", size.getX(), size.getY(), size.getZ(), + p.transform().degrees(), TransformText.mirrors(p.transform()))); + VerificationStats s = p.stats(); + if (ModConfig.RENDER_MODE.get().comparesWorld() && s.total() > 0) { + lines.add(Component.translatable("neolitematica.hud.stats", s.missing(), s.wrongBlock() + s.wrongState(), + s.correct(), s.total())); + } + } + + int y = 4; + for (Component line : lines) { + int width = mc.font.width(line); + graphics.fill(2, y - 1, 6 + width, y + 9, 0x90000000); + graphics.drawString(mc.font, line, 4, y, 0xFFFFFF, false); + y += 10; + } + } +} diff --git a/src/main/java/de/lucas/litematica/gui/MainScreen.java b/src/main/java/de/lucas/litematica/gui/MainScreen.java new file mode 100644 index 0000000..7a3897e --- /dev/null +++ b/src/main/java/de/lucas/litematica/gui/MainScreen.java @@ -0,0 +1,290 @@ +package de.lucas.litematica.gui; + +import de.lucas.litematica.NeoLitematica; +import de.lucas.litematica.config.ModConfig; +import de.lucas.litematica.config.RenderMode; +import de.lucas.litematica.placement.PlacementManager; +import de.lucas.litematica.placement.SchematicPlacement; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractSliderButton; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.ObjectSelectionList; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.options.controls.KeyBindsScreen; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Vec3i; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Mth; +import net.neoforged.fml.ModList; +import net.neoforged.neoforge.client.gui.ConfigurationScreen; +import org.jetbrains.annotations.Nullable; + +/** Overview of all placements in this dimension plus quick render settings. Opened with Alt+M. */ +public class MainScreen extends Screen { + private static final int SIDE_WIDTH = 150; + + @Nullable + private final Screen parent; + private PlacementList list; + private Button editButton; + private Button toggleButton; + private Button moveHereButton; + private Button removeButton; + + public MainScreen(@Nullable Screen parent) { + super(Component.translatable("neolitematica.screen.main.title")); + this.parent = parent; + } + + @Override + protected void init() { + int bottom = this.height - 80; + int listWidth = this.width - SIDE_WIDTH - 30; + this.list = new PlacementList(this.minecraft, listWidth, bottom - 26, 24); + this.list.setX(10); + this.addRenderableWidget(this.list); + + int sx = this.width - SIDE_WIDTH - 10; + int y = 24; + this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.load_schematic"), + b -> this.minecraft.setScreen(new SchematicBrowserScreen(this))).bounds(sx, y, SIDE_WIDTH, 20).build()); + y += 28; + this.editButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.edit"), + b -> this.withSelected(p -> this.minecraft.setScreen(new PlacementScreen(this, p)))).bounds(sx, y, SIDE_WIDTH, 20).build()); + y += 22; + this.toggleButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.toggle_placement"), + b -> this.withSelected(p -> PlacementManager.get().setEnabled(p, !p.enabled()))).bounds(sx, y, SIDE_WIDTH, 20).build()); + y += 22; + this.moveHereButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.move_here"), + b -> this.withSelected(p -> PlacementManager.get().setOrigin(p, this.minecraft.player.blockPosition()))) + .tooltip(Tooltip.create(Component.translatable("neolitematica.button.move_here.tooltip"))) + .bounds(sx, y, SIDE_WIDTH, 20).build()); + y += 22; + this.removeButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.remove"), + b -> this.withSelected(this::confirmRemove)).bounds(sx, y, SIDE_WIDTH, 20).build()); + + int cx = this.width / 2; + int row = bottom + 6; + this.addRenderableWidget(CycleButton.onOffBuilder(ModConfig.RENDERING_ENABLED.get()) + .create(cx - 155, row, 150, 20, Component.translatable("neolitematica.option.rendering"), + (b, value) -> ModConfig.set(ModConfig.RENDERING_ENABLED, value))); + this.addRenderableWidget(CycleButton.builder(RenderMode::getTranslatedName) + .withValues(RenderMode.values()) + .withInitialValue(ModConfig.RENDER_MODE.get()) + .withTooltip(mode -> Tooltip.create(Component.translatable("neolitematica.render_mode." + mode.name().toLowerCase() + ".tooltip"))) + .create(cx + 5, row, 150, 20, Component.translatable("neolitematica.option.render_mode"), + (b, value) -> ModConfig.set(ModConfig.RENDER_MODE, value))); + row += 22; + this.addRenderableWidget(new ConfigSlider(cx - 155, row, "neolitematica.option.ghost_alpha", + 0.15, 1.0, ModConfig.GHOST_ALPHA.get(), true) { + @Override + protected void apply(double value) { + ModConfig.GHOST_ALPHA.set(value); + } + }); + this.addRenderableWidget(new ConfigSlider(cx + 5, row, "neolitematica.option.render_distance", + 2, 48, ModConfig.RENDER_DISTANCE.get(), false) { + @Override + protected void apply(double value) { + ModConfig.RENDER_DISTANCE.set((int) Math.round(value)); + } + }); + row += 22; + this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.more_settings"), b -> this.openConfig()) + .bounds(cx - 155, row, 100, 20).build()); + this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.controls"), + b -> this.minecraft.setScreen(new KeyBindsScreen(this, this.minecraft.options))).bounds(cx - 50, row, 100, 20).build()); + this.addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> this.onClose()) + .bounds(cx + 55, row, 100, 20).build()); + + this.list.refresh(); + this.updateButtons(); + } + + private void openConfig() { + ModList.get().getModContainerById(NeoLitematica.MOD_ID).ifPresent(container -> + this.minecraft.setScreen(new ConfigurationScreen(container, this))); + } + + private void confirmRemove(SchematicPlacement placement) { + this.minecraft.setScreen(new ConfirmScreen(confirmed -> { + if (confirmed) { + PlacementManager.get().remove(placement); + } + this.minecraft.setScreen(this); + }, Component.translatable("neolitematica.screen.remove.title"), + Component.translatable("neolitematica.screen.remove.message", placement.name()))); + } + + private void withSelected(java.util.function.Consumer action) { + PlacementList.Entry entry = this.list.getSelected(); + if (entry != null) { + action.accept(entry.placement); + this.updateButtons(); + } + } + + private void updateButtons() { + boolean selected = this.list.getSelected() != null; + this.editButton.active = selected; + this.toggleButton.active = selected; + this.moveHereButton.active = selected && this.minecraft.player != null; + this.removeButton.active = selected; + } + + @Override + public void tick() { + this.updateButtons(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.render(graphics, mouseX, mouseY, partialTick); + graphics.drawCenteredString(this.font, this.title, this.width / 2, 8, 0xFFFFFF); + if (this.list.children().isEmpty()) { + graphics.drawCenteredString(this.font, Component.translatable("neolitematica.screen.main.empty"), + 10 + this.list.getWidth() / 2, 60, 0xA0A0A0); + } + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public void onClose() { + this.minecraft.setScreen(this.parent); + } + + /** List of placements in the current dimension. */ + class PlacementList extends ObjectSelectionList { + PlacementList(Minecraft minecraft, int width, int height, int y) { + super(minecraft, width, height, y, 24); + } + + void refresh() { + this.clearEntries(); + SchematicPlacement selected = PlacementManager.get().selected(); + for (SchematicPlacement p : PlacementManager.get().inCurrentDimension()) { + Entry entry = new Entry(p); + this.addEntry(entry); + if (p == selected) { + this.setSelected(entry); + } + } + } + + @Override + public void setSelected(@Nullable Entry entry) { + super.setSelected(entry); + if (entry != null) { + PlacementManager.get().select(entry.placement); + } + MainScreen.this.updateButtons(); + } + + @Override + public int getRowWidth() { + return this.width - 16; + } + + @Override + protected int getScrollbarPosition() { + return this.getX() + this.width - 6; + } + + class Entry extends ObjectSelectionList.Entry { + final SchematicPlacement placement; + private long lastClick; + + Entry(SchematicPlacement placement) { + this.placement = placement; + } + + @Override + public void render(GuiGraphics g, int index, int top, int left, int width, int height, int mouseX, int mouseY, + boolean hovering, float partialTick) { + SchematicPlacement p = this.placement; + int nameColor = p.enabled() ? 0xFFFFFF : 0x808080; + String marker = p.enabled() ? "● " : "○ "; + g.drawString(MainScreen.this.font, marker + p.name(), left + 2, top + 2, nameColor, false); + Component detail; + if (p.loadError() != null) { + detail = Component.literal(p.loadError()).withStyle(ChatFormatting.RED); + } else if (p.schematic() == null) { + detail = Component.translatable("neolitematica.hud.loading").withStyle(ChatFormatting.GRAY); + } else { + BlockPos o = p.origin(); + Vec3i s = p.placedSize(); + detail = Component.translatable("neolitematica.list.placement_detail", o.getX(), o.getY(), o.getZ(), + s.getX(), s.getY(), s.getZ(), p.transform().degrees()).withStyle(ChatFormatting.GRAY); + } + g.drawString(MainScreen.this.font, MainScreen.this.font.substrByWidth(detail, width - 6).getString(), + left + 2, top + 12, 0xA0A0A0, false); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + long now = net.minecraft.Util.getMillis(); + if (now - this.lastClick < 250L && PlacementList.this.getSelected() == this) { + MainScreen.this.minecraft.setScreen(new PlacementScreen(MainScreen.this, this.placement)); + } + this.lastClick = now; + PlacementList.this.setSelected(this); + return true; + } + + @Override + public Component getNarration() { + return Component.literal(this.placement.name()); + } + } + } + + /** Slider bound to a numeric config value; applies live while dragging and writes the file when released. */ + abstract static class ConfigSlider extends AbstractSliderButton { + private final String key; + private final double min; + private final double max; + private final boolean percent; + + ConfigSlider(int x, int y, String key, double min, double max, double value, boolean percent) { + super(x, y, 150, 20, Component.empty(), (value - min) / (max - min)); + this.key = key; + this.min = min; + this.max = max; + this.percent = percent; + this.updateMessage(); + } + + private double current() { + return this.min + Mth.clamp(this.value, 0.0, 1.0) * (this.max - this.min); + } + + @Override + protected void updateMessage() { + double v = this.current(); + String text = this.percent ? Math.round(v * 100) + "%" : String.valueOf(Math.round(v)); + this.setMessage(Component.translatable(this.key, text)); + } + + @Override + protected void applyValue() { + this.apply(this.current()); + } + + @Override + public void onRelease(double mouseX, double mouseY) { + super.onRelease(mouseX, mouseY); + ModConfig.SPEC.save(); + } + + protected abstract void apply(double value); + } +} diff --git a/src/main/java/de/lucas/litematica/gui/PlacementScreen.java b/src/main/java/de/lucas/litematica/gui/PlacementScreen.java new file mode 100644 index 0000000..f8d67c2 --- /dev/null +++ b/src/main/java/de/lucas/litematica/gui/PlacementScreen.java @@ -0,0 +1,247 @@ +package de.lucas.litematica.gui; + +import de.lucas.litematica.placement.PlacementManager; +import de.lucas.litematica.placement.PlacementTransform; +import de.lucas.litematica.placement.SchematicPlacement; +import de.lucas.litematica.placement.VerificationStats; +import de.lucas.litematica.schematic.LitematicSchematic; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Vec3i; +import net.minecraft.network.chat.Component; +import net.minecraft.world.level.block.Rotation; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Edits one placement: position, rotation, mirroring, name. Changes apply immediately and the screen does not + * blur or pause the game, so the blueprint can be watched moving in the world. + */ +public class PlacementScreen extends Screen { + private static final int COLUMN = 150; + + @Nullable + private final Screen parent; + private final SchematicPlacement placement; + private final EditBox[] coords = new EditBox[3]; + private EditBox nameBox; + private int panelTop; + private int panelBottom; + + public PlacementScreen(@Nullable Screen parent, SchematicPlacement placement) { + super(Component.translatable("neolitematica.screen.placement.title")); + this.parent = parent; + this.placement = placement; + } + + @Override + protected void init() { + PlacementManager manager = PlacementManager.get(); + int left = this.width / 2 - COLUMN - 5; + int right = this.width / 2 + 5; + this.panelTop = Math.max(4, this.height / 2 - 118); + int y = this.panelTop + 18; + + // Left column: name and position. + this.nameBox = new EditBox(this.font, left, y, COLUMN, 20, Component.translatable("neolitematica.label.name")); + this.nameBox.setMaxLength(64); + this.nameBox.setValue(this.placement.name()); + this.nameBox.setResponder(name -> manager.rename(this.placement, name)); + this.addRenderableWidget(this.nameBox); + + Direction.Axis[] axes = {Direction.Axis.X, Direction.Axis.Y, Direction.Axis.Z}; + for (int i = 0; i < 3; i++) { + this.addCoordinateRow(axes[i], i, left, y + 24 + i * 22); + } + int ly = y + 24 + 3 * 22; + this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.move_here"), b -> { + if (this.minecraft.player != null) manager.setOrigin(this.placement, this.minecraft.player.blockPosition()); + }).tooltip(Tooltip.create(Component.translatable("neolitematica.button.move_here.tooltip"))).bounds(left, ly, COLUMN, 20).build()); + ly += 22; + this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.reset_position"), + b -> manager.resetOrigin(this.placement)) + .tooltip(Tooltip.create(Component.translatable("neolitematica.button.reset_position.tooltip"))) + .bounds(left, ly, COLUMN, 20).build()); + ly += 22; + this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.store_origin"), + b -> manager.storeOriginAsOriginal(this.placement)) + .tooltip(Tooltip.create(Component.translatable("neolitematica.button.store_origin.tooltip"))) + .bounds(left, ly, COLUMN, 20).build()); + + // Right column: transform. + int ry = y; + PlacementTransform t = this.placement.transform(); + this.addRenderableWidget(CycleButton.builder(r -> Component.literal(degrees(r) + "°")) + .withValues(Rotation.NONE, Rotation.CLOCKWISE_90, Rotation.CLOCKWISE_180, Rotation.COUNTERCLOCKWISE_90) + .withInitialValue(t.rotation()) + .create(right, ry, COLUMN, 20, Component.translatable("neolitematica.option.rotation"), + (b, rotation) -> manager.setTransform(this.placement, this.placement.transform().withRotation(rotation)))); + ry += 24; + this.addRenderableWidget(CycleButton.onOffBuilder(t.mirrorX()) + .withTooltip(v -> Tooltip.create(Component.translatable("neolitematica.option.mirror_x.tooltip"))) + .create(right, ry, COLUMN, 20, Component.translatable("neolitematica.option.mirror_x"), + (b, v) -> manager.setTransform(this.placement, this.placement.transform().withMirrorX(v)))); + ry += 22; + this.addRenderableWidget(CycleButton.onOffBuilder(t.mirrorZ()) + .withTooltip(v -> Tooltip.create(Component.translatable("neolitematica.option.mirror_z.tooltip"))) + .create(right, ry, COLUMN, 20, Component.translatable("neolitematica.option.mirror_z"), + (b, v) -> manager.setTransform(this.placement, this.placement.transform().withMirrorZ(v)))); + ry += 22; + this.addRenderableWidget(CycleButton.onOffBuilder(t.mirrorY()) + .withTooltip(v -> Tooltip.create(Component.translatable("neolitematica.option.mirror_y.tooltip"))) + .create(right, ry, COLUMN, 20, Component.translatable("neolitematica.option.mirror_y"), + (b, v) -> manager.setTransform(this.placement, this.placement.transform().withMirrorY(v)))); + ry += 22; + this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.reset_transform"), b -> { + manager.setTransform(this.placement, PlacementTransform.IDENTITY); + this.rebuildWidgets(); + }).bounds(right, ry, COLUMN, 20).build()); + ry += 24; + this.addRenderableWidget(CycleButton.onOffBuilder(this.placement.enabled()) + .create(right, ry, COLUMN, 20, Component.translatable("neolitematica.option.visible"), + (b, v) -> manager.setEnabled(this.placement, v))); + + this.panelBottom = Math.max(ly, ry) + 24 + 4 * 10 + 6; + this.addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> this.onClose()) + .bounds(this.width / 2 - 100, Math.min(this.height - 24, this.panelBottom + 4), 200, 20).build()); + } + + private void addCoordinateRow(Direction.Axis axis, int index, int x, int y) { + int[] steps = {-10, -1, 1, 10}; + int[] widths = {26, 20, 20, 26}; + int boxWidth = COLUMN - 12 - (26 + 20 + 20 + 26) - 4; + int cx = x + 12; + for (int i = 0; i < 2; i++) { + int step = steps[i]; + this.addRenderableWidget(Button.builder(Component.literal(String.valueOf(step)), b -> this.nudge(axis, step)) + .bounds(cx, y, widths[i], 20).build()); + cx += widths[i]; + } + EditBox box = new EditBox(this.font, cx + 2, y, boxWidth, 20, Component.literal(axis.getName())); + box.setFilter(s -> s.matches("-?\\d{0,8}")); + box.setValue(String.valueOf(axis.choose(this.placement.origin().getX(), this.placement.origin().getY(), this.placement.origin().getZ()))); + box.setResponder(text -> this.applyCoordinate(axis, text)); + this.coords[index] = box; + this.addRenderableWidget(box); + cx += boxWidth + 4; + for (int i = 2; i < 4; i++) { + int step = steps[i]; + this.addRenderableWidget(Button.builder(Component.literal("+" + step), b -> this.nudge(axis, step)) + .bounds(cx, y, widths[i], 20).build()); + cx += widths[i]; + } + } + + private void nudge(Direction.Axis axis, int amount) { + int step = hasShiftDown() ? amount * 10 : amount; + PlacementManager.get().move(this.placement, + axis == Direction.Axis.X ? step : 0, axis == Direction.Axis.Y ? step : 0, axis == Direction.Axis.Z ? step : 0); + } + + private void applyCoordinate(Direction.Axis axis, String text) { + if (text.isEmpty() || text.equals("-")) { + return; + } + int value; + try { + value = Integer.parseInt(text); + } catch (NumberFormatException e) { + return; + } + BlockPos o = this.placement.origin(); + BlockPos target = switch (axis) { + case X -> new BlockPos(value, o.getY(), o.getZ()); + case Y -> new BlockPos(o.getX(), value, o.getZ()); + case Z -> new BlockPos(o.getX(), o.getY(), value); + }; + PlacementManager.get().setOrigin(this.placement, target); + } + + @Override + public void tick() { + // Keep the fields in sync with changes from buttons or hotkeys, but never overwrite what is being typed. + BlockPos o = this.placement.origin(); + int[] values = {o.getX(), o.getY(), o.getZ()}; + for (int i = 0; i < 3; i++) { + EditBox box = this.coords[i]; + if (!box.isFocused() && !box.getValue().equals(String.valueOf(values[i]))) { + box.setValue(String.valueOf(values[i])); + } + } + } + + @Override + public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + if (this.minecraft.level == null) { + super.renderBackground(graphics, mouseX, mouseY, partialTick); + return; + } + // Only a dark panel behind the controls, so the placement stays visible in the world. + graphics.fill(this.width / 2 - COLUMN - 12, this.panelTop - 2, this.width / 2 + COLUMN + 12, this.panelBottom, 0xA0000000); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.render(graphics, mouseX, mouseY, partialTick); + graphics.drawCenteredString(this.font, this.title, this.width / 2, this.panelTop + 4, 0xFFFFFF); + int left = this.width / 2 - COLUMN - 5; + String[] labels = {"X", "Y", "Z"}; + for (int i = 0; i < 3; i++) { + graphics.drawString(this.font, labels[i], left + 2, this.coords[i].getY() + 6, 0xFFFFFF); + } + int y = this.panelBottom - 4 * 10 - 2; + for (Component line : this.infoLines()) { + graphics.drawString(this.font, line, left, y, 0xFFFFFF, false); + y += 10; + } + } + + private List infoLines() { + List lines = new ArrayList<>(); + LitematicSchematic schematic = this.placement.schematic(); + lines.add(Component.translatable("neolitematica.info.file", this.placement.schematicFile()).withStyle(ChatFormatting.GRAY)); + if (this.placement.loadError() != null) { + lines.add(Component.literal(this.placement.loadError()).withStyle(ChatFormatting.RED)); + return lines; + } + if (schematic == null) { + lines.add(Component.translatable("neolitematica.hud.loading")); + return lines; + } + Vec3i size = this.placement.placedSize(); + lines.add(Component.translatable("neolitematica.info.size_blocks", size.getX(), size.getY(), size.getZ(), schematic.nonAirBlocks())); + if (!schematic.unknownBlocks().isEmpty()) { + lines.add(Component.translatable("neolitematica.info.unknown", schematic.unknownBlocks().size(), + String.join(", ", schematic.unknownBlocks())).withStyle(ChatFormatting.LIGHT_PURPLE)); + } + VerificationStats s = this.placement.stats(); + if (s.total() > 0) { + lines.add(Component.translatable(s.complete() ? "neolitematica.info.stats" : "neolitematica.info.stats_partial", + s.correct(), s.missing(), s.wrongBlock(), s.wrongState(), s.extra())); + } + return lines; + } + + private static int degrees(Rotation rotation) { + return PlacementTransform.IDENTITY.withRotation(rotation).degrees(); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public void onClose() { + this.minecraft.setScreen(this.parent); + } +} diff --git a/src/main/java/de/lucas/litematica/gui/SchematicBrowserScreen.java b/src/main/java/de/lucas/litematica/gui/SchematicBrowserScreen.java new file mode 100644 index 0000000..2d97c10 --- /dev/null +++ b/src/main/java/de/lucas/litematica/gui/SchematicBrowserScreen.java @@ -0,0 +1,385 @@ +package de.lucas.litematica.gui; + +import com.mojang.logging.LogUtils; +import de.lucas.litematica.placement.PlacementManager; +import de.lucas.litematica.schematic.SchematicFiles; +import de.lucas.litematica.schematic.SchematicLoadException; +import de.lucas.litematica.schematic.SchematicManager; +import de.lucas.litematica.schematic.SchematicMetadata; +import net.minecraft.ChatFormatting; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.ObjectSelectionList; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; +import org.jetbrains.annotations.Nullable; +import org.lwjgl.PointerBuffer; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.util.tinyfd.TinyFileDialogs; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Lists the .litematic files in {@code .minecraft/schematics}, shows their details and places them. + * Files can be imported with the system file dialog or by dragging them onto the window. + */ +public class SchematicBrowserScreen extends Screen { + private static final Logger LOGGER = LogUtils.getLogger(); + + @Nullable + private final Screen parent; + private final Map metadata = new HashMap<>(); + private FileList list; + private Button placeButton; + private Button renameButton; + private Button deleteButton; + @Nullable + private Component status; + private boolean dialogOpen; + + public SchematicBrowserScreen(@Nullable Screen parent) { + super(Component.translatable("neolitematica.screen.browser.title")); + this.parent = parent; + } + + @Override + protected void init() { + int listWidth = Math.max(160, this.width / 2 - 10); + this.list = new FileList(this.minecraft, listWidth, this.height - 24 - 58, 24); + this.list.setX(10); + this.addRenderableWidget(this.list); + + int cx = this.width / 2; + int row = this.height - 52; + this.placeButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.place"), b -> this.placeSelected()) + .tooltip(Tooltip.create(Component.translatable("neolitematica.button.place.tooltip"))) + .bounds(cx - 155, row, 100, 20).build()); + this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.import"), b -> this.openImportDialog()) + .tooltip(Tooltip.create(Component.translatable("neolitematica.button.import.tooltip"))) + .bounds(cx - 50, row, 100, 20).build()); + this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.open_folder"), + b -> Util.getPlatform().openPath(SchematicFiles.directory())).bounds(cx + 55, row, 100, 20).build()); + row += 24; + this.renameButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.rename"), b -> this.renameSelected()) + .bounds(cx - 155, row, 74, 20).build()); + this.deleteButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.delete"), b -> this.deleteSelected()) + .bounds(cx - 77, row, 74, 20).build()); + this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.refresh"), b -> this.refresh()) + .bounds(cx + 1, row, 74, 20).build()); + this.addRenderableWidget(Button.builder(Component.translatable("gui.back"), b -> this.onClose()) + .bounds(cx + 79, row, 76, 20).build()); + + this.refresh(); + } + + private void refresh() { + Path previous = this.selectedFile(); + this.list.clear(); + try { + for (Path file : SchematicFiles.list()) { + FileList.Entry entry = this.list.add(file); + if (file.equals(previous)) { + this.list.setSelected(entry); + } + } + } catch (IOException e) { + this.status = Component.translatable("neolitematica.message.list_failed", e.getMessage()).withStyle(ChatFormatting.RED); + } + this.updateButtons(); + } + + @Nullable + private Path selectedFile() { + FileList.Entry entry = this.list == null ? null : this.list.getSelected(); + return entry == null ? null : entry.file; + } + + private void updateButtons() { + boolean selected = this.selectedFile() != null; + this.placeButton.active = selected && this.minecraft.player != null; + this.renameButton.active = selected; + this.deleteButton.active = selected; + } + + private void placeSelected() { + Path file = this.selectedFile(); + if (file == null || this.minecraft.player == null) { + return; + } + PlacementManager.get().place(file, this.minecraft.player.blockPosition()); + this.minecraft.setScreen(null); + } + + private void renameSelected() { + Path file = this.selectedFile(); + if (file == null) { + return; + } + this.minecraft.setScreen(new TextInputScreen(this, Component.translatable("neolitematica.screen.rename.title"), + SchematicFiles.baseName(file), name -> { + try { + SchematicFiles.rename(file, name); + this.status = Component.translatable("neolitematica.message.renamed", name); + } catch (IOException e) { + this.status = Component.translatable("neolitematica.message.rename_failed", e.getMessage()).withStyle(ChatFormatting.RED); + } + })); + } + + private void deleteSelected() { + Path file = this.selectedFile(); + if (file == null) { + return; + } + this.minecraft.setScreen(new ConfirmScreen(confirmed -> { + if (confirmed) { + try { + SchematicFiles.delete(file); + SchematicManager.get().invalidate(file); + this.metadata.remove(file); + this.status = Component.translatable("neolitematica.message.deleted", file.getFileName().toString()); + } catch (IOException e) { + this.status = Component.translatable("neolitematica.message.delete_failed", e.getMessage()).withStyle(ChatFormatting.RED); + } + } + this.minecraft.setScreen(this); + }, Component.translatable("neolitematica.screen.delete.title"), + Component.translatable("neolitematica.screen.delete.message", file.getFileName().toString()))); + } + + /** Opens the native file dialog on a separate thread so the game keeps rendering. */ + private void openImportDialog() { + if (this.dialogOpen) { + return; + } + this.dialogOpen = true; + String title = Component.translatable("neolitematica.button.import").getString(); + String start = System.getProperty("user.home") + java.io.File.separator; + Thread thread = new Thread(() -> { + String result; + try (MemoryStack stack = MemoryStack.stackPush()) { + PointerBuffer filters = stack.mallocPointer(1); + filters.put(stack.UTF8("*.litematic")); + filters.flip(); + result = TinyFileDialogs.tinyfd_openFileDialog(title, start, filters, "Litematica (*.litematic)", true); + } catch (Throwable t) { + LOGGER.error("File dialog failed", t); + result = null; + } + String selection = result; + Minecraft.getInstance().execute(() -> { + this.dialogOpen = false; + if (selection != null && !selection.isBlank()) { + List paths = new ArrayList<>(); + for (String part : selection.split("\\|")) { + paths.add(Path.of(part)); + } + this.importFiles(paths); + } + }); + }, "NeoLitematica File Dialog"); + thread.setDaemon(true); + thread.start(); + } + + @Override + public void onFilesDrop(List files) { + this.importFiles(files); + } + + private void importFiles(List files) { + int imported = 0; + Path last = null; + for (Path file : files) { + try { + last = SchematicFiles.importFile(file); + imported++; + } catch (SchematicLoadException | IOException e) { + this.status = Component.translatable("neolitematica.message.import_failed", file.getFileName().toString(), e.getMessage()) + .withStyle(ChatFormatting.RED); + } + } + if (imported > 0) { + this.status = Component.translatable("neolitematica.message.imported", imported); + } + this.refresh(); + if (last != null) { + for (FileList.Entry entry : this.list.children()) { + if (entry.file.equals(last)) { + this.list.setSelected(entry); + this.list.centerScrollOn(entry); + } + } + } + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.render(graphics, mouseX, mouseY, partialTick); + graphics.drawCenteredString(this.font, this.title, this.width / 2, 8, 0xFFFFFF); + if (this.list.children().isEmpty()) { + graphics.drawCenteredString(this.font, Component.translatable("neolitematica.screen.browser.empty"), + 10 + this.list.getWidth() / 2, 50, 0xA0A0A0); + graphics.drawCenteredString(this.font, Component.translatable("neolitematica.screen.browser.drop_hint"), + 10 + this.list.getWidth() / 2, 62, 0xA0A0A0); + } + this.renderInfo(graphics); + if (this.status != null) { + graphics.drawCenteredString(this.font, this.status, this.width / 2, this.height - 64, 0xFFFFFF); + } + } + + private void renderInfo(GuiGraphics g) { + Path file = this.selectedFile(); + int x = this.list.getX() + this.list.getWidth() + 10; + int y = 28; + int maxWidth = this.width - x - 10; + if (file == null || maxWidth < 60) { + return; + } + Object info = this.metadata.get(file); + List lines = new ArrayList<>(); + lines.add(Component.literal(file.getFileName().toString()).withStyle(ChatFormatting.YELLOW)); + if (info == null) { + lines.add(Component.translatable("neolitematica.hud.loading")); + } else if (info instanceof String error) { + lines.add(Component.literal(error).withStyle(ChatFormatting.RED)); + } else if (info instanceof SchematicMetadata m) { + lines.add(Component.translatable("neolitematica.info.name", m.name())); + if (!m.author().isBlank()) lines.add(Component.translatable("neolitematica.info.author", m.author())); + lines.add(Component.translatable("neolitematica.info.size", m.enclosingSize().getX(), m.enclosingSize().getY(), m.enclosingSize().getZ())); + lines.add(Component.translatable("neolitematica.info.blocks", m.totalBlocks())); + lines.add(Component.translatable("neolitematica.info.volume", m.totalVolume())); + lines.add(Component.translatable("neolitematica.info.regions", m.regionCount())); + lines.add(Component.translatable("neolitematica.info.version", m.formatVersion(), m.dataVersion())); + if (m.timeModified() > 0) { + lines.add(Component.translatable("neolitematica.info.modified", DateFormat.getDateTimeInstance().format(new Date(m.timeModified())))); + } + try { + lines.add(Component.translatable("neolitematica.info.file_size", Files.size(file) / 1024)); + } catch (IOException ignored) { + // File vanished; the list refreshes on the next action. + } + if (!m.description().isBlank()) { + lines.add(Component.literal(m.description()).withStyle(ChatFormatting.GRAY)); + } + } + for (Component line : lines) { + for (FormattedCharSequence part : this.font.split(line, maxWidth)) { + g.drawString(this.font, part, x, y, 0xFFFFFF, false); + y += 10; + } + y += 1; + } + } + + private void requestMetadata(Path file) { + if (this.metadata.containsKey(file)) { + return; + } + this.metadata.put(file, null); + SchematicManager.get().metadata(file).whenComplete((m, error) -> Minecraft.getInstance().execute(() -> + this.metadata.put(file, error != null ? SchematicManager.errorMessage(error) : m))); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public void onClose() { + this.minecraft.setScreen(this.parent); + } + + class FileList extends ObjectSelectionList { + FileList(Minecraft minecraft, int width, int height, int y) { + super(minecraft, width, height, y, 14); + } + + void clear() { + this.clearEntries(); + } + + Entry add(Path file) { + Entry entry = new Entry(file); + this.addEntry(entry); + return entry; + } + + @Override + public void centerScrollOn(Entry entry) { + super.centerScrollOn(entry); + } + + @Override + public void setSelected(@Nullable Entry entry) { + super.setSelected(entry); + if (entry != null) { + SchematicBrowserScreen.this.requestMetadata(entry.file); + } + SchematicBrowserScreen.this.updateButtons(); + } + + @Override + public int getRowWidth() { + return this.width - 16; + } + + @Override + protected int getScrollbarPosition() { + return this.getX() + this.width - 6; + } + + class Entry extends ObjectSelectionList.Entry { + final Path file; + private final String label; + private long lastClick; + + Entry(Path file) { + this.file = file; + String relative = SchematicFiles.relativeName(file); + this.label = relative.endsWith(SchematicFiles.EXTENSION) + ? relative.substring(0, relative.length() - SchematicFiles.EXTENSION.length()) : relative; + } + + @Override + public void render(GuiGraphics g, int index, int top, int left, int width, int height, int mouseX, int mouseY, + boolean hovering, float partialTick) { + String text = SchematicBrowserScreen.this.font.plainSubstrByWidth(this.label, width - 4); + g.drawString(SchematicBrowserScreen.this.font, text, left + 2, top + 2, 0xFFFFFF, false); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + long now = Util.getMillis(); + boolean doubleClick = now - this.lastClick < 250L && FileList.this.getSelected() == this; + this.lastClick = now; + FileList.this.setSelected(this); + if (doubleClick) { + SchematicBrowserScreen.this.placeSelected(); + } + return true; + } + + @Override + public Component getNarration() { + return Component.literal(this.label); + } + } + } +} diff --git a/src/main/java/de/lucas/litematica/gui/TextInputScreen.java b/src/main/java/de/lucas/litematica/gui/TextInputScreen.java new file mode 100644 index 0000000..0375048 --- /dev/null +++ b/src/main/java/de/lucas/litematica/gui/TextInputScreen.java @@ -0,0 +1,68 @@ +package de.lucas.litematica.gui; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.lwjgl.glfw.GLFW; + +import java.util.function.Consumer; + +/** Small dialog asking for one line of text (used for renaming). */ +public class TextInputScreen extends Screen { + private final Screen parent; + private final String initial; + private final Consumer onConfirm; + private EditBox input; + + public TextInputScreen(Screen parent, Component title, String initial, Consumer onConfirm) { + super(title); + this.parent = parent; + this.initial = initial; + this.onConfirm = onConfirm; + } + + @Override + protected void init() { + int cx = this.width / 2; + int y = this.height / 2 - 20; + this.input = new EditBox(this.font, cx - 100, y, 200, 20, this.title); + this.input.setMaxLength(128); + this.input.setValue(this.initial); + this.addRenderableWidget(this.input); + this.setInitialFocus(this.input); + this.addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> this.confirm()) + .bounds(cx - 100, y + 28, 98, 20).build()); + this.addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), b -> this.onClose()) + .bounds(cx + 2, y + 28, 98, 20).build()); + } + + private void confirm() { + String value = this.input.getValue().trim(); + if (!value.isEmpty()) { + this.onConfirm.accept(value); + } + this.onClose(); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { + this.confirm(); + return true; + } + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.render(graphics, mouseX, mouseY, partialTick); + graphics.drawCenteredString(this.font, this.title, this.width / 2, this.height / 2 - 40, 0xFFFFFF); + } + + @Override + public void onClose() { + this.minecraft.setScreen(this.parent); + } +} diff --git a/src/main/java/de/lucas/litematica/input/InputHandler.java b/src/main/java/de/lucas/litematica/input/InputHandler.java new file mode 100644 index 0000000..4b93b8e --- /dev/null +++ b/src/main/java/de/lucas/litematica/input/InputHandler.java @@ -0,0 +1,140 @@ +package de.lucas.litematica.input; + +import de.lucas.litematica.config.ModConfig; +import de.lucas.litematica.config.RenderMode; +import de.lucas.litematica.gui.MainScreen; +import de.lucas.litematica.gui.PlacementScreen; +import de.lucas.litematica.gui.SchematicBrowserScreen; +import de.lucas.litematica.placement.PlacementManager; +import de.lucas.litematica.placement.SchematicPlacement; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.network.chat.Component; + +/** Handles the hotkeys once per client tick. Movement is relative to the direction the player is facing. */ +public final class InputHandler { + private static final int LARGE_STEP = 10; + + private InputHandler() { + } + + public static void tick() { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.screen != null) { + KeyBindings.ALL.forEach(InputHandler::drain); + return; + } + PlacementManager manager = PlacementManager.get(); + + while (KeyBindings.OPEN_MENU.consumeClick()) { + mc.setScreen(new MainScreen(null)); + } + while (KeyBindings.OPEN_BROWSER.consumeClick()) { + mc.setScreen(new SchematicBrowserScreen(null)); + } + while (KeyBindings.TOGGLE_RENDERING.consumeClick()) { + boolean enabled = !ModConfig.RENDERING_ENABLED.get(); + ModConfig.set(ModConfig.RENDERING_ENABLED, enabled); + status(Component.translatable(enabled ? "neolitematica.message.rendering_on" : "neolitematica.message.rendering_off")); + } + while (KeyBindings.CYCLE_RENDER_MODE.consumeClick()) { + RenderMode mode = ModConfig.RENDER_MODE.get().next(); + ModConfig.set(ModConfig.RENDER_MODE, mode); + status(Component.translatable("neolitematica.message.render_mode", mode.getTranslatedName())); + } + while (KeyBindings.NEXT_PLACEMENT.consumeClick()) { + manager.selectNext(); + SchematicPlacement selected = manager.selected(); + if (selected != null) { + status(Component.translatable("neolitematica.message.selected", selected.name())); + } + } + + SchematicPlacement p = manager.selected(); + if (p == null || mc.level == null || !p.dimension().equals(mc.level.dimension().location())) { + KeyBindings.ALL.forEach(InputHandler::drain); + return; + } + + while (KeyBindings.EDIT_PLACEMENT.consumeClick()) { + mc.setScreen(new PlacementScreen(null, p)); + return; + } + while (KeyBindings.TOGGLE_PLACEMENT.consumeClick()) { + manager.setEnabled(p, !p.enabled()); + status(Component.translatable(p.enabled() ? "neolitematica.message.placement_on" : "neolitematica.message.placement_off", p.name())); + } + while (KeyBindings.MOVE_TO_PLAYER.consumeClick()) { + manager.setOrigin(p, mc.player.blockPosition()); + showPosition(p); + } + while (KeyBindings.RESET_POSITION.consumeClick()) { + manager.resetOrigin(p); + showPosition(p); + } + + int step = Screen.hasControlDown() ? LARGE_STEP : 1; + Direction facing = mc.player.getDirection(); + move(manager, p, KeyBindings.MOVE_FORWARD, facing, step); + move(manager, p, KeyBindings.MOVE_BACK, facing.getOpposite(), step); + move(manager, p, KeyBindings.MOVE_LEFT, facing.getCounterClockWise(), step); + move(manager, p, KeyBindings.MOVE_RIGHT, facing.getClockWise(), step); + move(manager, p, KeyBindings.MOVE_UP, Direction.UP, step); + move(manager, p, KeyBindings.MOVE_DOWN, Direction.DOWN, step); + + while (KeyBindings.ROTATE_CW.consumeClick()) { + manager.setTransform(p, p.transform().rotatedClockwise()); + showTransform(p); + } + while (KeyBindings.ROTATE_CCW.consumeClick()) { + manager.setTransform(p, p.transform().rotatedCounterClockwise()); + showTransform(p); + } + while (KeyBindings.MIRROR_X.consumeClick()) { + manager.setTransform(p, p.transform().withMirrorX(!p.transform().mirrorX())); + showTransform(p); + } + while (KeyBindings.MIRROR_Y.consumeClick()) { + manager.setTransform(p, p.transform().withMirrorY(!p.transform().mirrorY())); + showTransform(p); + } + while (KeyBindings.MIRROR_Z.consumeClick()) { + manager.setTransform(p, p.transform().withMirrorZ(!p.transform().mirrorZ())); + showTransform(p); + } + } + + private static void move(PlacementManager manager, SchematicPlacement p, KeyMapping key, Direction direction, int step) { + boolean moved = false; + while (key.consumeClick()) { + manager.move(p, direction.getStepX() * step, direction.getStepY() * step, direction.getStepZ() * step); + moved = true; + } + if (moved) { + showPosition(p); + } + } + + private static void showPosition(SchematicPlacement p) { + BlockPos o = p.origin(); + status(Component.translatable("neolitematica.message.position", p.name(), o.getX(), o.getY(), o.getZ())); + } + + private static void showTransform(SchematicPlacement p) { + status(Component.translatable("neolitematica.message.transform", p.name(), p.transform().degrees(), + TransformText.mirrors(p.transform()))); + } + + private static void status(Component message) { + Minecraft.getInstance().gui.setOverlayMessage(message, false); + } + + private static void drain(KeyMapping key) { + while (key.consumeClick()) { + // Discard presses that happened while a screen was open or nothing was selected. + } + } +} diff --git a/src/main/java/de/lucas/litematica/input/KeyBindings.java b/src/main/java/de/lucas/litematica/input/KeyBindings.java new file mode 100644 index 0000000..282a671 --- /dev/null +++ b/src/main/java/de/lucas/litematica/input/KeyBindings.java @@ -0,0 +1,65 @@ +package de.lucas.litematica.input; + +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.KeyMapping; +import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent; +import net.neoforged.neoforge.client.settings.KeyConflictContext; +import net.neoforged.neoforge.client.settings.KeyModifier; +import org.lwjgl.glfw.GLFW; + +import java.util.List; + +/** + * Key bindings, all rebindable in Options → Controls. Defaults use Alt combinations or keys that vanilla leaves + * unused in-game (arrow keys, Page Up/Down), to avoid clashes with the many mods in large packs. + */ +public final class KeyBindings { + public static final String CATEGORY = "key.categories.neolitematica"; + + public static final KeyMapping OPEN_MENU = alt("open_menu", GLFW.GLFW_KEY_M); + public static final KeyMapping OPEN_BROWSER = alt("open_browser", GLFW.GLFW_KEY_L); + public static final KeyMapping EDIT_PLACEMENT = alt("edit_placement", GLFW.GLFW_KEY_E); + public static final KeyMapping TOGGLE_RENDERING = alt("toggle_rendering", GLFW.GLFW_KEY_H); + public static final KeyMapping CYCLE_RENDER_MODE = alt("cycle_render_mode", GLFW.GLFW_KEY_V); + public static final KeyMapping NEXT_PLACEMENT = alt("next_placement", GLFW.GLFW_KEY_N); + public static final KeyMapping TOGGLE_PLACEMENT = unbound("toggle_placement"); + public static final KeyMapping MOVE_TO_PLAYER = alt("move_to_player", GLFW.GLFW_KEY_P); + public static final KeyMapping RESET_POSITION = unbound("reset_position"); + + public static final KeyMapping MOVE_FORWARD = plain("move_forward", GLFW.GLFW_KEY_UP); + public static final KeyMapping MOVE_BACK = plain("move_back", GLFW.GLFW_KEY_DOWN); + public static final KeyMapping MOVE_LEFT = plain("move_left", GLFW.GLFW_KEY_LEFT); + public static final KeyMapping MOVE_RIGHT = plain("move_right", GLFW.GLFW_KEY_RIGHT); + public static final KeyMapping MOVE_UP = plain("move_up", GLFW.GLFW_KEY_PAGE_UP); + public static final KeyMapping MOVE_DOWN = plain("move_down", GLFW.GLFW_KEY_PAGE_DOWN); + + public static final KeyMapping ROTATE_CW = alt("rotate_cw", GLFW.GLFW_KEY_R); + public static final KeyMapping ROTATE_CCW = unbound("rotate_ccw"); + public static final KeyMapping MIRROR_X = unbound("mirror_x"); + public static final KeyMapping MIRROR_Y = unbound("mirror_y"); + public static final KeyMapping MIRROR_Z = unbound("mirror_z"); + + static final List ALL = List.of(OPEN_MENU, OPEN_BROWSER, EDIT_PLACEMENT, TOGGLE_RENDERING, CYCLE_RENDER_MODE, + NEXT_PLACEMENT, TOGGLE_PLACEMENT, MOVE_TO_PLAYER, RESET_POSITION, MOVE_FORWARD, MOVE_BACK, MOVE_LEFT, MOVE_RIGHT, + MOVE_UP, MOVE_DOWN, ROTATE_CW, ROTATE_CCW, MIRROR_X, MIRROR_Y, MIRROR_Z); + + private KeyBindings() { + } + + public static void register(RegisterKeyMappingsEvent event) { + ALL.forEach(event::register); + } + + private static KeyMapping alt(String name, int key) { + return new KeyMapping("key.neolitematica." + name, KeyConflictContext.IN_GAME, KeyModifier.ALT, + InputConstants.Type.KEYSYM, key, CATEGORY); + } + + private static KeyMapping plain(String name, int key) { + return new KeyMapping("key.neolitematica." + name, KeyConflictContext.IN_GAME, InputConstants.Type.KEYSYM, key, CATEGORY); + } + + private static KeyMapping unbound(String name) { + return new KeyMapping("key.neolitematica." + name, KeyConflictContext.IN_GAME, InputConstants.UNKNOWN, CATEGORY); + } +} diff --git a/src/main/java/de/lucas/litematica/input/TransformText.java b/src/main/java/de/lucas/litematica/input/TransformText.java new file mode 100644 index 0000000..dc4a35c --- /dev/null +++ b/src/main/java/de/lucas/litematica/input/TransformText.java @@ -0,0 +1,19 @@ +package de.lucas.litematica.input; + +import de.lucas.litematica.placement.PlacementTransform; +import net.minecraft.network.chat.Component; + +/** Short human-readable texts for transforms, shared by HUD, messages and screens. */ +public final class TransformText { + private TransformText() { + } + + /** e.g. "X Z", or "-" when nothing is mirrored. */ + public static Component mirrors(PlacementTransform t) { + StringBuilder sb = new StringBuilder(); + if (t.mirrorX()) sb.append("X "); + if (t.mirrorY()) sb.append("Y "); + if (t.mirrorZ()) sb.append("Z "); + return sb.isEmpty() ? Component.translatable("neolitematica.text.none") : Component.literal(sb.toString().trim()); + } +} diff --git a/src/main/java/de/lucas/litematica/mixin/LevelRendererMixin.java b/src/main/java/de/lucas/litematica/mixin/LevelRendererMixin.java new file mode 100644 index 0000000..dcb4e0f --- /dev/null +++ b/src/main/java/de/lucas/litematica/mixin/LevelRendererMixin.java @@ -0,0 +1,22 @@ +package de.lucas.litematica.mixin; + +import de.lucas.litematica.render.SchematicRenderer; +import net.minecraft.client.renderer.LevelRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Notifies the schematic renderer when vanilla re-renders a world section, i.e. when blocks change or chunks load. + * NeoForge has no client-side block change event, and the schematic overlay must react immediately when the player + * places or breaks a block. All of vanilla's dirty-marking paths funnel into this private method. + * {@code require = 0}: if a rendering mod replaces the method, the mod keeps working with chunk-load updates only. + */ +@Mixin(LevelRenderer.class) +abstract class LevelRendererMixin { + @Inject(method = "setSectionDirty(IIIZ)V", at = @At("HEAD"), require = 0) + private void neolitematica$onSectionDirty(int sectionX, int sectionY, int sectionZ, boolean reRenderOnMainThread, CallbackInfo ci) { + SchematicRenderer.get().onWorldSectionDirty(sectionX, sectionY, sectionZ); + } +} diff --git a/src/main/java/de/lucas/litematica/placement/PlacementManager.java b/src/main/java/de/lucas/litematica/placement/PlacementManager.java new file mode 100644 index 0000000..dcb5029 --- /dev/null +++ b/src/main/java/de/lucas/litematica/placement/PlacementManager.java @@ -0,0 +1,206 @@ +package de.lucas.litematica.placement; + +import de.lucas.litematica.schematic.SchematicFiles; +import de.lucas.litematica.schematic.SchematicManager; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +/** + * All placements of the current world/server. Main thread only. Placements are persisted per world and restored + * on the next login, see {@link PlacementStorage}. + */ +public final class PlacementManager { + private static final PlacementManager INSTANCE = new PlacementManager(); + private static final int SAVE_DELAY_TICKS = 40; + + private final List placements = new ArrayList<>(); + @Nullable + private UUID selected; + @Nullable + private String worldKey; + private int saveCountdown = -1; + + public static PlacementManager get() { + return INSTANCE; + } + + public List all() { + return Collections.unmodifiableList(this.placements); + } + + /** Placements in the player's current dimension. */ + public List inCurrentDimension() { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null) { + return List.of(); + } + ResourceLocation dim = mc.level.dimension().location(); + return this.placements.stream().filter(p -> p.dimension().equals(dim)).toList(); + } + + @Nullable + public SchematicPlacement selected() { + for (SchematicPlacement p : this.placements) { + if (p.id().equals(this.selected)) { + return p; + } + } + return null; + } + + public void select(@Nullable SchematicPlacement placement) { + this.selected = placement == null ? null : placement.id(); + this.markDirty(); + } + + /** Selects the next placement in the current dimension, wrapping around. */ + public void selectNext() { + List list = this.inCurrentDimension(); + if (list.isEmpty()) { + return; + } + int index = list.indexOf(this.selected()); + this.select(list.get((index + 1) % list.size())); + } + + /** Places a schematic file with its minimum corner at {@code origin}. The file is loaded in the background. */ + public SchematicPlacement place(Path file, BlockPos origin) { + Minecraft mc = Minecraft.getInstance(); + ResourceLocation dim = mc.level != null ? mc.level.dimension().location() : ResourceLocation.withDefaultNamespace("overworld"); + SchematicPlacement placement = new SchematicPlacement(UUID.randomUUID(), SchematicFiles.relativeName(file), dim, + SchematicFiles.baseName(file), origin.immutable(), origin.immutable(), PlacementTransform.IDENTITY, true); + this.placements.add(placement); + this.selected = placement.id(); + this.loadSchematic(placement, true); + this.markDirty(); + return placement; + } + + public void remove(SchematicPlacement placement) { + this.placements.remove(placement); + if (placement.id().equals(this.selected)) { + this.selected = this.placements.isEmpty() ? null : this.placements.getLast().id(); + } + this.markDirty(); + } + + public void move(SchematicPlacement placement, int dx, int dy, int dz) { + this.setOrigin(placement, placement.origin().offset(dx, dy, dz)); + } + + public void setOrigin(SchematicPlacement placement, BlockPos origin) { + placement.setOrigin(origin); + this.markDirty(); + } + + public void resetOrigin(SchematicPlacement placement) { + this.setOrigin(placement, placement.originalOrigin()); + } + + /** Makes the current position the one {@link #resetOrigin} returns to. */ + public void storeOriginAsOriginal(SchematicPlacement placement) { + placement.setOriginalOrigin(placement.origin()); + this.markDirty(); + } + + public void setTransform(SchematicPlacement placement, PlacementTransform transform) { + placement.setTransform(transform); + this.markDirty(); + } + + public void setEnabled(SchematicPlacement placement, boolean enabled) { + placement.setEnabled(enabled); + this.markDirty(); + } + + public void rename(SchematicPlacement placement, String name) { + if (!name.isBlank()) { + placement.setName(name.trim()); + this.markDirty(); + } + } + + /** Reloads all placements that use the given file, e.g. after it was replaced on disk. */ + public void reloadFile(Path file) { + String relative = SchematicFiles.relativeName(file); + SchematicManager.get().invalidate(file); + for (SchematicPlacement p : this.placements) { + if (p.schematicFile().equals(relative)) { + this.loadSchematic(p, false); + } + } + } + + public void tick() { + if (this.saveCountdown >= 0 && --this.saveCountdown < 0) { + this.save(); + } + } + + public void onLogin() { + this.placements.clear(); + this.worldKey = PlacementStorage.currentWorldKey(); + PlacementStorage.Loaded loaded = PlacementStorage.load(this.worldKey); + this.placements.addAll(loaded.placements()); + this.selected = loaded.selected(); + for (SchematicPlacement p : this.placements) { + this.loadSchematic(p, false); + } + } + + public void onLogout() { + this.save(); + this.placements.clear(); + this.selected = null; + this.worldKey = null; + } + + private void markDirty() { + this.saveCountdown = SAVE_DELAY_TICKS; + } + + private void save() { + this.saveCountdown = -1; + if (this.worldKey != null) { + PlacementStorage.save(this.worldKey, this.placements, this.selected); + } + } + + private void loadSchematic(SchematicPlacement placement, boolean announce) { + Minecraft mc = Minecraft.getInstance(); + Path file = SchematicFiles.resolve(placement.schematicFile()); + SchematicManager.get().load(file).whenComplete((schematic, error) -> mc.execute(() -> { + if (!this.placements.contains(placement)) { + return; + } + if (error != null) { + String message = SchematicManager.errorMessage(error); + placement.setSchematic(null, message); + notify(Component.translatable("neolitematica.message.load_failed", placement.schematicFile(), message).withStyle(ChatFormatting.RED)); + } else { + placement.setSchematic(schematic, null); + if (announce) { + notify(Component.translatable("neolitematica.message.placed", placement.name(), + placement.origin().getX(), placement.origin().getY(), placement.origin().getZ())); + } + } + })); + } + + private static void notify(Component message) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player != null) { + mc.player.displayClientMessage(message, false); + } + } +} diff --git a/src/main/java/de/lucas/litematica/placement/PlacementStorage.java b/src/main/java/de/lucas/litematica/placement/PlacementStorage.java new file mode 100644 index 0000000..4782b75 --- /dev/null +++ b/src/main/java/de/lucas/litematica/placement/PlacementStorage.java @@ -0,0 +1,150 @@ +package de.lucas.litematica.placement; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.storage.LevelResource; +import net.neoforged.fml.loading.FMLPaths; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Saves placements as JSON in {@code config/neolitematica/placements/.json}, one file per singleplayer world + * or server address. This lives on the client only; nothing is stored on the server. + */ +final class PlacementStorage { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private PlacementStorage() { + } + + record Loaded(List placements, @Nullable UUID selected) { + } + + static String currentWorldKey() { + Minecraft mc = Minecraft.getInstance(); + MinecraftServer server = mc.getSingleplayerServer(); + String key; + if (server != null) { + Path root = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); + key = "sp_" + root.getFileName(); + } else { + ServerData data = mc.getCurrentServer(); + key = data != null ? "mp_" + data.ip : "unknown"; + } + return key.replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + private static Path file(String worldKey) { + return FMLPaths.CONFIGDIR.get().resolve("neolitematica").resolve("placements").resolve(worldKey + ".json"); + } + + static Loaded load(String worldKey) { + Path file = file(worldKey); + List list = new ArrayList<>(); + if (!Files.isRegularFile(file)) { + return new Loaded(list, null); + } + try { + JsonObject root = JsonParser.parseString(Files.readString(file)).getAsJsonObject(); + for (JsonElement element : root.getAsJsonArray("placements")) { + try { + list.add(fromJson(element.getAsJsonObject())); + } catch (RuntimeException e) { + LOGGER.warn("Skipping invalid placement entry in {}: {}", file, e.toString()); + } + } + UUID selected = root.has("selected") ? UUID.fromString(root.get("selected").getAsString()) : null; + return new Loaded(list, selected); + } catch (IOException | RuntimeException e) { + LOGGER.error("Could not read placements from {}", file, e); + return new Loaded(list, null); + } + } + + static void save(String worldKey, List placements, @Nullable UUID selected) { + Path file = file(worldKey); + JsonObject root = new JsonObject(); + JsonArray array = new JsonArray(); + for (SchematicPlacement p : placements) { + array.add(toJson(p)); + } + root.add("placements", array); + if (selected != null) { + root.addProperty("selected", selected.toString()); + } + try { + Files.createDirectories(file.getParent()); + // Write to a temp file first so a crash mid-write cannot destroy the saved placements. + Path tmp = file.resolveSibling(file.getFileName() + ".tmp"); + Files.writeString(tmp, GSON.toJson(root)); + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + LOGGER.error("Could not save placements to {}", file, e); + } + } + + private static JsonObject toJson(SchematicPlacement p) { + JsonObject o = new JsonObject(); + o.addProperty("id", p.id().toString()); + o.addProperty("name", p.name()); + o.addProperty("file", p.schematicFile()); + o.addProperty("dimension", p.dimension().toString()); + o.add("origin", pos(p.origin())); + o.add("originalOrigin", pos(p.originalOrigin())); + o.addProperty("rotation", p.transform().rotation().name()); + o.addProperty("mirrorX", p.transform().mirrorX()); + o.addProperty("mirrorY", p.transform().mirrorY()); + o.addProperty("mirrorZ", p.transform().mirrorZ()); + o.addProperty("enabled", p.enabled()); + return o; + } + + private static SchematicPlacement fromJson(JsonObject o) { + PlacementTransform transform = new PlacementTransform( + Rotation.valueOf(o.get("rotation").getAsString()), + o.get("mirrorX").getAsBoolean(), + o.get("mirrorY").getAsBoolean(), + o.get("mirrorZ").getAsBoolean()); + return new SchematicPlacement( + UUID.fromString(o.get("id").getAsString()), + o.get("file").getAsString(), + ResourceLocation.parse(o.get("dimension").getAsString()), + o.get("name").getAsString(), + pos(o.getAsJsonArray("origin")), + pos(o.getAsJsonArray("originalOrigin")), + transform, + o.get("enabled").getAsBoolean()); + } + + private static JsonArray pos(BlockPos pos) { + JsonArray a = new JsonArray(); + a.add(pos.getX()); + a.add(pos.getY()); + a.add(pos.getZ()); + return a; + } + + private static BlockPos pos(JsonArray a) { + return new BlockPos(a.get(0).getAsInt(), a.get(1).getAsInt(), a.get(2).getAsInt()); + } +} diff --git a/src/main/java/de/lucas/litematica/placement/PlacementTransform.java b/src/main/java/de/lucas/litematica/placement/PlacementTransform.java new file mode 100644 index 0000000..e44cce6 --- /dev/null +++ b/src/main/java/de/lucas/litematica/placement/PlacementTransform.java @@ -0,0 +1,116 @@ +package de.lucas.litematica.placement; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Vec3i; +import net.minecraft.world.level.block.Mirror; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Rotation and mirroring of a schematic, applied in the order: mirror Y, mirror X, mirror Z, rotate around Y. + * This matches vanilla structure placement (mirror before rotate). + *

+ * Positions are mapped inside the schematic's bounding box, so the transformed schematic always starts at the + * placement origin and extends in positive direction. The origin therefore stays the minimum corner. + * + * @param mirrorX flips along the X axis (east/west), vanilla {@link Mirror#FRONT_BACK} + * @param mirrorY flips upside down; block states are adjusted by {@link VerticalFlip} + * @param mirrorZ flips along the Z axis (north/south), vanilla {@link Mirror#LEFT_RIGHT} + */ +public record PlacementTransform(Rotation rotation, boolean mirrorX, boolean mirrorY, boolean mirrorZ) { + public static final PlacementTransform IDENTITY = new PlacementTransform(Rotation.NONE, false, false, false); + + public PlacementTransform withRotation(Rotation rotation) { + return new PlacementTransform(rotation, this.mirrorX, this.mirrorY, this.mirrorZ); + } + + public PlacementTransform rotatedClockwise() { + return this.withRotation(this.rotation.getRotated(Rotation.CLOCKWISE_90)); + } + + public PlacementTransform rotatedCounterClockwise() { + return this.withRotation(this.rotation.getRotated(Rotation.COUNTERCLOCKWISE_90)); + } + + public PlacementTransform withMirrorX(boolean value) { + return new PlacementTransform(this.rotation, value, this.mirrorY, this.mirrorZ); + } + + public PlacementTransform withMirrorY(boolean value) { + return new PlacementTransform(this.rotation, this.mirrorX, value, this.mirrorZ); + } + + public PlacementTransform withMirrorZ(boolean value) { + return new PlacementTransform(this.rotation, this.mirrorX, this.mirrorY, value); + } + + public boolean swapsXZ() { + return this.rotation == Rotation.CLOCKWISE_90 || this.rotation == Rotation.COUNTERCLOCKWISE_90; + } + + /** Size of the schematic after this transform. */ + public Vec3i transformSize(Vec3i size) { + return this.swapsXZ() ? new Vec3i(size.getZ(), size.getY(), size.getX()) : size; + } + + /** Maps a schematic-local position to an offset from the placement origin. */ + public BlockPos toPlaced(int x, int y, int z, Vec3i size) { + if (this.mirrorY) y = size.getY() - 1 - y; + if (this.mirrorX) x = size.getX() - 1 - x; + if (this.mirrorZ) z = size.getZ() - 1 - z; + return switch (this.rotation) { + case NONE -> new BlockPos(x, y, z); + case CLOCKWISE_90 -> new BlockPos(size.getZ() - 1 - z, y, x); + case CLOCKWISE_180 -> new BlockPos(size.getX() - 1 - x, y, size.getZ() - 1 - z); + case COUNTERCLOCKWISE_90 -> new BlockPos(z, y, size.getX() - 1 - x); + }; + } + + /** + * Inverse of {@link #toPlaced}: maps an offset from the placement origin back to schematic-local coordinates, + * written into {@code out}. {@code size} is the untransformed schematic size. + */ + public void toLocal(int px, int py, int pz, Vec3i size, BlockPos.MutableBlockPos out) { + int x, z; + switch (this.rotation) { + case CLOCKWISE_90 -> { + x = pz; + z = size.getZ() - 1 - px; + } + case CLOCKWISE_180 -> { + x = size.getX() - 1 - px; + z = size.getZ() - 1 - pz; + } + case COUNTERCLOCKWISE_90 -> { + x = size.getX() - 1 - pz; + z = px; + } + default -> { + x = px; + z = pz; + } + } + int y = py; + if (this.mirrorZ) z = size.getZ() - 1 - z; + if (this.mirrorX) x = size.getX() - 1 - x; + if (this.mirrorY) y = size.getY() - 1 - y; + out.set(x, y, z); + } + + public BlockState apply(BlockState state) { + if (this.mirrorY) state = VerticalFlip.flip(state); + if (this.mirrorX) state = state.mirror(Mirror.FRONT_BACK); + if (this.mirrorZ) state = state.mirror(Mirror.LEFT_RIGHT); + if (this.rotation != Rotation.NONE) state = state.rotate(this.rotation); + return state; + } + + public int degrees() { + return switch (this.rotation) { + case NONE -> 0; + case CLOCKWISE_90 -> 90; + case CLOCKWISE_180 -> 180; + case COUNTERCLOCKWISE_90 -> 270; + }; + } +} diff --git a/src/main/java/de/lucas/litematica/placement/PlacementView.java b/src/main/java/de/lucas/litematica/placement/PlacementView.java new file mode 100644 index 0000000..ab43de0 --- /dev/null +++ b/src/main/java/de/lucas/litematica/placement/PlacementView.java @@ -0,0 +1,125 @@ +package de.lucas.litematica.placement; + +import de.lucas.litematica.schematic.LitematicSchematic; +import de.lucas.litematica.schematic.SchematicRegion; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Vec3i; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * Immutable snapshot of a placement: schematic + origin + transform, with block states already transformed per + * palette entry. Render workers query blocks in world coordinates through this class without locking. + * A new view is created whenever the placement changes. + */ +public final class PlacementView { + private static final BlockState AIR = Blocks.AIR.defaultBlockState(); + + private final LitematicSchematic schematic; + private final BlockPos origin; + private final PlacementTransform transform; + private final Vec3i localSize; + private final BoundingBox box; + private final List regions; + private final BlockState[][] transformedPalettes; + + public PlacementView(LitematicSchematic schematic, BlockPos origin, PlacementTransform transform) { + this.schematic = schematic; + this.origin = origin; + this.transform = transform; + this.localSize = schematic.size(); + Vec3i placed = transform.transformSize(this.localSize); + this.box = new BoundingBox(origin.getX(), origin.getY(), origin.getZ(), + origin.getX() + placed.getX() - 1, origin.getY() + placed.getY() - 1, origin.getZ() + placed.getZ() - 1); + this.regions = schematic.regions(); + this.transformedPalettes = new BlockState[this.regions.size()][]; + for (int i = 0; i < this.regions.size(); i++) { + BlockState[] palette = this.regions.get(i).palette(); + BlockState[] transformed = new BlockState[palette.length]; + for (int j = 0; j < palette.length; j++) { + transformed[j] = transform.apply(palette[j]); + } + this.transformedPalettes[i] = transformed; + } + } + + public LitematicSchematic schematic() { + return this.schematic; + } + + public BlockPos origin() { + return this.origin; + } + + public PlacementTransform transform() { + return this.transform; + } + + /** World-space bounding box (inclusive). */ + public BoundingBox box() { + return this.box; + } + + /** Transformed block state at a world position; air outside the schematic. {@code scratch} avoids allocation. */ + public BlockState getState(int x, int y, int z, BlockPos.MutableBlockPos scratch) { + int region = this.locate(x, y, z, scratch); + if (region < 0) { + return AIR; + } + SchematicRegion r = this.regions.get(region); + return this.transformedPalettes[region][r.paletteIndex(scratch.getX(), scratch.getY(), scratch.getZ())]; + } + + /** {@code true} if the schematic contains a block at this position that does not exist in the running game. */ + public boolean isUnknown(int x, int y, int z, BlockPos.MutableBlockPos scratch) { + int region = this.locate(x, y, z, scratch); + if (region < 0) { + return false; + } + SchematicRegion r = this.regions.get(region); + return r.unknown()[r.paletteIndex(scratch.getX(), scratch.getY(), scratch.getZ())]; + } + + @Nullable + public CompoundTag getBlockEntity(int x, int y, int z, BlockPos.MutableBlockPos scratch) { + int region = this.locate(x, y, z, scratch); + return region < 0 ? null : this.regions.get(region).getBlockEntity(scratch.getX(), scratch.getY(), scratch.getZ()); + } + + /** + * Resolves a world position to a region index, leaving the region-local coordinates in {@code scratch}. + * Returns -1 if no region covers the position. + */ + private int locate(int x, int y, int z, BlockPos.MutableBlockPos scratch) { + if (!this.box.isInside(x, y, z)) { + return -1; + } + this.transform.toLocal(x - this.origin.getX(), y - this.origin.getY(), z - this.origin.getZ(), this.localSize, scratch); + int lx = scratch.getX(), ly = scratch.getY(), lz = scratch.getZ(); + int fallback = -1; + for (int i = 0; i < this.regions.size(); i++) { + SchematicRegion r = this.regions.get(i); + int rx = lx - r.min().getX(), ry = ly - r.min().getY(), rz = lz - r.min().getZ(); + if (r.contains(rx, ry, rz)) { + int index = r.paletteIndex(rx, ry, rz); + if (this.regions.size() == 1 || !r.palette()[index].isAir() || r.unknown()[index]) { + scratch.set(rx, ry, rz); + return i; + } + if (fallback < 0) { + fallback = i; + } + } + } + if (fallback >= 0) { + SchematicRegion r = this.regions.get(fallback); + scratch.set(lx - r.min().getX(), ly - r.min().getY(), lz - r.min().getZ()); + } + return fallback; + } +} diff --git a/src/main/java/de/lucas/litematica/placement/SchematicPlacement.java b/src/main/java/de/lucas/litematica/placement/SchematicPlacement.java new file mode 100644 index 0000000..3740d90 --- /dev/null +++ b/src/main/java/de/lucas/litematica/placement/SchematicPlacement.java @@ -0,0 +1,154 @@ +package de.lucas.litematica.placement; + +import de.lucas.litematica.schematic.LitematicSchematic; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Vec3i; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +/** + * A schematic placed in a world. Mutable and owned by the main thread; every change increments + * {@link #revision()} so the renderer knows to rebuild, and creates a fresh immutable {@link PlacementView}. + */ +public final class SchematicPlacement { + private final UUID id; + private final String schematicFile; + private final ResourceLocation dimension; + private String name; + private BlockPos origin; + private BlockPos originalOrigin; + private PlacementTransform transform; + private boolean enabled; + + @Nullable + private LitematicSchematic schematic; + @Nullable + private String loadError; + @Nullable + private PlacementView view; + private int revision; + private VerificationStats stats = VerificationStats.EMPTY; + + public SchematicPlacement(UUID id, String schematicFile, ResourceLocation dimension, String name, BlockPos origin, + BlockPos originalOrigin, PlacementTransform transform, boolean enabled) { + this.id = id; + this.schematicFile = schematicFile; + this.dimension = dimension; + this.name = name; + this.origin = origin; + this.originalOrigin = originalOrigin; + this.transform = transform; + this.enabled = enabled; + } + + public UUID id() { + return this.id; + } + + /** Schematic file, relative to the schematics directory. */ + public String schematicFile() { + return this.schematicFile; + } + + public ResourceLocation dimension() { + return this.dimension; + } + + public String name() { + return this.name; + } + + public BlockPos origin() { + return this.origin; + } + + public BlockPos originalOrigin() { + return this.originalOrigin; + } + + public PlacementTransform transform() { + return this.transform; + } + + public boolean enabled() { + return this.enabled; + } + + @Nullable + public LitematicSchematic schematic() { + return this.schematic; + } + + @Nullable + public String loadError() { + return this.loadError; + } + + public int revision() { + return this.revision; + } + + public VerificationStats stats() { + return this.stats; + } + + public void setStats(VerificationStats stats) { + this.stats = stats; + } + + /** The current immutable view, or {@code null} while the schematic is not loaded. */ + @Nullable + public PlacementView view() { + if (this.view == null && this.schematic != null) { + this.view = new PlacementView(this.schematic, this.origin, this.transform); + } + return this.view; + } + + /** Transformed size, or zero while the schematic is not loaded. */ + public Vec3i placedSize() { + return this.schematic == null ? Vec3i.ZERO : this.transform.transformSize(this.schematic.size()); + } + + void setSchematic(@Nullable LitematicSchematic schematic, @Nullable String error) { + this.schematic = schematic; + this.loadError = error; + this.changed(); + } + + void setName(String name) { + this.name = name; + } + + void setOrigin(BlockPos origin) { + if (!origin.equals(this.origin)) { + this.origin = origin.immutable(); + this.changed(); + } + } + + void setOriginalOrigin(BlockPos originalOrigin) { + this.originalOrigin = originalOrigin.immutable(); + } + + void setTransform(PlacementTransform transform) { + if (!transform.equals(this.transform)) { + this.transform = transform; + this.changed(); + } + } + + void setEnabled(boolean enabled) { + if (enabled != this.enabled) { + this.enabled = enabled; + this.changed(); + } + } + + private void changed() { + this.view = null; + this.revision++; + } +} diff --git a/src/main/java/de/lucas/litematica/placement/VerificationStats.java b/src/main/java/de/lucas/litematica/placement/VerificationStats.java new file mode 100644 index 0000000..f0f93d5 --- /dev/null +++ b/src/main/java/de/lucas/litematica/placement/VerificationStats.java @@ -0,0 +1,15 @@ +package de.lucas.litematica.placement; + +/** + * Result of comparing a placement with the world, summed over all currently built render sections. + * Only sections within render distance are compared, so {@link #complete} tells whether the numbers cover the + * whole placement. + */ +public record VerificationStats(int correct, int missing, int wrongBlock, int wrongState, int extra, int unknown, + boolean complete) { + public static final VerificationStats EMPTY = new VerificationStats(0, 0, 0, 0, 0, 0, false); + + public int total() { + return this.correct + this.missing + this.wrongBlock + this.wrongState; + } +} diff --git a/src/main/java/de/lucas/litematica/placement/VerticalFlip.java b/src/main/java/de/lucas/litematica/placement/VerticalFlip.java new file mode 100644 index 0000000..e9e82ff --- /dev/null +++ b/src/main/java/de/lucas/litematica/placement/VerticalFlip.java @@ -0,0 +1,64 @@ +package de.lucas.litematica.placement; + +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.AttachFace; +import net.minecraft.world.level.block.state.properties.BellAttachType; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.DirectionProperty; +import net.minecraft.world.level.block.state.properties.DoubleBlockHalf; +import net.minecraft.world.level.block.state.properties.Half; +import net.minecraft.world.level.block.state.properties.Property; +import net.minecraft.world.level.block.state.properties.SlabType; + +/** + * Upside-down mirroring of block states. Vanilla has no API for this ({@code Mirror} only covers X and Z), so the + * common vertical properties are flipped explicitly: stair/trapdoor halves, slab types, door/plant halves, + * up/down facings, floor/ceiling attachment, dripstone direction and up/down connection flags. + * Blocks without a vertical representation (e.g. hanging lanterns) keep their state. + */ +public final class VerticalFlip { + private VerticalFlip() { + } + + public static BlockState flip(BlockState state) { + for (Property property : state.getProperties()) { + state = flipProperty(state, property); + } + if (state.hasProperty(BlockStateProperties.UP) && state.hasProperty(BlockStateProperties.DOWN)) { + boolean up = state.getValue(BlockStateProperties.UP); + boolean down = state.getValue(BlockStateProperties.DOWN); + state = state.setValue(BlockStateProperties.UP, down).setValue(BlockStateProperties.DOWN, up); + } + return state; + } + + private static BlockState flipProperty(BlockState state, Property property) { + Object value = state.getValue(property); + if (value instanceof Half half) { + return set(state, property, half == Half.TOP ? Half.BOTTOM : Half.TOP); + } + if (value instanceof SlabType type && type != SlabType.DOUBLE) { + return set(state, property, type == SlabType.TOP ? SlabType.BOTTOM : SlabType.TOP); + } + if (value instanceof DoubleBlockHalf half) { + return set(state, property, half == DoubleBlockHalf.UPPER ? DoubleBlockHalf.LOWER : DoubleBlockHalf.UPPER); + } + if (value instanceof AttachFace face && face != AttachFace.WALL) { + return set(state, property, face == AttachFace.FLOOR ? AttachFace.CEILING : AttachFace.FLOOR); + } + if (value instanceof BellAttachType type && (type == BellAttachType.FLOOR || type == BellAttachType.CEILING)) { + return set(state, property, type == BellAttachType.FLOOR ? BellAttachType.CEILING : BellAttachType.FLOOR); + } + if (property instanceof DirectionProperty && value instanceof Direction dir && dir.getAxis() == Direction.Axis.Y) { + return set(state, property, dir.getOpposite()); + } + return state; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static BlockState set(BlockState state, Property property, Comparable value) { + // Some blocks restrict a property's values (e.g. hoppers cannot face up); keep the state then. + return property.getPossibleValues().contains(value) ? state.setValue(property, value) : state; + } +} diff --git a/src/main/java/de/lucas/litematica/render/BlockStatus.java b/src/main/java/de/lucas/litematica/render/BlockStatus.java new file mode 100644 index 0000000..80c0327 --- /dev/null +++ b/src/main/java/de/lucas/litematica/render/BlockStatus.java @@ -0,0 +1,66 @@ +package de.lucas.litematica.render; + +import de.lucas.litematica.config.RenderMode; +import net.minecraft.world.level.block.LiquidBlock; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +/** Comparison of one schematic position with the world. Stored as a byte per block while meshing. */ +final class BlockStatus { + static final byte NONE = 0; + /** Schematic block shown as ghost without comparison (mode ALL, or the chunk is not loaded). */ + static final byte UNCHECKED = 1; + static final byte MISSING = 2; + static final byte CORRECT = 3; + static final byte WRONG_BLOCK = 4; + static final byte WRONG_STATE = 5; + static final byte EXTRA = 6; + static final byte UNKNOWN = 7; + static final int COUNT = 8; + + private BlockStatus() { + } + + static byte classify(BlockState schematic, boolean unknownBlock, @Nullable BlockState world, RenderMode mode) { + if (unknownBlock) { + return UNKNOWN; + } + if (schematic.isAir()) { + if (world != null && mode.comparesWorld() && !world.isAir() && !(world.getBlock() instanceof LiquidBlock)) { + return EXTRA; + } + return NONE; + } + if (world == null || !mode.comparesWorld()) { + return UNCHECKED; + } + if (world == schematic) { + return CORRECT; + } + if (world.getBlock() == schematic.getBlock()) { + // Fluid levels change on their own; any state of the right fluid counts as correct. + return schematic.getBlock() instanceof LiquidBlock ? CORRECT : WRONG_STATE; + } + // Air, fluids, grass, snow layers... can simply be built over. + if (world.isAir() || world.canBeReplaced()) { + return MISSING; + } + return WRONG_BLOCK; + } + + static boolean showsGhost(byte status) { + return status == UNCHECKED || status == MISSING; + } + + /** Overlay colour as ARGB, or 0 for no overlay. */ + static int overlayColor(byte status, RenderMode mode) { + return switch (status) { + case WRONG_BLOCK -> 0xFFFF2828; + case WRONG_STATE -> 0xFFFFA000; + case UNKNOWN -> 0xFF8C3CFF; + case EXTRA -> mode == RenderMode.VERIFY ? 0xFFFF00FF : 0; + case CORRECT -> mode == RenderMode.VERIFY ? 0x8028DC28 : 0; + default -> 0; + }; + } +} diff --git a/src/main/java/de/lucas/litematica/render/GhostBufferSource.java b/src/main/java/de/lucas/litematica/render/GhostBufferSource.java new file mode 100644 index 0000000..e56b831 --- /dev/null +++ b/src/main/java/de/lucas/litematica/render/GhostBufferSource.java @@ -0,0 +1,62 @@ +package de.lucas.litematica.render; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.BufferUploader; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.MeshData; +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Buffer source for block entity renderers that draws everything semi-transparent. + *

+ * Block entity renderers pick their own render types (mostly opaque entity types). Each batch is drawn with the + * type's normal state, but with blending forced on and the shader colour's alpha lowered; all vanilla entity + * shaders multiply by {@code ColorModulator}, so this makes chests, signs, beds etc. translucent like block ghosts. + */ +final class GhostBufferSource implements MultiBufferSource, AutoCloseable { + private static final int MAX_CACHED_TYPES = 64; + + private final Map memory = new HashMap<>(); + private final Map active = new LinkedHashMap<>(); + + @Override + public VertexConsumer getBuffer(RenderType type) { + return this.active.computeIfAbsent(type, t -> new BufferBuilder( + this.memory.computeIfAbsent(t, x -> new ByteBufferBuilder(Math.min(x.bufferSize(), 1 << 18))), t.mode(), t.format())); + } + + void flush(float alpha) { + for (Map.Entry entry : this.active.entrySet()) { + MeshData mesh = entry.getValue().build(); + if (mesh == null) { + continue; + } + RenderType type = entry.getKey(); + type.setupRenderState(); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, alpha); + BufferUploader.drawWithShader(mesh); + RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f); + type.clearRenderState(); + } + this.active.clear(); + if (this.memory.size() > MAX_CACHED_TYPES) { + this.close(); + } + } + + @Override + public void close() { + this.active.clear(); + this.memory.values().forEach(ByteBufferBuilder::close); + this.memory.clear(); + } +} diff --git a/src/main/java/de/lucas/litematica/render/PlacementRenderer.java b/src/main/java/de/lucas/litematica/render/PlacementRenderer.java new file mode 100644 index 0000000..c6786d3 --- /dev/null +++ b/src/main/java/de/lucas/litematica/render/PlacementRenderer.java @@ -0,0 +1,260 @@ +package de.lucas.litematica.render; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import java.io.IOException; +import com.mojang.logging.LogUtils; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.nbt.Tag; +import de.lucas.litematica.placement.PlacementView; +import de.lucas.litematica.placement.SchematicPlacement; +import de.lucas.litematica.placement.VerificationStats; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** Render state of one placement: its sections and block entity ghosts. Main/render thread only. */ +final class PlacementRenderer { + private static final Logger LOGGER = LogUtils.getLogger(); + /** Block entity types whose renderer failed once; they are not tried again this session. */ + static final Set> FAILED_BLOCK_ENTITIES = ConcurrentHashMap.newKeySet(); + private static final Object NO_BLOCK_ENTITY = new Object(); + private static final Gson GSON = new Gson(); + + private final SchematicPlacement placement; + private final Long2ObjectMap sections = new Long2ObjectOpenHashMap<>(); + private final Long2ObjectMap blockEntities = new Long2ObjectOpenHashMap<>(); + @Nullable + private PlacementView view; + private int revision = -1; + private int generation; + + PlacementRenderer(SchematicPlacement placement) { + this.placement = placement; + } + + SchematicPlacement placement() { + return this.placement; + } + + @Nullable + PlacementView view() { + return this.view; + } + + int generation() { + return this.generation; + } + + Iterable sections() { + return this.sections.values(); + } + + @Nullable + RenderSection section(long key) { + return this.sections.get(key); + } + + /** + * Picks up placement changes, creates sections in range, drops far ones and adds sections that need a + * rebuild to {@code candidates}. + */ + void update(int camX, int camY, int camZ, int distance, List candidates) { + if (this.placement.revision() != this.revision) { + this.revision = this.placement.revision(); + this.view = this.placement.view(); + this.generation++; + this.blockEntities.clear(); + for (RenderSection section : this.sections.values()) { + section.markDirty(); + } + } + if (this.view == null) { + this.closeAll(); + return; + } + + BoundingBox box = this.view.box(); + int minX = Math.max(SectionPos.blockToSectionCoord(box.minX()), camX - distance); + int maxX = Math.min(SectionPos.blockToSectionCoord(box.maxX()), camX + distance); + int minY = Math.max(SectionPos.blockToSectionCoord(box.minY()), camY - distance); + int maxY = Math.min(SectionPos.blockToSectionCoord(box.maxY()), camY + distance); + int minZ = Math.max(SectionPos.blockToSectionCoord(box.minZ()), camZ - distance); + int maxZ = Math.min(SectionPos.blockToSectionCoord(box.maxZ()), camZ + distance); + + // Drop sections outside the box or well outside the render distance (hysteresis avoids rebuild ping-pong). + Iterator it = this.sections.values().iterator(); + while (it.hasNext()) { + RenderSection section = it.next(); + int sx = SectionPos.x(section.key()), sy = SectionPos.y(section.key()), sz = SectionPos.z(section.key()); + boolean inBox = sx >= SectionPos.blockToSectionCoord(box.minX()) && sx <= SectionPos.blockToSectionCoord(box.maxX()) + && sy >= SectionPos.blockToSectionCoord(box.minY()) && sy <= SectionPos.blockToSectionCoord(box.maxY()) + && sz >= SectionPos.blockToSectionCoord(box.minZ()) && sz <= SectionPos.blockToSectionCoord(box.maxZ()); + int far = Math.max(Math.abs(sx - camX), Math.max(Math.abs(sy - camY), Math.abs(sz - camZ))); + if (!inBox || far > distance + 2) { + section.close(); + it.remove(); + } + } + + for (int sx = minX; sx <= maxX; sx++) { + for (int sy = minY; sy <= maxY; sy++) { + for (int sz = minZ; sz <= maxZ; sz++) { + long key = SectionPos.asLong(sx, sy, sz); + RenderSection section = this.sections.get(key); + if (section == null) { + section = new RenderSection(key); + this.sections.put(key, section); + } + if (section.needsBuild()) { + candidates.add(section); + } + } + } + } + } + + void markDirty(long sectionKey) { + RenderSection section = this.sections.get(sectionKey); + if (section != null) { + section.markDirty(); + } + } + + void markAllDirty() { + for (RenderSection section : this.sections.values()) { + section.markDirty(); + } + } + + /** Sums the comparison results of all built sections into the placement's statistics. */ + void updateStats(boolean comparesWorld) { + if (this.view == null) { + this.placement.setStats(VerificationStats.EMPTY); + return; + } + int[] sum = new int[BlockStatus.COUNT]; + boolean complete = comparesWorld; + for (RenderSection section : this.sections.values()) { + if (!section.hasData() || section.needsBuild()) { + complete = false; + } + int[] counts = section.counts(); + for (int i = 0; i < sum.length; i++) { + sum[i] += counts[i]; + } + } + BoundingBox box = this.view.box(); + long sectionsInBox = (long) (SectionPos.blockToSectionCoord(box.maxX()) - SectionPos.blockToSectionCoord(box.minX()) + 1) + * (SectionPos.blockToSectionCoord(box.maxY()) - SectionPos.blockToSectionCoord(box.minY()) + 1) + * (SectionPos.blockToSectionCoord(box.maxZ()) - SectionPos.blockToSectionCoord(box.minZ()) + 1); + if (this.sections.size() < sectionsInBox || sum[BlockStatus.UNCHECKED] > 0) { + complete = false; + } + this.placement.setStats(new VerificationStats(sum[BlockStatus.CORRECT], sum[BlockStatus.MISSING], + sum[BlockStatus.WRONG_BLOCK], sum[BlockStatus.WRONG_STATE], sum[BlockStatus.EXTRA], sum[BlockStatus.UNKNOWN], complete)); + } + + /** + * Returns the block entity used to draw a ghost at {@code pos}, creating it from the schematic's NBT on first + * use, or {@code null} if the block has no block entity renderer. + */ + @Nullable + BlockEntity blockEntity(long pos, ClientLevel level) { + Object cached = this.blockEntities.get(pos); + if (cached == null) { + cached = this.createBlockEntity(BlockPos.of(pos), level); + this.blockEntities.put(pos, cached == null ? NO_BLOCK_ENTITY : cached); + } + return cached instanceof BlockEntity be ? be : null; + } + + @Nullable + private BlockEntity createBlockEntity(BlockPos pos, ClientLevel level) { + if (this.view == null) { + return null; + } + BlockPos.MutableBlockPos scratch = new BlockPos.MutableBlockPos(); + BlockState state = this.view.getState(pos.getX(), pos.getY(), pos.getZ(), scratch); + if (!(state.getBlock() instanceof EntityBlock entityBlock)) { + return null; + } + try { + BlockEntity be = entityBlock.newBlockEntity(pos, state); + if (be == null || FAILED_BLOCK_ENTITIES.contains(be.getType()) + || Minecraft.getInstance().getBlockEntityRenderDispatcher().getRenderer(be) == null) { + return null; + } + CompoundTag tag = this.view.getBlockEntity(pos.getX(), pos.getY(), pos.getZ(), scratch); + if (tag != null) { + CompoundTag copy = tag.copy(); + copy.putInt("x", pos.getX()); + copy.putInt("y", pos.getY()); + copy.putInt("z", pos.getZ()); + repairSignText(copy, "front_text"); + repairSignText(copy, "back_text"); + be.loadWithComponents(copy, level.registryAccess()); + } + // Vanilla renderers need the level (e.g. double chests). Modded block entities may register + // themselves somewhere when given a level, so they stay detached. + ResourceLocation type = BuiltInRegistries.BLOCK_ENTITY_TYPE.getKey(be.getType()); + if (type != null && type.getNamespace().equals("minecraft")) { + be.setLevel(level); + } + return be; + } catch (Throwable t) { + LOGGER.warn("Cannot create block entity for {} in schematic: {}", state, t.toString()); + return null; + } + } + + /** + * Some schematic generators store sign lines as plain text instead of JSON text components, which vanilla + * rejects (blank sign plus log spam). Plain lines are converted to JSON strings. + */ + private static void repairSignText(CompoundTag tag, String side) { + if (!tag.contains(side, Tag.TAG_COMPOUND)) { + return; + } + ListTag messages = tag.getCompound(side).getList("messages", Tag.TAG_STRING); + for (int i = 0; i < messages.size(); i++) { + String line = messages.getString(i); + try { + // Strict parse like vanilla; the lenient JsonParser would accept unquoted text. + if (!line.isBlank() && GSON.getAdapter(JsonElement.class).fromJson(line) != null) { + continue; + } + } catch (IOException | RuntimeException ignored) { + // Not JSON: plain text. + } + messages.set(i, StringTag.valueOf(GSON.toJson(line))); + } + } + + void closeAll() { + for (RenderSection section : this.sections.values()) { + section.close(); + } + this.sections.clear(); + this.blockEntities.clear(); + } +} diff --git a/src/main/java/de/lucas/litematica/render/RenderSection.java b/src/main/java/de/lucas/litematica/render/RenderSection.java new file mode 100644 index 0000000..5eddba6 --- /dev/null +++ b/src/main/java/de/lucas/litematica/render/RenderSection.java @@ -0,0 +1,138 @@ +package de.lucas.litematica.render; + +import com.mojang.blaze3d.vertex.VertexBuffer; +import net.minecraft.core.SectionPos; +import net.minecraft.world.phys.AABB; +import org.jetbrains.annotations.Nullable; + +/** + * GPU buffers of one 16x16x16 world section of a placement. Rebuilt only when the placement or the world inside + * the section changes; drawing a frame just binds the cached buffers. Main/render thread only. + */ +final class RenderSection { + private static final long[] NO_BLOCK_ENTITIES = new long[0]; + + private final long key; + private final int originX, originY, originZ; + private final AABB bounds; + + @Nullable + private VertexBuffer ghost; + @Nullable + private VertexBuffer overlay; + private long[] blockEntities = NO_BLOCK_ENTITIES; + private int[] counts = new int[BlockStatus.COUNT]; + + /** Incremented when the section must be rebuilt; a build result carries the version it was started for. */ + private int dirtyVersion = 1; + private int builtVersion; + private boolean hasData; + private boolean building; + + RenderSection(long key) { + this.key = key; + this.originX = SectionPos.sectionToBlockCoord(SectionPos.x(key)); + this.originY = SectionPos.sectionToBlockCoord(SectionPos.y(key)); + this.originZ = SectionPos.sectionToBlockCoord(SectionPos.z(key)); + this.bounds = new AABB(this.originX, this.originY, this.originZ, this.originX + 16, this.originY + 16, this.originZ + 16); + } + + long key() { + return this.key; + } + + int originX() { + return this.originX; + } + + int originY() { + return this.originY; + } + + int originZ() { + return this.originZ; + } + + AABB bounds() { + return this.bounds; + } + + @Nullable + VertexBuffer ghost() { + return this.ghost; + } + + @Nullable + VertexBuffer overlay() { + return this.overlay; + } + + long[] blockEntities() { + return this.blockEntities; + } + + int[] counts() { + return this.counts; + } + + boolean hasData() { + return this.hasData; + } + + void markDirty() { + this.dirtyVersion++; + } + + boolean needsBuild() { + return !this.building && this.builtVersion != this.dirtyVersion; + } + + int dirtyVersion() { + return this.dirtyVersion; + } + + void setBuilding(boolean building) { + this.building = building; + } + + /** Uploads a finished build. Takes ownership of the result's native memory. */ + void upload(SectionMeshBuilder.Result result) { + this.ghost = uploadInto(this.ghost, result.ghost()); + this.overlay = uploadInto(this.overlay, result.overlay()); + if (result.ghostMemory() != null) result.ghostMemory().close(); + if (result.overlayMemory() != null) result.overlayMemory().close(); + this.blockEntities = result.blockEntities(); + this.counts = result.counts(); + this.builtVersion = result.task().dirtyVersion(); + this.hasData = true; + } + + @Nullable + private static VertexBuffer uploadInto(@Nullable VertexBuffer buffer, @Nullable com.mojang.blaze3d.vertex.MeshData mesh) { + if (mesh == null) { + if (buffer != null) { + buffer.close(); + } + return null; + } + if (buffer == null) { + buffer = new VertexBuffer(VertexBuffer.Usage.STATIC); + } + buffer.bind(); + buffer.upload(mesh); + VertexBuffer.unbind(); + return buffer; + } + + void close() { + if (this.ghost != null) { + this.ghost.close(); + this.ghost = null; + } + if (this.overlay != null) { + this.overlay.close(); + this.overlay = null; + } + this.hasData = false; + } +} diff --git a/src/main/java/de/lucas/litematica/render/SchematicBlockGetter.java b/src/main/java/de/lucas/litematica/render/SchematicBlockGetter.java new file mode 100644 index 0000000..db1a689 --- /dev/null +++ b/src/main/java/de/lucas/litematica/render/SchematicBlockGetter.java @@ -0,0 +1,86 @@ +package de.lucas.litematica.render; + +import de.lucas.litematica.placement.PlacementView; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.BlockAndTintGetter; +import net.minecraft.world.level.ColorResolver; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.lighting.LevelLightEngine; +import net.minecraft.world.level.material.FluidState; +import org.jetbrains.annotations.Nullable; + +/** + * The world as the block renderer sees it while meshing a schematic: block states come from the placement, so + * faces between neighbouring schematic blocks are culled and connected textures (CTM, Fusion, connected glass) + * connect within the schematic. Biome tints and shading come from the real level. + *

+ * Light is always full block light so ghosts stay visible at night and underground. + * One instance per build task; not thread-safe (uses a scratch position). + */ +final class SchematicBlockGetter implements BlockAndTintGetter { + private static final int FULL_LIGHT = 15; + + private final PlacementView view; + private final ClientLevel level; + private final BlockPos.MutableBlockPos scratch = new BlockPos.MutableBlockPos(); + + SchematicBlockGetter(PlacementView view, ClientLevel level) { + this.view = view; + this.level = level; + } + + @Override + public BlockState getBlockState(BlockPos pos) { + return this.view.getState(pos.getX(), pos.getY(), pos.getZ(), this.scratch); + } + + @Override + public FluidState getFluidState(BlockPos pos) { + return this.getBlockState(pos).getFluidState(); + } + + @Nullable + @Override + public BlockEntity getBlockEntity(BlockPos pos) { + return null; + } + + @Override + public float getShade(Direction direction, boolean shade) { + return this.level.getShade(direction, shade); + } + + @Override + public LevelLightEngine getLightEngine() { + return this.level.getLightEngine(); + } + + @Override + public int getBrightness(LightLayer lightType, BlockPos pos) { + return FULL_LIGHT; + } + + @Override + public int getRawBrightness(BlockPos pos, int amount) { + return FULL_LIGHT; + } + + @Override + public int getBlockTint(BlockPos pos, ColorResolver resolver) { + return this.level.getBlockTint(pos, resolver); + } + + @Override + public int getHeight() { + return this.level.getHeight(); + } + + @Override + public int getMinBuildHeight() { + return this.level.getMinBuildHeight(); + } +} diff --git a/src/main/java/de/lucas/litematica/render/SchematicRenderer.java b/src/main/java/de/lucas/litematica/render/SchematicRenderer.java new file mode 100644 index 0000000..fda8712 --- /dev/null +++ b/src/main/java/de/lucas/litematica/render/SchematicRenderer.java @@ -0,0 +1,463 @@ +package de.lucas.litematica.render; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexBuffer; +import com.mojang.logging.LogUtils; +import de.lucas.litematica.config.ModConfig; +import de.lucas.litematica.config.RenderMode; +import de.lucas.litematica.placement.PlacementManager; +import de.lucas.litematica.placement.PlacementView; +import de.lucas.litematica.placement.SchematicPlacement; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.ShaderInstance; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.culling.Frustum; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.client.event.RenderLevelStageEvent; +import org.jetbrains.annotations.Nullable; +import org.joml.Matrix4f; +import org.slf4j.Logger; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Renders all placements of the current dimension. + *

+ * Design (similar in spirit to vanilla's chunk renderer): placements are split into 16x16x16 world sections. + * Each section is meshed once on a background thread into GPU vertex buffers and only rebuilt when the placement + * changes or a block in that world section changes (reported by {@code LevelRendererMixin}). A frame then only + * binds and draws cached buffers, so even schematics with hundreds of thousands of blocks cost little per frame. + *

+ * Drawing happens in {@link RenderLevelStageEvent.Stage#AFTER_TRANSLUCENT_BLOCKS} using vanilla shaders, which + * keeps it compatible with the normal renderer and with mods that only modify chunk rendering. + */ +public final class SchematicRenderer { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final SchematicRenderer INSTANCE = new SchematicRenderer(); + private static final long UPLOAD_BUDGET_NANOS = 3_000_000L; + private static final BlockState AIR = Blocks.AIR.defaultBlockState(); + + private final Map renderers = new HashMap<>(); + private final ConcurrentLinkedQueue results = new ConcurrentLinkedQueue<>(); + private final ConcurrentLinkedQueue dirtyWorldSections = new ConcurrentLinkedQueue<>(); + private final AtomicInteger inFlight = new AtomicInteger(); + private final GhostBufferSource blockEntityBuffers = new GhostBufferSource(); + @Nullable + private ExecutorService workers; + private int threads; + @Nullable + private MultiBufferSource.BufferSource lineBuffers; + @Nullable + private RenderMode lastMode; + @Nullable + private ClientLevel lastLevel; + + public static SchematicRenderer get() { + return INSTANCE; + } + + /** Called from the level renderer whenever vanilla marks a world section for re-rendering. Any thread. */ + public void onWorldSectionDirty(int x, int y, int z) { + if (!this.renderers.isEmpty()) { + this.dirtyWorldSections.add(SectionPos.asLong(x, y, z)); + } + } + + /** Marks a whole chunk column dirty, used when chunks (re)load. */ + public void onChunkLoaded(int chunkX, int chunkZ) { + ClientLevel level = Minecraft.getInstance().level; + if (level == null || this.renderers.isEmpty()) { + return; + } + for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) { + this.dirtyWorldSections.add(SectionPos.asLong(chunkX, sy, chunkZ)); + } + } + + /** Main thread, once per client tick: syncs placements and schedules section builds. */ + public void tick() { + Minecraft mc = Minecraft.getInstance(); + ClientLevel level = mc.level; + if (level != this.lastLevel) { + this.clear(); + this.lastLevel = level; + } + if (level == null) { + return; + } + + RenderMode mode = ModConfig.RENDER_MODE.get(); + if (mode != this.lastMode) { + this.lastMode = mode; + this.renderers.values().forEach(PlacementRenderer::markAllDirty); + } + + this.syncPlacements(); + + Long key; + while ((key = this.dirtyWorldSections.poll()) != null) { + for (PlacementRenderer renderer : this.renderers.values()) { + renderer.markDirty(key); + } + } + + Vec3 cam = mc.gameRenderer.getMainCamera().getPosition(); + int camX = SectionPos.posToSectionCoord(cam.x); + int camY = SectionPos.posToSectionCoord(cam.y); + int camZ = SectionPos.posToSectionCoord(cam.z); + int distance = ModConfig.RENDER_DISTANCE.get(); + + List candidates = new ArrayList<>(); + Map owners = new HashMap<>(); + for (PlacementRenderer renderer : this.renderers.values()) { + int before = candidates.size(); + renderer.update(camX, camY, camZ, distance, candidates); + for (int i = before; i < candidates.size(); i++) { + owners.put(candidates.get(i), renderer); + } + renderer.updateStats(mode.comparesWorld()); + } + this.schedule(candidates, owners, cam, level, mode); + } + + private void syncPlacements() { + Set wanted = new HashSet<>(); + for (SchematicPlacement placement : PlacementManager.get().inCurrentDimension()) { + if (placement.enabled()) { + wanted.add(placement.id()); + this.renderers.computeIfAbsent(placement.id(), id -> new PlacementRenderer(placement)); + } + } + this.renderers.entrySet().removeIf(entry -> { + boolean remove = !wanted.contains(entry.getKey()) + || !PlacementManager.get().all().contains(entry.getValue().placement()); + if (remove) { + entry.getValue().closeAll(); + } + return remove; + }); + } + + private void schedule(List candidates, Map owners, Vec3 cam, + ClientLevel level, RenderMode mode) { + int free = this.threads() * 2 - this.inFlight.get(); + if (free <= 0 || candidates.isEmpty()) { + return; + } + candidates.sort(Comparator.comparingDouble(s -> s.bounds().getCenter().distanceToSqr(cam))); + ExecutorService pool = this.workers(); + for (int i = 0; i < candidates.size() && free > 0; i++, free--) { + RenderSection section = candidates.get(i); + PlacementRenderer owner = owners.get(section); + PlacementView view = owner.view(); + if (view == null) { + continue; + } + // The world copy is taken here on the main thread, so workers never read live chunk data. + BlockState[] world = mode.comparesWorld() ? snapshotWorld(level, section.key()) : null; + SectionMeshBuilder.Task task = new SectionMeshBuilder.Task(owner, section, view, owner.generation(), + section.dirtyVersion(), world, mode, level); + section.setBuilding(true); + this.inFlight.incrementAndGet(); + pool.execute(() -> { + try { + this.results.add(SectionMeshBuilder.build(task)); + } catch (Throwable t) { + LOGGER.error("Failed to build schematic section", t); + this.results.add(new SectionMeshBuilder.Result(task, null, null, null, null, new long[0], new int[BlockStatus.COUNT])); + } finally { + this.inFlight.decrementAndGet(); + } + }); + } + } + + /** Copies the world's block states of one section, or {@code null} if the chunk is not loaded. */ + @Nullable + private static BlockState[] snapshotWorld(ClientLevel level, long key) { + int sx = SectionPos.x(key), sy = SectionPos.y(key), sz = SectionPos.z(key); + if (!level.getChunkSource().hasChunk(sx, sz)) { + return null; + } + BlockState[] states = new BlockState[4096]; + LevelChunk chunk = level.getChunk(sx, sz); + int index = level.getSectionIndexFromSectionY(sy); + if (index < 0 || index >= chunk.getSections().length || chunk.getSection(index).hasOnlyAir()) { + Arrays.fill(states, AIR); + return states; + } + LevelChunkSection section = chunk.getSection(index); + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + states[(y << 8) | (z << 4) | x] = section.getBlockState(x, y, z); + } + } + } + return states; + } + + private void uploadResults() { + long deadline = System.nanoTime() + UPLOAD_BUDGET_NANOS; + SectionMeshBuilder.Result result; + while (System.nanoTime() < deadline && (result = this.results.poll()) != null) { + SectionMeshBuilder.Task task = result.task(); + RenderSection section = task.section(); + section.setBuilding(false); + PlacementRenderer owner = task.owner(); + boolean current = this.renderers.get(owner.placement().id()) == owner + && owner.generation() == task.generation() + && owner.section(section.key()) == section; + if (current) { + section.upload(result); + } else { + result.discard(); + } + } + } + + public void render(RenderLevelStageEvent event) { + if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_TRANSLUCENT_BLOCKS) { + return; + } + this.uploadResults(); + if (!ModConfig.RENDERING_ENABLED.get() || this.renderers.isEmpty()) { + return; + } + Minecraft mc = Minecraft.getInstance(); + ClientLevel level = mc.level; + if (level == null) { + return; + } + + Camera camera = event.getCamera(); + Vec3 cam = camera.getPosition(); + Frustum frustum = event.getFrustum(); + Matrix4f modelView = event.getModelViewMatrix(); + Matrix4f projection = event.getProjectionMatrix(); + int distance = ModConfig.RENDER_DISTANCE.get(); + int camX = SectionPos.posToSectionCoord(cam.x), camY = SectionPos.posToSectionCoord(cam.y), camZ = SectionPos.posToSectionCoord(cam.z); + + List visible = new ArrayList<>(); + List visibleOwners = new ArrayList<>(); + for (PlacementRenderer renderer : this.renderers.values()) { + for (RenderSection section : renderer.sections()) { + if (!section.hasData()) continue; + int sx = SectionPos.x(section.key()), sy = SectionPos.y(section.key()), sz = SectionPos.z(section.key()); + if (Math.max(Math.abs(sx - camX), Math.max(Math.abs(sy - camY), Math.abs(sz - camZ))) > distance) continue; + if (!frustum.isVisible(section.bounds())) continue; + visible.add(section); + visibleOwners.add(renderer); + } + } + + this.drawGhosts(visible, cam, modelView, projection); + this.drawOverlays(visible, cam, modelView, projection); + if (ModConfig.RENDER_BLOCK_ENTITIES.get()) { + this.drawBlockEntities(visible, visibleOwners, cam, level, event.getPartialTick().getGameTimeDeltaPartialTick(false)); + } + if (ModConfig.SHOW_BOUNDING_BOX.get()) { + this.drawBoundingBoxes(cam); + } + } + + private void drawGhosts(List sections, Vec3 cam, Matrix4f modelView, Matrix4f projection) { + // Block atlas, lightmap and render state of the cutout layer, with blending on: the cutout shader discards + // texels below 10% alpha after applying ColorModulator, so transparent texture parts stay invisible. + RenderType.cutoutMipped().setupRenderState(); + RenderSystem.setShader(GameRenderer::getRendertypeCutoutShader); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, ModConfig.GHOST_ALPHA.get().floatValue()); + boolean overlapping = ModConfig.RENDER_MODE.get() == RenderMode.ALL; + if (overlapping) { + // In ALL mode ghosts coincide with real blocks; pull them forward to avoid z-fighting. + RenderSystem.enablePolygonOffset(); + RenderSystem.polygonOffset(-1.0f, -10.0f); + } + ShaderInstance shader = RenderSystem.getShader(); + for (RenderSection section : sections) { + VertexBuffer buffer = section.ghost(); + if (buffer != null && shader != null) { + buffer.bind(); + buffer.drawWithShader(sectionMatrix(modelView, section, cam), projection, shader); + } + } + VertexBuffer.unbind(); + if (overlapping) { + RenderSystem.polygonOffset(0.0f, 0.0f); + RenderSystem.disablePolygonOffset(); + } + RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f); + RenderSystem.disableBlend(); + RenderType.cutoutMipped().clearRenderState(); + } + + private void drawOverlays(List sections, Vec3 cam, Matrix4f modelView, Matrix4f projection) { + RenderSystem.setShader(GameRenderer::getPositionColorShader); + ShaderInstance shader = RenderSystem.getShader(); + if (shader == null) { + return; + } + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + RenderSystem.enableDepthTest(); + RenderSystem.depthMask(false); + RenderSystem.disableCull(); + RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, ModConfig.OVERLAY_ALPHA.get().floatValue()); + for (RenderSection section : sections) { + VertexBuffer buffer = section.overlay(); + if (buffer != null) { + buffer.bind(); + buffer.drawWithShader(sectionMatrix(modelView, section, cam), projection, shader); + } + } + VertexBuffer.unbind(); + RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f); + RenderSystem.enableCull(); + RenderSystem.depthMask(true); + RenderSystem.disableBlend(); + } + + private void drawBlockEntities(List sections, List owners, Vec3 cam, ClientLevel level, float partialTick) { + Minecraft mc = Minecraft.getInstance(); + PoseStack pose = new PoseStack(); + boolean any = false; + for (int i = 0; i < sections.size(); i++) { + PlacementRenderer owner = owners.get(i); + for (long pos : sections.get(i).blockEntities()) { + BlockEntity be = owner.blockEntity(pos, level); + if (be == null) continue; + BlockEntityRenderer renderer = mc.getBlockEntityRenderDispatcher().getRenderer(be); + if (renderer == null) continue; + BlockPos p = be.getBlockPos(); + pose.pushPose(); + pose.translate(p.getX() - cam.x, p.getY() - cam.y, p.getZ() - cam.z); + try { + renderer.render(be, partialTick, pose, this.blockEntityBuffers, LightTexture.FULL_BRIGHT, OverlayTexture.NO_OVERLAY); + any = true; + } catch (Throwable t) { + if (PlacementRenderer.FAILED_BLOCK_ENTITIES.add(be.getType())) { + LOGGER.warn("Block entity renderer for {} failed in schematic and is disabled: {}", be.getType(), t.toString()); + } + } + pose.popPose(); + } + } + if (any) { + this.blockEntityBuffers.flush(ModConfig.GHOST_ALPHA.get().floatValue()); + } + } + + private void drawBoundingBoxes(Vec3 cam) { + MultiBufferSource.BufferSource buffers = this.lineBuffers(); + PoseStack pose = new PoseStack(); + pose.translate(-cam.x, -cam.y, -cam.z); + SchematicPlacement selected = PlacementManager.get().selected(); + for (PlacementRenderer renderer : this.renderers.values()) { + PlacementView view = renderer.view(); + if (view == null) continue; + AABB box = AABB.of(view.box()); + if (renderer.placement() == selected) { + LevelRenderer.renderLineBox(pose, buffers.getBuffer(RenderType.lines()), box, 1.0f, 1.0f, 1.0f, 1.0f); + BlockPos o = view.origin(); + LevelRenderer.renderLineBox(pose, buffers.getBuffer(RenderType.lines()), + new AABB(o).inflate(0.02), 0.2f, 0.9f, 1.0f, 1.0f); + } else { + LevelRenderer.renderLineBox(pose, buffers.getBuffer(RenderType.lines()), box, 0.6f, 0.6f, 0.6f, 0.6f); + } + } + buffers.endBatch(RenderType.lines()); + } + + private static Matrix4f sectionMatrix(Matrix4f modelView, RenderSection section, Vec3 cam) { + return new Matrix4f(modelView).translate( + (float) (section.originX() - cam.x), (float) (section.originY() - cam.y), (float) (section.originZ() - cam.z)); + } + + private MultiBufferSource.BufferSource lineBuffers() { + if (this.lineBuffers == null) { + this.lineBuffers = MultiBufferSource.immediate(new ByteBufferBuilder(1 << 16)); + } + return this.lineBuffers; + } + + private int threads() { + if (this.threads == 0) { + this.threads = ModConfig.BUILDER_THREADS.get(); + } + return this.threads; + } + + private ExecutorService workers() { + if (this.workers == null) { + AtomicInteger counter = new AtomicInteger(); + this.workers = Executors.newFixedThreadPool(this.threads(), r -> { + Thread thread = new Thread(r, "NeoLitematica Mesh Builder " + counter.incrementAndGet()); + thread.setDaemon(true); + thread.setPriority(Thread.NORM_PRIORITY - 1); + return thread; + }); + } + return this.workers; + } + + /** Frees all GPU buffers, e.g. on world change or logout. Render thread only. */ + public void clear() { + this.renderers.values().forEach(PlacementRenderer::closeAll); + this.renderers.clear(); + this.dirtyWorldSections.clear(); + SectionMeshBuilder.Result result; + while ((result = this.results.poll()) != null) { + result.task().section().setBuilding(false); + result.discard(); + } + this.blockEntityBuffers.close(); + } + + /** Forces all sections to rebuild, e.g. after resource packs reload. */ + public void markAllDirty() { + this.renderers.values().forEach(PlacementRenderer::markAllDirty); + } + + /** Number of sections still waiting for a build; used by the HUD and the smoke test. */ + public int pendingSections() { + int pending = this.inFlight.get() + this.results.size(); + for (PlacementRenderer renderer : this.renderers.values()) { + for (RenderSection section : renderer.sections()) { + if (section.needsBuild()) pending++; + } + } + return pending; + } +} diff --git a/src/main/java/de/lucas/litematica/render/SectionMeshBuilder.java b/src/main/java/de/lucas/litematica/render/SectionMeshBuilder.java new file mode 100644 index 0000000..3b4d298 --- /dev/null +++ b/src/main/java/de/lucas/litematica/render/SectionMeshBuilder.java @@ -0,0 +1,243 @@ +package de.lucas.litematica.render; + +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.MeshData; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexFormat; +import com.mojang.logging.LogUtils; +import de.lucas.litematica.config.RenderMode; +import de.lucas.litematica.placement.PlacementView; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.block.BlockRenderDispatcher; +import net.minecraft.client.resources.model.BakedModel; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.material.FluidState; +import net.neoforged.neoforge.client.model.data.ModelData; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Builds the meshes of one 16x16x16 section of a placement. Runs on a worker thread and only touches immutable + * data: the {@link PlacementView}, a copy of the world's block states taken on the main thread, and the + * thread-safe parts of the client (block models, block colours, biome tint cache), like vanilla's section builder. + */ +final class SectionMeshBuilder { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final Set FAILED_BLOCKS = ConcurrentHashMap.newKeySet(); + /** Overlay boxes are grown slightly so they do not z-fight with the block they mark. */ + private static final float OVERLAY_GROW = 0.003f; + + record Task(PlacementRenderer owner, RenderSection section, PlacementView view, int generation, int dirtyVersion, + @Nullable BlockState[] world, RenderMode mode, ClientLevel level) { + } + + record Result(Task task, @Nullable MeshData ghost, @Nullable ByteBufferBuilder ghostMemory, + @Nullable MeshData overlay, @Nullable ByteBufferBuilder overlayMemory, + long[] blockEntities, int[] counts) { + void discard() { + if (this.ghost != null) this.ghost.close(); + if (this.ghostMemory != null) this.ghostMemory.close(); + if (this.overlay != null) this.overlay.close(); + if (this.overlayMemory != null) this.overlayMemory.close(); + } + } + + private SectionMeshBuilder() { + } + + static Result build(Task task) { + RenderSection section = task.section(); + PlacementView view = task.view(); + BoundingBox box = view.box(); + int ox = section.originX(), oy = section.originY(), oz = section.originZ(); + int x0 = Math.max(ox, box.minX()), x1 = Math.min(ox + 15, box.maxX()); + int y0 = Math.max(oy, box.minY()), y1 = Math.min(oy + 15, box.maxY()); + int z0 = Math.max(oz, box.minZ()), z1 = Math.min(oz + 15, box.maxZ()); + + BlockPos.MutableBlockPos scratch = new BlockPos.MutableBlockPos(); + BlockState[] states = new BlockState[4096]; + byte[] status = new byte[4096]; + int[] counts = new int[BlockStatus.COUNT]; + int ghosts = 0; + + for (int y = y0; y <= y1; y++) { + for (int z = z0; z <= z1; z++) { + for (int x = x0; x <= x1; x++) { + int index = index(x, y, z); + BlockState state = view.getState(x, y, z, scratch); + boolean unknown = state.isAir() && view.isUnknown(x, y, z, scratch); + BlockState world = task.world() == null ? null : task.world()[index]; + byte s = BlockStatus.classify(state, unknown, world, task.mode()); + states[index] = state; + status[index] = s; + counts[s]++; + if (BlockStatus.showsGhost(s)) { + ghosts++; + } + } + } + } + + MeshData ghostMesh = null; + ByteBufferBuilder ghostMemory = null; + LongArrayList blockEntities = new LongArrayList(); + if (ghosts > 0) { + ghostMemory = new ByteBufferBuilder(Math.min(4 << 20, 4096 + ghosts * 1024)); + BufferBuilder builder = new BufferBuilder(ghostMemory, VertexFormat.Mode.QUADS, DefaultVertexFormat.BLOCK); + SchematicBlockGetter getter = new SchematicBlockGetter(view, task.level()); + BlockRenderDispatcher dispatcher = Minecraft.getInstance().getBlockRenderer(); + RandomSource random = RandomSource.create(); + PoseStack pose = new PoseStack(); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + + for (int y = y0; y <= y1; y++) { + for (int z = z0; z <= z1; z++) { + for (int x = x0; x <= x1; x++) { + int index = index(x, y, z); + if (!BlockStatus.showsGhost(status[index])) { + continue; + } + BlockState state = states[index]; + pos.set(x, y, z); + renderBlock(state, pos, getter, dispatcher, builder, pose, random); + if (state.hasBlockEntity()) { + blockEntities.add(pos.asLong()); + } + } + } + } + ghostMesh = builder.build(); + if (ghostMesh == null) { + ghostMemory.close(); + ghostMemory = null; + } + } + + MeshData overlayMesh = null; + ByteBufferBuilder overlayMemory = null; + int overlays = 0; + for (int i = 0; i < 4096; i++) { + if (status[i] != BlockStatus.NONE && BlockStatus.overlayColor(status[i], task.mode()) != 0) { + overlays++; + } + } + if (overlays > 0) { + overlayMemory = new ByteBufferBuilder(Math.min(4 << 20, overlays * 24 * 16)); + BufferBuilder builder = new BufferBuilder(overlayMemory, VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR); + for (int y = y0; y <= y1; y++) { + for (int z = z0; z <= z1; z++) { + for (int x = x0; x <= x1; x++) { + int index = index(x, y, z); + int color = BlockStatus.overlayColor(status[index], task.mode()); + if (color != 0) { + addOverlayBox(builder, status, x & 15, y & 15, z & 15, color); + } + } + } + } + overlayMesh = builder.build(); + if (overlayMesh == null) { + overlayMemory.close(); + overlayMemory = null; + } + } + + return new Result(task, ghostMesh, ghostMemory, overlayMesh, overlayMemory, blockEntities.toLongArray(), counts); + } + + private static void renderBlock(BlockState state, BlockPos pos, SchematicBlockGetter getter, BlockRenderDispatcher dispatcher, + BufferBuilder builder, PoseStack pose, RandomSource random) { + Block block = state.getBlock(); + if (FAILED_BLOCKS.contains(block)) { + return; + } + try { + if (state.getRenderShape() == RenderShape.MODEL) { + BakedModel model = dispatcher.getBlockModel(state); + ModelData modelData = model.getModelData(getter, pos, state, ModelData.EMPTY); + random.setSeed(state.getSeed(pos)); + pose.pushPose(); + pose.translate(pos.getX() & 15, pos.getY() & 15, pos.getZ() & 15); + for (RenderType renderType : model.getRenderTypes(state, random, modelData)) { + dispatcher.renderBatched(state, pos, getter, pose, builder, true, random, modelData, renderType); + } + pose.popPose(); + } + FluidState fluid = state.getFluidState(); + if (!fluid.isEmpty()) { + // The liquid renderer positions vertices relative to the section itself. + dispatcher.renderLiquid(pos, getter, builder, state, fluid); + } + } catch (Throwable t) { + // Third-party models occasionally assume a real Level; skip that block type instead of failing the section. + if (FAILED_BLOCKS.add(block)) { + LOGGER.warn("Cannot render {} in schematic, it will be skipped: {}", state, t.toString()); + } + } + } + + /** Adds the faces of a highlight box, skipping faces shared with a same-status neighbour in this section. */ + private static void addOverlayBox(BufferBuilder b, byte[] status, int x, int y, int z, int argb) { + byte s = status[(y << 8) | (z << 4) | x]; + int a = argb >>> 24, r = (argb >> 16) & 0xFF, g = (argb >> 8) & 0xFF, bl = argb & 0xFF; + float minX = x - OVERLAY_GROW, minY = y - OVERLAY_GROW, minZ = z - OVERLAY_GROW; + float maxX = x + 1 + OVERLAY_GROW, maxY = y + 1 + OVERLAY_GROW, maxZ = z + 1 + OVERLAY_GROW; + + if (!same(status, s, x, y - 1, z)) { + quad(b, r, g, bl, a, minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ, minX, minY, maxZ); + } + if (!same(status, s, x, y + 1, z)) { + quad(b, r, g, bl, a, minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ); + } + if (!same(status, s, x, y, z - 1)) { + quad(b, r, g, bl, a, minX, minY, minZ, minX, maxY, minZ, maxX, maxY, minZ, maxX, minY, minZ); + } + if (!same(status, s, x, y, z + 1)) { + quad(b, r, g, bl, a, minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ, minX, maxY, maxZ); + } + if (!same(status, s, x - 1, y, z)) { + quad(b, r, g, bl, a, minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ, minX, maxY, minZ); + } + if (!same(status, s, x + 1, y, z)) { + quad(b, r, g, bl, a, maxX, minY, minZ, maxX, maxY, minZ, maxX, maxY, maxZ, maxX, minY, maxZ); + } + } + + private static boolean same(byte[] status, byte s, int x, int y, int z) { + if (x < 0 || y < 0 || z < 0 || x > 15 || y > 15 || z > 15) { + return false; + } + return status[(y << 8) | (z << 4) | x] == s; + } + + private static void quad(BufferBuilder b, int r, int g, int bl, int a, + float x1, float y1, float z1, float x2, float y2, float z2, + float x3, float y3, float z3, float x4, float y4, float z4) { + b.addVertex(x1, y1, z1).setColor(r, g, bl, a); + b.addVertex(x2, y2, z2).setColor(r, g, bl, a); + b.addVertex(x3, y3, z3).setColor(r, g, bl, a); + b.addVertex(x4, y4, z4).setColor(r, g, bl, a); + } + + static int index(int x, int y, int z) { + return ((y & 15) << 8) | ((z & 15) << 4) | (x & 15); + } + + static long sectionKey(int sx, int sy, int sz) { + return SectionPos.asLong(sx, sy, sz); + } +} diff --git a/src/main/java/de/lucas/litematica/schematic/LitematicBitArray.java b/src/main/java/de/lucas/litematica/schematic/LitematicBitArray.java new file mode 100644 index 0000000..bf864ea --- /dev/null +++ b/src/main/java/de/lucas/litematica/schematic/LitematicBitArray.java @@ -0,0 +1,45 @@ +package de.lucas.litematica.schematic; + +/** + * Read-only view of Litematica's packed block state index array. + *

+ * Unlike vanilla's {@code SimpleBitStorage}, Litematica packs entries tightly: an entry may span + * two neighbouring longs. Entry bit width is {@code max(2, ceil(log2(paletteSize)))}. + */ +public final class LitematicBitArray { + private final long[] data; + private final int bitsPerEntry; + private final long mask; + private final long size; + + public LitematicBitArray(int bitsPerEntry, long size, long[] data) { + long required = (size * bitsPerEntry + 63L) / 64L; + if (data.length < required) { + throw new IllegalArgumentException("Block state array too short: expected " + required + " longs, got " + data.length); + } + this.data = data; + this.bitsPerEntry = bitsPerEntry; + this.mask = (1L << bitsPerEntry) - 1L; + this.size = size; + } + + public int get(long index) { + long startBit = index * this.bitsPerEntry; + int startLong = (int) (startBit >> 6); + int endLong = (int) (((index + 1L) * this.bitsPerEntry - 1L) >> 6); + int offset = (int) (startBit & 63L); + + if (startLong == endLong) { + return (int) (this.data[startLong] >>> offset & this.mask); + } + return (int) ((this.data[startLong] >>> offset | this.data[endLong] << (64 - offset)) & this.mask); + } + + public long size() { + return this.size; + } + + public static int bitsFor(int paletteSize) { + return Math.max(2, 32 - Integer.numberOfLeadingZeros(Math.max(1, paletteSize - 1))); + } +} diff --git a/src/main/java/de/lucas/litematica/schematic/LitematicReader.java b/src/main/java/de/lucas/litematica/schematic/LitematicReader.java new file mode 100644 index 0000000..82e0929 --- /dev/null +++ b/src/main/java/de/lucas/litematica/schematic/LitematicReader.java @@ -0,0 +1,237 @@ +package de.lucas.litematica.schematic; + +import com.mojang.datafixers.DataFixer; +import com.mojang.logging.LogUtils; +import com.mojang.serialization.Dynamic; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Vec3i; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.util.datafix.fixes.References; +import net.minecraft.world.level.block.state.BlockState; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Reads Litematica's {@code .litematic} format (gzip-compressed NBT, format versions 1 to 7). + *

+ * Layout: {@code Version}, {@code MinecraftDataVersion}, {@code Metadata{...}} and {@code Regions{name: {Position, + * Size, BlockStatePalette, BlockStates, TileEntities, ...}}}. A region's {@code Size} may be negative on any axis, + * meaning the region extends from {@code Position} in the negative direction. Blocks are indexed as + * {@code (y * sizeZ + z) * sizeX + x} relative to the region's minimum corner. + *

+ * Schematics saved by older Minecraft versions are upgraded through Minecraft's own DataFixer, the same way the + * game upgrades old worlds. + */ +public final class LitematicReader { + private static final Logger LOGGER = LogUtils.getLogger(); + /** Litematica files without {@code MinecraftDataVersion} predate it; 1139 is the first 1.13 data version. */ + private static final int FALLBACK_DATA_VERSION = 1139; + private static final int NEWEST_KNOWN_FORMAT = 7; + + private LitematicReader() { + } + + public static LitematicSchematic read(Path file) throws SchematicLoadException { + CompoundTag root = readRoot(file); + SchematicMetadata metadata = readMetadata(root); + + if (!root.contains("Regions", Tag.TAG_COMPOUND)) { + throw new SchematicLoadException("File contains no 'Regions' tag - not a Litematica schematic"); + } + if (metadata.formatVersion() > NEWEST_KNOWN_FORMAT) { + LOGGER.warn("{} uses schematic format version {}, newest known is {}; trying anyway", + file.getFileName(), metadata.formatVersion(), NEWEST_KNOWN_FORMAT); + } + + int currentDataVersion = SharedConstants.getCurrentVersion().getDataVersion().getVersion(); + int fileDataVersion = metadata.dataVersion(); + DataFixer fixer = fileDataVersion < currentDataVersion ? DataFixers.getDataFixer() : null; + + CompoundTag regionsTag = root.getCompound("Regions"); + List raws = new ArrayList<>(); + Set unknownBlocks = new LinkedHashSet<>(); + for (String name : regionsTag.getAllKeys()) { + RawRegion raw = readRegion(name, regionsTag.getCompound(name), fixer, fileDataVersion, currentDataVersion, unknownBlocks); + if (raw != null) { + raws.add(raw); + } + } + if (raws.isEmpty()) { + throw new SchematicLoadException("Schematic contains no non-empty regions"); + } + + int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE; + for (RawRegion raw : raws) { + minX = Math.min(minX, raw.min.getX()); + minY = Math.min(minY, raw.min.getY()); + minZ = Math.min(minZ, raw.min.getZ()); + maxX = Math.max(maxX, raw.min.getX() + raw.sizeX); + maxY = Math.max(maxY, raw.min.getY() + raw.sizeY); + maxZ = Math.max(maxZ, raw.min.getZ() + raw.sizeZ); + } + + List regions = new ArrayList<>(raws.size()); + long nonAir = 0; + for (RawRegion raw : raws) { + BlockPos normalizedMin = raw.min.offset(-minX, -minY, -minZ); + SchematicRegion region = new SchematicRegion(raw.name, normalizedMin, raw.sizeX, raw.sizeY, raw.sizeZ, + raw.palette, raw.unknown, raw.blocks, raw.blockEntities); + nonAir += validateAndCount(region); + regions.add(region); + } + + Vec3i size = new Vec3i(maxX - minX, maxY - minY, maxZ - minZ); + if (!unknownBlocks.isEmpty()) { + LOGGER.warn("{} references {} block(s) that are not installed: {}", file.getFileName(), unknownBlocks.size(), unknownBlocks); + } + return new LitematicSchematic(file, metadata, regions, size, nonAir, unknownBlocks); + } + + /** Reads only the header. Still decompresses the whole file, but skips block decoding. */ + public static SchematicMetadata readMetadata(Path file) throws SchematicLoadException { + return readMetadata(readRoot(file)); + } + + private static CompoundTag readRoot(Path file) throws SchematicLoadException { + if (!Files.isRegularFile(file)) { + throw new SchematicLoadException("File not found: " + file); + } + try { + return NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap()); + } catch (IOException | RuntimeException e) { + throw new SchematicLoadException("Not a valid compressed NBT file: " + e.getMessage(), e); + } + } + + private static SchematicMetadata readMetadata(CompoundTag root) throws SchematicLoadException { + if (!root.contains("Version", Tag.TAG_ANY_NUMERIC)) { + throw new SchematicLoadException("Missing 'Version' tag - not a Litematica schematic"); + } + int version = root.getInt("Version"); + int dataVersion = root.contains("MinecraftDataVersion", Tag.TAG_ANY_NUMERIC) ? root.getInt("MinecraftDataVersion") : FALLBACK_DATA_VERSION; + CompoundTag meta = root.getCompound("Metadata"); + CompoundTag enclosing = meta.getCompound("EnclosingSize"); + return new SchematicMetadata( + meta.getString("Name"), + meta.getString("Author"), + meta.getString("Description"), + version, + dataVersion, + meta.getInt("RegionCount"), + meta.getLong("TotalBlocks"), + meta.getLong("TotalVolume"), + new Vec3i(enclosing.getInt("x"), enclosing.getInt("y"), enclosing.getInt("z")), + meta.getLong("TimeCreated"), + meta.getLong("TimeModified")); + } + + private static RawRegion readRegion(String name, CompoundTag tag, DataFixer fixer, int fromVersion, int toVersion, + Set unknownBlocks) throws SchematicLoadException { + BlockPos pos = readVec(tag.getCompound("Position")); + BlockPos size = readVec(tag.getCompound("Size")); + if (size.getX() == 0 || size.getY() == 0 || size.getZ() == 0) { + return null; + } + // Negative sizes extend from Position towards negative coordinates (Position is then the max corner). + BlockPos min = new BlockPos( + size.getX() < 0 ? pos.getX() + size.getX() + 1 : pos.getX(), + size.getY() < 0 ? pos.getY() + size.getY() + 1 : pos.getY(), + size.getZ() < 0 ? pos.getZ() + size.getZ() + 1 : pos.getZ()); + int sx = Math.abs(size.getX()); + int sy = Math.abs(size.getY()); + int sz = Math.abs(size.getZ()); + + ListTag paletteTag = tag.getList("BlockStatePalette", Tag.TAG_COMPOUND); + if (paletteTag.isEmpty()) { + throw new SchematicLoadException("Region '" + name + "' has an empty block state palette"); + } + BlockState[] palette = new BlockState[paletteTag.size()]; + boolean[] unknown = new boolean[paletteTag.size()]; + for (int i = 0; i < palette.length; i++) { + CompoundTag entry = paletteTag.getCompound(i); + if (fixer != null) { + entry = (CompoundTag) fixer.update(References.BLOCK_STATE, new Dynamic<>(NbtOps.INSTANCE, entry), fromVersion, toVersion).getValue(); + } + String blockName = entry.getString("Name"); + ResourceLocation id = ResourceLocation.tryParse(blockName); + if (id == null || !BuiltInRegistries.BLOCK.containsKey(id)) { + unknown[i] = true; + unknownBlocks.add(blockName); + } + // Unknown blocks and invalid property values fall back to air / default values. + palette[i] = NbtUtils.readBlockState(BuiltInRegistries.BLOCK.asLookup(), entry); + } + + long volume = (long) sx * sy * sz; + long[] data = tag.getLongArray("BlockStates"); + LitematicBitArray blocks; + try { + blocks = new LitematicBitArray(LitematicBitArray.bitsFor(palette.length), volume, data); + } catch (IllegalArgumentException e) { + throw new SchematicLoadException("Region '" + name + "': " + e.getMessage(), e); + } + + Long2ObjectMap blockEntities = new Long2ObjectOpenHashMap<>(); + ListTag beList = tag.getList("TileEntities", Tag.TAG_COMPOUND); + for (int i = 0; i < beList.size(); i++) { + CompoundTag be = beList.getCompound(i); + if (fixer != null && be.contains("id", Tag.TAG_STRING)) { + be = (CompoundTag) fixer.update(References.BLOCK_ENTITY, new Dynamic<>(NbtOps.INSTANCE, be), fromVersion, toVersion).getValue(); + } + blockEntities.put(BlockPos.asLong(be.getInt("x"), be.getInt("y"), be.getInt("z")), be); + } + + return new RawRegion(name, min, sx, sy, sz, palette, unknown, blocks, blockEntities); + } + + /** Ensures every stored index is inside the palette and counts non-air blocks. */ + private static long validateAndCount(SchematicRegion region) throws SchematicLoadException { + BlockState[] palette = region.palette(); + boolean[] counts = new boolean[palette.length]; + for (int i = 0; i < palette.length; i++) { + counts[i] = !palette[i].isAir() || region.unknown()[i]; + } + long volume = region.volume(); + long nonAir = 0; + LitematicBitArray blocks = region.blocks(); + for (long i = 0; i < volume; i++) { + int index = blocks.get(i); + if (index >= palette.length) { + throw new SchematicLoadException("Region '" + region.name() + "' is corrupt: palette index " + index + + " out of range (palette size " + palette.length + ")"); + } + if (counts[index]) { + nonAir++; + } + } + return nonAir; + } + + private static BlockPos readVec(CompoundTag tag) { + return new BlockPos(tag.getInt("x"), tag.getInt("y"), tag.getInt("z")); + } + + private record RawRegion(String name, BlockPos min, int sizeX, int sizeY, int sizeZ, BlockState[] palette, + boolean[] unknown, LitematicBitArray blocks, Long2ObjectMap blockEntities) { + } +} diff --git a/src/main/java/de/lucas/litematica/schematic/LitematicSchematic.java b/src/main/java/de/lucas/litematica/schematic/LitematicSchematic.java new file mode 100644 index 0000000..8ab0157 --- /dev/null +++ b/src/main/java/de/lucas/litematica/schematic/LitematicSchematic.java @@ -0,0 +1,107 @@ +package de.lucas.litematica.schematic; + +import net.minecraft.core.Vec3i; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +/** + * An immutable, fully loaded schematic. Safe to read from any thread, so render workers query it directly. + * Coordinates are relative to the minimum corner of the enclosing box of all regions. + */ +public final class LitematicSchematic { + private final Path file; + private final SchematicMetadata metadata; + private final List regions; + private final Vec3i size; + private final long nonAirBlocks; + private final Set unknownBlocks; + + LitematicSchematic(Path file, SchematicMetadata metadata, List regions, Vec3i size, long nonAirBlocks, Set unknownBlocks) { + this.file = file; + this.metadata = metadata; + this.regions = List.copyOf(regions); + this.size = size; + this.nonAirBlocks = nonAirBlocks; + this.unknownBlocks = Set.copyOf(unknownBlocks); + } + + public Path file() { + return this.file; + } + + public SchematicMetadata metadata() { + return this.metadata; + } + + public List regions() { + return this.regions; + } + + /** Size of the enclosing box of all regions. */ + public Vec3i size() { + return this.size; + } + + /** Number of non-air blocks actually contained in the block arrays. */ + public long nonAirBlocks() { + return this.nonAirBlocks; + } + + /** Registry names of blocks that are not available in the running game. */ + public Set unknownBlocks() { + return this.unknownBlocks; + } + + /** + * Finds the region defining the block at a schematic-local position, or {@code null} if no region covers it. + * Overlapping regions are resolved in file order; the first non-air entry wins. + */ + @Nullable + public SchematicRegion regionAt(int x, int y, int z) { + SchematicRegion fallback = null; + for (SchematicRegion region : this.regions) { + int rx = x - region.min().getX(); + int ry = y - region.min().getY(); + int rz = z - region.min().getZ(); + if (region.contains(rx, ry, rz)) { + int index = region.paletteIndex(rx, ry, rz); + if (!region.palette()[index].isAir() || region.unknown()[index]) { + return region; + } + fallback = region; + } + } + return fallback; + } + + public BlockState getBlockState(int x, int y, int z) { + SchematicRegion region = this.regionAt(x, y, z); + if (region == null) { + return Blocks.AIR.defaultBlockState(); + } + return region.getState(x - region.min().getX(), y - region.min().getY(), z - region.min().getZ()); + } + + @Nullable + public CompoundTag getBlockEntity(int x, int y, int z) { + SchematicRegion region = this.regionAt(x, y, z); + if (region == null) { + return null; + } + return region.getBlockEntity(x - region.min().getX(), y - region.min().getY(), z - region.min().getZ()); + } + + public String displayName() { + String name = this.metadata.name(); + if (name == null || name.isBlank()) { + return SchematicFiles.baseName(this.file); + } + return name; + } +} diff --git a/src/main/java/de/lucas/litematica/schematic/SchematicFiles.java b/src/main/java/de/lucas/litematica/schematic/SchematicFiles.java new file mode 100644 index 0000000..a56ae38 --- /dev/null +++ b/src/main/java/de/lucas/litematica/schematic/SchematicFiles.java @@ -0,0 +1,110 @@ +package de.lucas.litematica.schematic; + +import net.neoforged.fml.loading.FMLPaths; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +/** + * File operations on the schematics directory. The directory is {@code .minecraft/schematics}, the same folder + * Litematica uses, so existing schematic collections work without copying. + */ +public final class SchematicFiles { + public static final String EXTENSION = ".litematic"; + + private SchematicFiles() { + } + + public static Path directory() { + Path dir = FMLPaths.GAMEDIR.get().resolve("schematics"); + try { + Files.createDirectories(dir); + } catch (IOException ignored) { + // Reported by the operations that actually need the directory. + } + return dir; + } + + /** All .litematic files below the schematics directory, sorted by relative path. */ + public static List list() throws IOException { + Path dir = directory(); + try (Stream stream = Files.walk(dir, 8)) { + return stream.filter(p -> Files.isRegularFile(p) && isSchematic(p)) + .sorted(Comparator.comparing(p -> dir.relativize(p).toString().toLowerCase())) + .toList(); + } + } + + public static boolean isSchematic(Path file) { + return file.getFileName().toString().toLowerCase().endsWith(EXTENSION); + } + + public static String baseName(Path file) { + String name = file.getFileName().toString(); + return name.toLowerCase().endsWith(EXTENSION) ? name.substring(0, name.length() - EXTENSION.length()) : name; + } + + /** Path relative to the schematics directory, with forward slashes; used to reference files in saved placements. */ + public static String relativeName(Path file) { + Path dir = directory().toAbsolutePath().normalize(); + Path abs = file.toAbsolutePath().normalize(); + return abs.startsWith(dir) ? dir.relativize(abs).toString().replace('\\', '/') : abs.toString(); + } + + public static Path resolve(String relativeName) { + Path path = Path.of(relativeName); + return path.isAbsolute() ? path : directory().resolve(relativeName).normalize(); + } + + /** + * Copies an external file into the schematics directory after validating it. Never overwrites: a numeric suffix + * is added when the name is taken. + */ + public static Path importFile(Path source) throws SchematicLoadException, IOException { + if (!isSchematic(source)) { + throw new SchematicLoadException("Only " + EXTENSION + " files are supported: " + source.getFileName()); + } + LitematicReader.readMetadata(source); + Path dir = directory(); + if (source.toAbsolutePath().normalize().startsWith(dir.toAbsolutePath().normalize())) { + return source; + } + Path target = freeName(dir, baseName(source)); + Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES); + return target; + } + + public static Path rename(Path file, String newBaseName) throws IOException { + String clean = sanitize(newBaseName); + if (clean.isEmpty()) { + throw new IOException("Invalid name"); + } + Path target = file.resolveSibling(clean + EXTENSION); + if (Files.exists(target)) { + throw new IOException("A schematic named '" + clean + "' already exists"); + } + return Files.move(file, target); + } + + public static void delete(Path file) throws IOException { + Files.deleteIfExists(file); + } + + private static Path freeName(Path dir, String base) { + String clean = sanitize(base); + Path target = dir.resolve(clean + EXTENSION); + for (int i = 2; Files.exists(target); i++) { + target = dir.resolve(clean + "_" + i + EXTENSION); + } + return target; + } + + private static String sanitize(String name) { + return name.trim().replaceAll("[\\\\/:*?\"<>|]", "_"); + } +} diff --git a/src/main/java/de/lucas/litematica/schematic/SchematicLoadException.java b/src/main/java/de/lucas/litematica/schematic/SchematicLoadException.java new file mode 100644 index 0000000..1f2f397 --- /dev/null +++ b/src/main/java/de/lucas/litematica/schematic/SchematicLoadException.java @@ -0,0 +1,12 @@ +package de.lucas.litematica.schematic; + +/** Thrown when a file cannot be read as a .litematic schematic. The message is shown to the user. */ +public class SchematicLoadException extends Exception { + public SchematicLoadException(String message) { + super(message); + } + + public SchematicLoadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/de/lucas/litematica/schematic/SchematicManager.java b/src/main/java/de/lucas/litematica/schematic/SchematicManager.java new file mode 100644 index 0000000..6ba6306 --- /dev/null +++ b/src/main/java/de/lucas/litematica/schematic/SchematicManager.java @@ -0,0 +1,92 @@ +package de.lucas.litematica.schematic; + +import com.mojang.logging.LogUtils; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Loads schematics off the main thread and caches them, so several placements of the same file share one copy. + * The cache is keyed by absolute path and invalidated when the file's modification time changes. + */ +public final class SchematicManager { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final SchematicManager INSTANCE = new SchematicManager(); + + private final ExecutorService loader = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "NeoLitematica Schematic Loader"); + thread.setDaemon(true); + return thread; + }); + private final Map cache = new ConcurrentHashMap<>(); + private final Map> pending = new ConcurrentHashMap<>(); + + public static SchematicManager get() { + return INSTANCE; + } + + /** Loads (or returns the cached) schematic. The future completes on the loader thread. */ + public CompletableFuture load(Path file) { + Path key = file.toAbsolutePath().normalize(); + Cached cached = this.cache.get(key); + if (cached != null && cached.modified.equals(modifiedTime(key))) { + return CompletableFuture.completedFuture(cached.schematic); + } + CompletableFuture future = this.pending.computeIfAbsent(key, k -> CompletableFuture.supplyAsync(() -> { + try { + long start = System.nanoTime(); + LitematicSchematic schematic = LitematicReader.read(k); + this.cache.put(k, new Cached(schematic, modifiedTime(k))); + LOGGER.info("Loaded schematic {} ({}x{}x{}, {} blocks, {} regions) in {} ms", k.getFileName(), + schematic.size().getX(), schematic.size().getY(), schematic.size().getZ(), + schematic.nonAirBlocks(), schematic.regions().size(), (System.nanoTime() - start) / 1_000_000); + return schematic; + } catch (SchematicLoadException e) { + throw new CompletionException(e); + } + }, this.loader)); + future.whenComplete((schematic, error) -> this.pending.remove(key, future)); + return future; + } + + /** Reads only the header of a file, for the schematic browser. */ + public CompletableFuture metadata(Path file) { + return CompletableFuture.supplyAsync(() -> { + try { + return LitematicReader.readMetadata(file); + } catch (SchematicLoadException e) { + throw new CompletionException(e); + } + }, this.loader); + } + + /** Unwraps the user-facing message of a failed {@link #load}. */ + public static String errorMessage(Throwable error) { + Throwable cause = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error; + return cause.getMessage() != null ? cause.getMessage() : cause.getClass().getSimpleName(); + } + + public void invalidate(Path file) { + this.cache.remove(file.toAbsolutePath().normalize()); + } + + private static FileTime modifiedTime(Path file) { + try { + return Files.getLastModifiedTime(file); + } catch (IOException e) { + return FileTime.fromMillis(0); + } + } + + private record Cached(LitematicSchematic schematic, FileTime modified) { + } +} diff --git a/src/main/java/de/lucas/litematica/schematic/SchematicMetadata.java b/src/main/java/de/lucas/litematica/schematic/SchematicMetadata.java new file mode 100644 index 0000000..8bf1e7f --- /dev/null +++ b/src/main/java/de/lucas/litematica/schematic/SchematicMetadata.java @@ -0,0 +1,19 @@ +package de.lucas.litematica.schematic; + +import net.minecraft.core.Vec3i; + +/** Header information of a .litematic file. */ +public record SchematicMetadata( + String name, + String author, + String description, + int formatVersion, + int dataVersion, + int regionCount, + long totalBlocks, + long totalVolume, + Vec3i enclosingSize, + long timeCreated, + long timeModified +) { +} diff --git a/src/main/java/de/lucas/litematica/schematic/SchematicRegion.java b/src/main/java/de/lucas/litematica/schematic/SchematicRegion.java new file mode 100644 index 0000000..b513a16 --- /dev/null +++ b/src/main/java/de/lucas/litematica/schematic/SchematicRegion.java @@ -0,0 +1,51 @@ +package de.lucas.litematica.schematic; + +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +/** + * One sub-region of a schematic. All block coordinates are relative to the region's minimum corner. + * + * @param name region name from the file + * @param min minimum corner inside the schematic's enclosing box (never negative) + * @param palette resolved block states (unknown blocks resolve to air) + * @param unknown {@code true} for palette entries whose block is not registered in this game + * @param blockEntities block entity NBT keyed by {@link BlockPos#asLong} of the region-local position + */ +public record SchematicRegion( + String name, + BlockPos min, + int sizeX, + int sizeY, + int sizeZ, + BlockState[] palette, + boolean[] unknown, + LitematicBitArray blocks, + Long2ObjectMap blockEntities +) { + public boolean contains(int x, int y, int z) { + return x >= 0 && y >= 0 && z >= 0 && x < this.sizeX && y < this.sizeY && z < this.sizeZ; + } + + /** Palette index at a region-local position; the caller must ensure {@link #contains}. */ + public int paletteIndex(int x, int y, int z) { + long index = ((long) y * this.sizeZ + z) * this.sizeX + x; + return this.blocks.get(index); + } + + public BlockState getState(int x, int y, int z) { + return this.palette[this.paletteIndex(x, y, z)]; + } + + @Nullable + public CompoundTag getBlockEntity(int x, int y, int z) { + return this.blockEntities.get(BlockPos.asLong(x, y, z)); + } + + public long volume() { + return (long) this.sizeX * this.sizeY * this.sizeZ; + } +} diff --git a/src/main/resources/assets/neolitematica/lang/de_de.json b/src/main/resources/assets/neolitematica/lang/de_de.json new file mode 100644 index 0000000..dbea735 --- /dev/null +++ b/src/main/resources/assets/neolitematica/lang/de_de.json @@ -0,0 +1,134 @@ +{ + "key.categories.neolitematica": "NeoLitematica", + "key.neolitematica.open_menu": "Hauptmenü öffnen", + "key.neolitematica.open_browser": "Schematic-Übersicht öffnen", + "key.neolitematica.edit_placement": "Ausgewähltes Placement bearbeiten", + "key.neolitematica.toggle_rendering": "Alle Schematics ein-/ausblenden", + "key.neolitematica.cycle_render_mode": "Rendering-Modus wechseln", + "key.neolitematica.next_placement": "Nächstes Placement auswählen", + "key.neolitematica.toggle_placement": "Ausgewähltes Placement ein-/ausblenden", + "key.neolitematica.move_to_player": "Placement zum Spieler verschieben", + "key.neolitematica.reset_position": "Placement-Position zurücksetzen", + "key.neolitematica.move_forward": "Placement nach vorne", + "key.neolitematica.move_back": "Placement nach hinten", + "key.neolitematica.move_left": "Placement nach links", + "key.neolitematica.move_right": "Placement nach rechts", + "key.neolitematica.move_up": "Placement nach oben", + "key.neolitematica.move_down": "Placement nach unten", + "key.neolitematica.rotate_cw": "Placement im Uhrzeigersinn drehen", + "key.neolitematica.rotate_ccw": "Placement gegen den Uhrzeigersinn drehen", + "key.neolitematica.mirror_x": "Placement entlang X spiegeln", + "key.neolitematica.mirror_y": "Placement vertikal spiegeln (Y)", + "key.neolitematica.mirror_z": "Placement entlang Z spiegeln", + + "neolitematica.render_mode.all": "Alle Blöcke", + "neolitematica.render_mode.all.tooltip": "Zeigt jeden Block des Schematics, ohne Vergleich mit der Welt.", + "neolitematica.render_mode.missing_only": "Nur fehlende", + "neolitematica.render_mode.missing_only.tooltip": "Blendet korrekt platzierte Blöcke aus. Falsche Blöcke rot, falsche Ausrichtung/Zustand orange.", + "neolitematica.render_mode.verify": "Prüfen", + "neolitematica.render_mode.verify.tooltip": "Wie 'Nur fehlende', zusätzlich korrekte Blöcke grün und überzählige Blöcke magenta.", + + "neolitematica.message.load_failed": "Schematic %s konnte nicht geladen werden: %s", + "neolitematica.message.placed": "'%s' platziert bei %s %s %s", + "neolitematica.message.rendering_on": "Schematic-Anzeige an", + "neolitematica.message.rendering_off": "Schematic-Anzeige aus", + "neolitematica.message.render_mode": "Rendering-Modus: %s", + "neolitematica.message.selected": "Ausgewählt: %s", + "neolitematica.message.placement_on": "'%s' sichtbar", + "neolitematica.message.placement_off": "'%s' ausgeblendet", + "neolitematica.message.position": "%s: %s %s %s", + "neolitematica.message.transform": "%s: Drehung %s°, gespiegelt: %s", + "neolitematica.message.list_failed": "Schematics konnten nicht aufgelistet werden: %s", + "neolitematica.message.renamed": "Umbenannt in '%s'", + "neolitematica.message.rename_failed": "Umbenennen fehlgeschlagen: %s", + "neolitematica.message.deleted": "%s gelöscht", + "neolitematica.message.delete_failed": "Löschen fehlgeschlagen: %s", + "neolitematica.message.import_failed": "Import von %s fehlgeschlagen: %s", + "neolitematica.message.imported": "%s Schematic(s) importiert", + "neolitematica.text.none": "keine", + + "neolitematica.hud.title": "Schematic: %s", + "neolitematica.hud.hidden": "Anzeige ist aus (Alt+H)", + "neolitematica.hud.disabled": "Placement ausgeblendet", + "neolitematica.hud.loading": "Wird geladen...", + "neolitematica.hud.position": "Position: %s %s %s", + "neolitematica.hud.size": "Größe: %s x %s x %s Drehung: %s° Spiegelung: %s", + "neolitematica.hud.stats": "Fehlend: %s Falsch: %s Korrekt: %s / %s", + + "neolitematica.screen.main.title": "NeoLitematica - Placements", + "neolitematica.screen.main.empty": "Noch keine Placements. 'Schematic laden...' verwenden", + "neolitematica.screen.remove.title": "Placement entfernen?", + "neolitematica.screen.remove.message": "'%s' wird aus dieser Welt entfernt. Die Schematic-Datei bleibt erhalten.", + "neolitematica.list.placement_detail": "%s %s %s | %sx%sx%s | %s°", + "neolitematica.screen.browser.title": "Schematics", + "neolitematica.screen.browser.empty": "Keine .litematic-Dateien im Schematics-Ordner.", + "neolitematica.screen.browser.drop_hint": "'Importieren...' verwenden oder Dateien ins Fenster ziehen.", + "neolitematica.screen.rename.title": "Neuer Name", + "neolitematica.screen.delete.title": "Schematic-Datei löschen?", + "neolitematica.screen.delete.message": "%s wird von der Festplatte gelöscht. Das kann nicht rückgängig gemacht werden.", + "neolitematica.screen.placement.title": "Placement bearbeiten", + + "neolitematica.button.load_schematic": "Schematic laden...", + "neolitematica.button.edit": "Position bearbeiten...", + "neolitematica.button.toggle_placement": "Ein- / ausblenden", + "neolitematica.button.move_here": "Zu meiner Position", + "neolitematica.button.move_here.tooltip": "Setzt die minimale Ecke des Placements an deine Position.", + "neolitematica.button.remove": "Placement entfernen", + "neolitematica.button.more_settings": "Einstellungen...", + "neolitematica.button.controls": "Steuerung...", + "neolitematica.button.place": "Hier platzieren", + "neolitematica.button.place.tooltip": "Platziert das Schematic mit der minimalen Ecke an deiner Position. Doppelklick geht auch.", + "neolitematica.button.import": "Importieren...", + "neolitematica.button.import.tooltip": "Kopiert .litematic-Dateien in den Schematics-Ordner. Dateien können auch ins Fenster gezogen werden.", + "neolitematica.button.open_folder": "Ordner öffnen", + "neolitematica.button.rename": "Umbenennen", + "neolitematica.button.delete": "Löschen", + "neolitematica.button.refresh": "Aktualisieren", + "neolitematica.button.reset_position": "Position zurücksetzen", + "neolitematica.button.reset_position.tooltip": "Setzt das Placement auf die ursprüngliche (oder zuletzt gemerkte) Position zurück.", + "neolitematica.button.store_origin": "Als Ursprung merken", + "neolitematica.button.store_origin.tooltip": "Macht die aktuelle Position zu der, auf die 'Position zurücksetzen' springt.", + "neolitematica.button.reset_transform": "Drehung/Spiegelung zurück", + + "neolitematica.option.rendering": "Anzeige", + "neolitematica.option.render_mode": "Modus", + "neolitematica.option.ghost_alpha": "Deckkraft: %s", + "neolitematica.option.render_distance": "Distanz: %s Chunks", + "neolitematica.option.rotation": "Drehung", + "neolitematica.option.mirror_x": "Spiegeln X", + "neolitematica.option.mirror_x.tooltip": "Spiegelt Ost/West.", + "neolitematica.option.mirror_y": "Spiegeln Y", + "neolitematica.option.mirror_y.tooltip": "Kopfüber. Blockzustände werden angepasst, wo Minecraft ein Gegenstück hat (Treppen, Stufen, Türen, ...).", + "neolitematica.option.mirror_z": "Spiegeln Z", + "neolitematica.option.mirror_z.tooltip": "Spiegelt Nord/Süd.", + "neolitematica.option.visible": "Sichtbar", + "neolitematica.label.name": "Name", + + "neolitematica.info.name": "Name: %s", + "neolitematica.info.author": "Autor: %s", + "neolitematica.info.size": "Größe: %s x %s x %s", + "neolitematica.info.blocks": "Blöcke: %s", + "neolitematica.info.volume": "Volumen: %s", + "neolitematica.info.regions": "Regionen: %s", + "neolitematica.info.version": "Format v%s, Datenversion %s", + "neolitematica.info.modified": "Geändert: %s", + "neolitematica.info.file_size": "Dateigröße: %s KiB", + "neolitematica.info.file": "Datei: %s", + "neolitematica.info.size_blocks": "Größe %s x %s x %s, %s Blöcke", + "neolitematica.info.unknown": "%s unbekannte Blocktypen (nicht installiert): %s", + "neolitematica.info.stats": "Korrekt %s, fehlend %s, falsch %s, falscher Zustand %s, überzählig %s", + "neolitematica.info.stats_partial": "In der Nähe: korrekt %s, fehlend %s, falsch %s, falscher Zustand %s, überzählig %s", + + "neolitematica.configuration.title": "NeoLitematica-Einstellungen", + "neolitematica.configuration.section.neolitematica.client.toml": "Client-Einstellungen", + "neolitematica.configuration.section.neolitematica.client.toml.title": "Client-Einstellungen", + "neolitematica.configuration.renderingEnabled": "Anzeige aktiv", + "neolitematica.configuration.renderMode": "Rendering-Modus", + "neolitematica.configuration.ghostAlpha": "Deckkraft der Geisterblöcke", + "neolitematica.configuration.overlayAlpha": "Deckkraft der Markierungen", + "neolitematica.configuration.renderDistance": "Render-Distanz (Chunks)", + "neolitematica.configuration.renderBlockEntities": "Block-Entities anzeigen", + "neolitematica.configuration.showBoundingBox": "Rahmen anzeigen", + "neolitematica.configuration.showHud": "Info-HUD anzeigen", + "neolitematica.configuration.builderThreads": "Mesh-Threads (Neustart nötig)" +} diff --git a/src/main/resources/assets/neolitematica/lang/en_us.json b/src/main/resources/assets/neolitematica/lang/en_us.json new file mode 100644 index 0000000..0bab19a --- /dev/null +++ b/src/main/resources/assets/neolitematica/lang/en_us.json @@ -0,0 +1,134 @@ +{ + "key.categories.neolitematica": "NeoLitematica", + "key.neolitematica.open_menu": "Open main menu", + "key.neolitematica.open_browser": "Open schematic browser", + "key.neolitematica.edit_placement": "Edit selected placement", + "key.neolitematica.toggle_rendering": "Show/hide all schematics", + "key.neolitematica.cycle_render_mode": "Cycle render mode", + "key.neolitematica.next_placement": "Select next placement", + "key.neolitematica.toggle_placement": "Show/hide selected placement", + "key.neolitematica.move_to_player": "Move placement to player", + "key.neolitematica.reset_position": "Reset placement position", + "key.neolitematica.move_forward": "Move placement forward", + "key.neolitematica.move_back": "Move placement back", + "key.neolitematica.move_left": "Move placement left", + "key.neolitematica.move_right": "Move placement right", + "key.neolitematica.move_up": "Move placement up", + "key.neolitematica.move_down": "Move placement down", + "key.neolitematica.rotate_cw": "Rotate placement clockwise", + "key.neolitematica.rotate_ccw": "Rotate placement counter-clockwise", + "key.neolitematica.mirror_x": "Mirror placement along X", + "key.neolitematica.mirror_y": "Mirror placement upside down (Y)", + "key.neolitematica.mirror_z": "Mirror placement along Z", + + "neolitematica.render_mode.all": "All blocks", + "neolitematica.render_mode.all.tooltip": "Shows every schematic block, without comparing with the world.", + "neolitematica.render_mode.missing_only": "Missing only", + "neolitematica.render_mode.missing_only.tooltip": "Hides blocks that are already placed correctly. Wrong blocks are red, wrong orientation/state orange.", + "neolitematica.render_mode.verify": "Verify", + "neolitematica.render_mode.verify.tooltip": "Like 'Missing only', additionally marks correct blocks green and extra blocks magenta.", + + "neolitematica.message.load_failed": "Could not load schematic %s: %s", + "neolitematica.message.placed": "Placed '%s' at %s %s %s", + "neolitematica.message.rendering_on": "Schematic rendering on", + "neolitematica.message.rendering_off": "Schematic rendering off", + "neolitematica.message.render_mode": "Render mode: %s", + "neolitematica.message.selected": "Selected placement: %s", + "neolitematica.message.placement_on": "'%s' visible", + "neolitematica.message.placement_off": "'%s' hidden", + "neolitematica.message.position": "%s: %s %s %s", + "neolitematica.message.transform": "%s: rotation %s°, mirrored: %s", + "neolitematica.message.list_failed": "Could not list schematics: %s", + "neolitematica.message.renamed": "Renamed to '%s'", + "neolitematica.message.rename_failed": "Rename failed: %s", + "neolitematica.message.deleted": "Deleted %s", + "neolitematica.message.delete_failed": "Delete failed: %s", + "neolitematica.message.import_failed": "Import of %s failed: %s", + "neolitematica.message.imported": "Imported %s schematic(s)", + "neolitematica.text.none": "none", + + "neolitematica.hud.title": "Schematic: %s", + "neolitematica.hud.hidden": "Rendering is off (Alt+H)", + "neolitematica.hud.disabled": "Placement hidden", + "neolitematica.hud.loading": "Loading...", + "neolitematica.hud.position": "Position: %s %s %s", + "neolitematica.hud.size": "Size: %s x %s x %s Rotation: %s° Mirror: %s", + "neolitematica.hud.stats": "Missing: %s Wrong: %s Correct: %s / %s", + + "neolitematica.screen.main.title": "NeoLitematica - Placements", + "neolitematica.screen.main.empty": "No placements yet. Use 'Load schematic...'", + "neolitematica.screen.remove.title": "Remove placement?", + "neolitematica.screen.remove.message": "'%s' will be removed from this world. The schematic file is kept.", + "neolitematica.list.placement_detail": "%s %s %s | %sx%sx%s | %s°", + "neolitematica.screen.browser.title": "Schematics", + "neolitematica.screen.browser.empty": "No .litematic files in the schematics folder.", + "neolitematica.screen.browser.drop_hint": "Use 'Import...' or drag files onto this window.", + "neolitematica.screen.rename.title": "New name", + "neolitematica.screen.delete.title": "Delete schematic file?", + "neolitematica.screen.delete.message": "%s will be deleted from disk. This cannot be undone.", + "neolitematica.screen.placement.title": "Edit placement", + + "neolitematica.button.load_schematic": "Load schematic...", + "neolitematica.button.edit": "Edit position...", + "neolitematica.button.toggle_placement": "Show / hide", + "neolitematica.button.move_here": "Move to my position", + "neolitematica.button.move_here.tooltip": "Puts the placement's minimum corner where you stand.", + "neolitematica.button.remove": "Remove placement", + "neolitematica.button.more_settings": "Settings...", + "neolitematica.button.controls": "Controls...", + "neolitematica.button.place": "Place here", + "neolitematica.button.place.tooltip": "Places the schematic with its minimum corner at your position. Double-click works too.", + "neolitematica.button.import": "Import...", + "neolitematica.button.import.tooltip": "Copies .litematic files into the schematics folder. You can also drag files onto the window.", + "neolitematica.button.open_folder": "Open folder", + "neolitematica.button.rename": "Rename", + "neolitematica.button.delete": "Delete", + "neolitematica.button.refresh": "Refresh", + "neolitematica.button.reset_position": "Reset position", + "neolitematica.button.reset_position.tooltip": "Moves the placement back to where it was first placed (or last stored).", + "neolitematica.button.store_origin": "Store as original", + "neolitematica.button.store_origin.tooltip": "Makes the current position the one 'Reset position' returns to.", + "neolitematica.button.reset_transform": "Reset rotation/mirror", + + "neolitematica.option.rendering": "Rendering", + "neolitematica.option.render_mode": "Mode", + "neolitematica.option.ghost_alpha": "Opacity: %s", + "neolitematica.option.render_distance": "Distance: %s chunks", + "neolitematica.option.rotation": "Rotation", + "neolitematica.option.mirror_x": "Mirror X", + "neolitematica.option.mirror_x.tooltip": "Mirrors east/west.", + "neolitematica.option.mirror_y": "Mirror Y", + "neolitematica.option.mirror_y.tooltip": "Upside down. Block states are flipped where Minecraft has an equivalent (stairs, slabs, doors, ...).", + "neolitematica.option.mirror_z": "Mirror Z", + "neolitematica.option.mirror_z.tooltip": "Mirrors north/south.", + "neolitematica.option.visible": "Visible", + "neolitematica.label.name": "Name", + + "neolitematica.info.name": "Name: %s", + "neolitematica.info.author": "Author: %s", + "neolitematica.info.size": "Size: %s x %s x %s", + "neolitematica.info.blocks": "Blocks: %s", + "neolitematica.info.volume": "Volume: %s", + "neolitematica.info.regions": "Regions: %s", + "neolitematica.info.version": "Format v%s, data version %s", + "neolitematica.info.modified": "Modified: %s", + "neolitematica.info.file_size": "File size: %s KiB", + "neolitematica.info.file": "File: %s", + "neolitematica.info.size_blocks": "Size %s x %s x %s, %s blocks", + "neolitematica.info.unknown": "%s unknown block types (not installed): %s", + "neolitematica.info.stats": "Correct %s, missing %s, wrong %s, wrong state %s, extra %s", + "neolitematica.info.stats_partial": "Nearby: correct %s, missing %s, wrong %s, wrong state %s, extra %s", + + "neolitematica.configuration.title": "NeoLitematica Settings", + "neolitematica.configuration.section.neolitematica.client.toml": "Client settings", + "neolitematica.configuration.section.neolitematica.client.toml.title": "Client settings", + "neolitematica.configuration.renderingEnabled": "Rendering enabled", + "neolitematica.configuration.renderMode": "Render mode", + "neolitematica.configuration.ghostAlpha": "Ghost block opacity", + "neolitematica.configuration.overlayAlpha": "Overlay opacity", + "neolitematica.configuration.renderDistance": "Render distance (chunks)", + "neolitematica.configuration.renderBlockEntities": "Render block entities", + "neolitematica.configuration.showBoundingBox": "Show bounding box", + "neolitematica.configuration.showHud": "Show info HUD", + "neolitematica.configuration.builderThreads": "Mesh builder threads (restart)" +} diff --git a/src/main/resources/neolitematica.mixins.json b/src/main/resources/neolitematica.mixins.json new file mode 100644 index 0000000..4deb5ed --- /dev/null +++ b/src/main/resources/neolitematica.mixins.json @@ -0,0 +1,12 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "de.lucas.litematica.mixin", + "compatibilityLevel": "JAVA_21", + "client": [ + "LevelRendererMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/templates/META-INF/neoforge.mods.toml b/src/main/templates/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..0adf32f --- /dev/null +++ b/src/main/templates/META-INF/neoforge.mods.toml @@ -0,0 +1,30 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" +issueTrackerURL="" + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +authors="${mod_authors}" +description='''${mod_description}''' +# Client-only mod: the server does not need it, and its presence/version is never checked. +displayTest="IGNORE_ALL_VERSION" + +[[mixins]] +config="${mod_id}.mixins.json" + +[[dependencies.${mod_id}]] +modId="neoforge" +type="required" +versionRange="${neo_version_range}" +ordering="NONE" +side="CLIENT" + +[[dependencies.${mod_id}]] +modId="minecraft" +type="required" +versionRange="${minecraft_version_range}" +ordering="NONE" +side="CLIENT" diff --git a/src/test/java/de/lucas/litematica/placement/PlacementTransformTest.java b/src/test/java/de/lucas/litematica/placement/PlacementTransformTest.java new file mode 100644 index 0000000..d9891f3 --- /dev/null +++ b/src/test/java/de/lucas/litematica/placement/PlacementTransformTest.java @@ -0,0 +1,100 @@ +package de.lucas.litematica.placement; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Vec3i; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.DoorBlock; +import net.minecraft.world.level.block.FurnaceBlock; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.block.SlabBlock; +import net.minecraft.world.level.block.StairBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.DoubleBlockHalf; +import net.minecraft.world.level.block.state.properties.Half; +import net.minecraft.world.level.block.state.properties.SlabType; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class PlacementTransformTest { + private static final Vec3i SIZE = new Vec3i(3, 4, 5); + + private static List allTransforms() { + List list = new ArrayList<>(); + for (Rotation rotation : Rotation.values()) { + for (int bits = 0; bits < 8; bits++) { + list.add(new PlacementTransform(rotation, (bits & 1) != 0, (bits & 2) != 0, (bits & 4) != 0)); + } + } + return list; + } + + @Test + void positionsRoundTripAndStayInsideBox() { + BlockPos.MutableBlockPos back = new BlockPos.MutableBlockPos(); + for (PlacementTransform t : allTransforms()) { + Vec3i placedSize = t.transformSize(SIZE); + for (int x = 0; x < SIZE.getX(); x++) { + for (int y = 0; y < SIZE.getY(); y++) { + for (int z = 0; z < SIZE.getZ(); z++) { + BlockPos placed = t.toPlaced(x, y, z, SIZE); + assertTrue(placed.getX() >= 0 && placed.getX() < placedSize.getX(), t + " x"); + assertTrue(placed.getY() >= 0 && placed.getY() < placedSize.getY(), t + " y"); + assertTrue(placed.getZ() >= 0 && placed.getZ() < placedSize.getZ(), t + " z"); + t.toLocal(placed.getX(), placed.getY(), placed.getZ(), SIZE, back); + assertEquals(new BlockPos(x, y, z), back.immutable(), t.toString()); + } + } + } + } + } + + /** A furnace must keep facing the same neighbouring block after its position is transformed. */ + @Test + void stateFacingMatchesPositionTransform() { + BlockPos center = new BlockPos(1, 1, 2); + for (PlacementTransform t : allTransforms()) { + for (Direction facing : Direction.Plane.HORIZONTAL) { + BlockState state = Blocks.FURNACE.defaultBlockState().setValue(FurnaceBlock.FACING, facing); + BlockPos neighbor = center.relative(facing); + BlockPos delta = t.toPlaced(neighbor.getX(), neighbor.getY(), neighbor.getZ(), SIZE) + .subtract(t.toPlaced(center.getX(), center.getY(), center.getZ(), SIZE)); + Direction expected = Direction.fromDelta(delta.getX(), delta.getY(), delta.getZ()); + assertEquals(expected, t.apply(state).getValue(FurnaceBlock.FACING), t + " " + facing); + } + } + } + + @Test + void verticalFlipAdjustsHalves() { + PlacementTransform flip = PlacementTransform.IDENTITY.withMirrorY(true); + BlockState stair = Blocks.OAK_STAIRS.defaultBlockState().setValue(StairBlock.HALF, Half.BOTTOM); + assertEquals(Half.TOP, flip.apply(stair).getValue(StairBlock.HALF)); + + BlockState slab = Blocks.OAK_SLAB.defaultBlockState().setValue(SlabBlock.TYPE, SlabType.BOTTOM); + assertEquals(SlabType.TOP, flip.apply(slab).getValue(SlabBlock.TYPE)); + BlockState doubleSlab = slab.setValue(SlabBlock.TYPE, SlabType.DOUBLE); + assertEquals(SlabType.DOUBLE, flip.apply(doubleSlab).getValue(SlabBlock.TYPE)); + + BlockState door = Blocks.OAK_DOOR.defaultBlockState().setValue(DoorBlock.HALF, DoubleBlockHalf.LOWER); + assertEquals(DoubleBlockHalf.UPPER, flip.apply(door).getValue(DoorBlock.HALF)); + + // Hoppers cannot face up; flipping must keep a valid state instead of throwing. + BlockState hopper = Blocks.HOPPER.defaultBlockState(); + assertEquals(Direction.DOWN, flip.apply(hopper).getValue(net.minecraft.world.level.block.HopperBlock.FACING)); + } + + @Test + void rotationCycles() { + PlacementTransform t = PlacementTransform.IDENTITY; + for (int i = 0; i < 4; i++) { + t = t.rotatedClockwise(); + } + assertEquals(PlacementTransform.IDENTITY, t); + assertEquals(270, PlacementTransform.IDENTITY.rotatedCounterClockwise().degrees()); + } +} diff --git a/src/test/java/de/lucas/litematica/schematic/LitematicReaderTest.java b/src/test/java/de/lucas/litematica/schematic/LitematicReaderTest.java new file mode 100644 index 0000000..03a3a48 --- /dev/null +++ b/src/test/java/de/lucas/litematica/schematic/LitematicReaderTest.java @@ -0,0 +1,77 @@ +package de.lucas.litematica.schematic; + +import net.minecraft.core.Vec3i; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.WallSignBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.core.Direction; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.GZIPOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class LitematicReaderTest { + private static Path sample() { + return Path.of(System.getProperty("neolitematica.testSchematic")); + } + + @Test + void readsSampleCastle() throws Exception { + LitematicSchematic schematic = LitematicReader.read(sample()); + + assertEquals(new Vec3i(64, 48, 80), schematic.size()); + assertEquals(1, schematic.regions().size()); + SchematicRegion region = schematic.regions().getFirst(); + assertEquals(480, region.palette().length); + // Metadata TotalBlocks, also verified with an independent decoder. + assertEquals(14905, schematic.nonAirBlocks()); + assertEquals(schematic.metadata().totalBlocks(), schematic.nonAirBlocks()); + assertTrue(schematic.unknownBlocks().isEmpty(), () -> "unknown: " + schematic.unknownBlocks()); + assertEquals(322, region.blockEntities().size()); + assertEquals(7, schematic.metadata().formatVersion()); + assertEquals(3955, schematic.metadata().dataVersion()); + + BlockState sign = schematic.getBlockState(24, 8, 24); + assertSame(Blocks.SPRUCE_WALL_SIGN, sign.getBlock()); + assertEquals(Direction.WEST, sign.getValue(WallSignBlock.FACING)); + CompoundTag signTag = schematic.getBlockEntity(24, 8, 24); + assertNotNull(signTag); + assertEquals("minecraft:sign", signTag.getString("id")); + } + + @Test + void rejectsGarbage(@org.junit.jupiter.api.io.TempDir Path dir) throws Exception { + Path notGzip = dir.resolve("broken.litematic"); + Files.writeString(notGzip, "definitely not nbt"); + assertThrows(SchematicLoadException.class, () -> LitematicReader.read(notGzip)); + + Path emptyNbt = dir.resolve("empty.litematic"); + try (var out = new GZIPOutputStream(Files.newOutputStream(emptyNbt))) { + // Empty root compound: tag type 10, empty name, end tag. + out.write(new byte[]{10, 0, 0, 0}); + } + assertThrows(SchematicLoadException.class, () -> LitematicReader.read(emptyNbt)); + assertThrows(SchematicLoadException.class, () -> LitematicReader.read(dir.resolve("missing.litematic"))); + } + + @Test + void bitArraySpansLongs() { + // 9-bit entries: entry 7 occupies bits 63..71 and therefore spans two longs. + long[] data = new long[2]; + int value = 0b101010101; + long bit = 7 * 9; + data[0] |= ((long) value) << bit; + data[1] |= ((long) value) >>> (64 - bit); + LitematicBitArray array = new LitematicBitArray(9, 14, data); + assertEquals(value, array.get(7)); + assertEquals(0, array.get(6)); + assertEquals(2, LitematicBitArray.bitsFor(1)); + assertEquals(2, LitematicBitArray.bitsFor(4)); + assertEquals(3, LitematicBitArray.bitsFor(5)); + assertEquals(9, LitematicBitArray.bitsFor(480)); + } +}