commit 07047c6ee4fb5b7fff5fb29beea9ffca605e2346 Author: Lucas Hahmann Date: Wed Nov 4 13:36:01 2026 +0100 first commit 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 0000000..f8e1ee3 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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 0000000..112423b Binary files /dev/null and b/ressources/Another castle.litematic differ diff --git a/ressources/mods-atm10.txt b/ressources/mods-atm10.txt new file mode 100644 index 0000000..f6ab3de --- /dev/null +++ b/ressources/mods-atm10.txt @@ -0,0 +1,464 @@ +"/mods/[1.21.1] SecurityCraft v1.10.2.1.jar" +/mods/[Neoforge]ctov-3.6.3.jar +/mods/accelerated-decay-neoforge-21.0.0.jar +/mods/actuallyadditions-1.3.26+mc1.21.1.jar +/mods/adastra-1.21.1-1.16.26-neoforge.jar +/mods/Ad-Astra-Giselle-Addon-neoforge-1.21.1-8.4.jar +/mods/additional_lights-neoforge-1.21-2.1.10.jar +/mods/AdditionalEnchantedMiner-1.21.1-neoforge-21.1.164.jar +/mods/AdvancedAE-1.6.12-1.21.1.jar +/mods/AdvancedPeripherals-1.21.1-0.8.1a.jar +/mods/ae2ct-1.21.1-1.1.1.jar +/mods/ae2importexportcard-1.21.1-1.9.0.jar +/mods/ae2jeiintegration-1.2.1.jar +/mods/AE2NetworkAnalyzer-1.21-2.1.5-neoforge.jar +/mods/ae2wtlib-19.5.1.jar +/mods/aeinfinitybooster-neoforge-1.21.1-1.0.0.58.jar +/mods/aether-1.21.1-1.5.10-neoforge.jar +/mods/AI-Improvements-1.21-0.5.3.jar +/mods/AkashicTome-1.8-30.jar +/mods/allthearcanistgear-1.21.1-21.3.2.jar +/mods/allthecompressed-1.21.1-4.4.0.jar +/mods/alltheleaks-1.1.13+1.21.1-neoforge.jar +/mods/allthemodium-3.0.1_mc_1.21.1.jar +/mods/alltheores-3.2.0_neoforge_1.21.1.jar +/mods/allthetweaks-1.21.1-2.10.0.jar +/mods/allthewizardgear-1.21.1-1.2.0.jar +/mods/almostunified-neoforge-1.21.1-1.4.2.jar +/mods/amendments-1.21-2.1.10-neoforge.jar +/mods/Apotheosis-1.21.1-8.8.0.jar +/mods/ApothicAttributes-1.21.1-2.10.1.jar +/mods/ApothicEnchanting-1.21.1-1.6.2.jar +/mods/ApothicSpawners-1.21.1-1.4.0.jar +/mods/appleskin-neoforge-mc1.21-3.0.9.jar +/mods/appliedenergistics2-19.2.17.jar +/mods/AppliedFlux-1.21-2.1.5-neoforge.jar +/mods/Applied-Mekanistics-1.6.3.jar +/mods/Aquaculture-1.21.1-2.7.21.jar +/mods/architectury-13.0.11-neoforge.jar +/mods/ars_additions-1.21.1-21.3.0.jar +/mods/ars_controle-1.21.1-1.6.16.jar +/mods/ars_creo-1.21.1-5.4.0.jar +/mods/ars_elemancy-1.21.1-1.18.3.jar +/mods/ars_elemental-1.21.1-0.7.10.1.jar +/mods/ars_nouveau-1.21.1-5.13.1.jar +/mods/ars_ocultas-1.21.1-2.6.1.jar +/mods/ars_polymorphia-1.0.3.jar +/mods/ars_technica-1.21.1-2.7.6.jar +/mods/ars_unification-1.2.21.jar +/mods/arseng-2.1.1-beta.jar +/mods/artifacts-neoforge-13.2.3.jar +/mods/athena-neoforge-1.21.1-4.0.6.jar +/mods/atlas_api-1.21.1-1.2.0.jar +/mods/attributefix-neoforge-1.21.1-21.1.3.jar +/mods/auroral-1.21.1-1.1.2.jar +/mods/balm-neoforge-1.21.1-21.0.65.jar +/mods/baubley-heart-canisters-1.21.1-1.4.2.jar +/mods/bellsandwhistles-0.4.7-1.21.1.jar +/mods/better-advanced-tooltips-2101.1.0-build.5.jar +/mods/better-compatability-checker-neoforge-21.1.8.jar +/mods/bibliobiomes-1.21.1-1.6.1.jar +/mods/bibliocraft-1.21.1-1.6.5.jar +/mods/bibliowoods-1.21.1-1.6.2.jar +/mods/blockui-1.0.212-1.21.1-snapshot.jar +/mods/blueflame-1.21.1-1.1.1.jar +/mods/bookshelf-neoforge-1.21.1-21.1.81.jar +/mods/borderless-neoforge-1.21.1-1.7.5_1-all.jar +/mods/botanypotsmystical-neoforge-1.21.1-21.1.12.jar +/mods/botanypots-neoforge-1.21.1-21.1.44.jar +/mods/botanytrees-neoforge-1.21.1-21.1.7.jar +/mods/BrandonsCore-1.21.1-3.2.1.309.jar +/mods/BridgingMod-2.6.2+1.21.1.neoforge-release.jar +/mods/buildinggadgets2-1.3.9.jar +/mods/bwncr-neoforge-1.21.1-3.20.4.jar +/mods/Byzantine-1.21.1-51.jar +/mods/cabletiers-neoforge-1.21.1-0.6.14.jar +/mods/caelus-neoforge-7.0.1+1.21.1.jar +/mods/camol-1.21.1-0.3.1.jar +/mods/catjammies-1.21-1.7.1.jar +/mods/cc-tweaked-1.21.1-forge-1.113.1.jar +/mods/cc-tweaked-1.21.1-forge-1.120.2.jar +/mods/charginggadgets-1.14.1.jar +/mods/chipped-neoforge-1.21.1-4.0.2.jar +/mods/chisel-neoforge-2.0.1+mc1.21.1.jar +/mods/chromacarvings-1.21.1-0.2.0.jar +/mods/cleanswing-1.10-1.21.jar +/mods/cloth-config-15.0.140-neoforge.jar +/mods/CloudGlass-1.21.1-1.0.2.jar +/mods/Clumps-neoforge-1.21.1-19.0.0.1.jar +/mods/cobblegengalore-1.21.1-0.2.9.jar +/mods/cobweb-neoforge-1.21-1.4.0.jar +/mods/CodeChickenLib-1.21.1-4.6.1.529.jar +/mods/comforts-neoforge-9.0.5+1.21.1.jar +/mods/commoncapabilities-1.21.1-neoforge-2.11.6.jar +/mods/common-storage-lib-neoforge-1.21.1-0.0.10.jar +/mods/compactmachines-neoforge-7.0.81.jar +/mods/connectedglass-1.1.14-neoforge-mc1.21.jar +/mods/connectivity-1.21.1-7.6.jar +/mods/ConstructionSticks-1.21.1-1.5.0.jar +/mods/Controlling-neoforge-1.21.1-19.0.5.jar +/mods/cookingforblockheads-neoforge-1.21.1-21.1.24.jar +/mods/copycats-3.0.9+mc.1.21.1-neoforge.jar +/mods/Corgilib-NeoForge-1.21.1-5.0.0.9.jar +/mods/cosmeticarmorreworked-1.21.1-v1-neoforge.jar +/mods/crafting_on_a_stick-1.21.0.6.jar +/mods/craftingtweaks-neoforge-1.21.1-21.1.11.jar +/mods/CrashAssistant-neoforge-1.20.6-1.21.4-1.11.12.jar +/mods/crashutilities-9.0.4.jar +/mods/create_aquatic_ambitions-1.21.1-2.0.4.jar +/mods/create_hypertube-0.6.0-NEOFORGE.jar +/mods/create-1.21.1-6.0.10.jar +/mods/createaddition-1.7.1.jar +/mods/CreateDragonsPlus-1.11.9.jar +/mods/create-enchantment-industry-2.5.4.jar +/mods/CreeperOverhaul-neoforge-1.21.1-4.0.6.jar +/mods/cristellib-neoforge-1.21.1-3.1.7.jar +/mods/cryonicconfig-neoforge-1.0.0+mc1.21.11.jar +/mods/crystalix-3.0.1.jar +/mods/CTM-1.21-1.2.1+3.jar +/mods/Cucumber-1.21.1-8.0.16.jar +/mods/cupboard-1.21.1-4.2.jar +/mods/curios-neoforge-9.5.1+1.21.1.jar +/mods/cyclopscore-1.21.1-neoforge-1.30.0.jar +/mods/deeperdarker-neoforge-1.21.1-1.4.1.jar +/mods/deimos-1.21.1-neoforge-2.7.jar +/mods/DimStorage-1.21.1-9.1.1.jar +/mods/disenchanting_table-merged-1.21.1-5.0.2.jar +/mods/domum-ornamentum-1.0.236-snapshot-main.jar +/mods/Draconic-Evolution-1.21.1-3.1.4.633.jar +/mods/dummmmmmy-1.21-2.1.2-neoforge.jar +/mods/DungeonCrawl-NeoForge-1.21-2.3.17.jar +/mods/DungeonsArise-1.21.1-2.1.68-release.jar +/mods/dyenamics-1.21.1-3.4.2.jar +/mods/dyenamicsandfriends-1.21.1-2.2.2.jar +/mods/dysoncubeproject-1.0.5.jar +/mods/easy-villagers-neoforge-1.21.1-1.1.42.jar +/mods/EdivadLib-1.21-3.0.0.jar +/mods/elevatorid-neoforge-1.21.1-1.11.4.jar +/mods/enchdesc-neoforge-1.21.1-21.1.11.jar +/mods/enderdrives-neoforge-1.21.1-1.5.23.jar +/mods/enderio-8.2.12-beta.jar +/mods/endermanoverhaul-neoforge-1.21.1-2.0.3.jar +/mods/EnderStorage-1.21.1-2.13.0.191.jar +/mods/energymeter-neoforge-1.21.1-0.5.2.jar +/mods/entangled-1.3.21-neoforge-mc1.21.jar +/mods/eternalstarlight-0.9.1+1.21.1+neoforge.jar +/mods/everythingcopper-1.21.1-2.4.5.jar +/mods/evilcraft-1.21.1-neoforge-1.2.96-1106.jar +/mods/expandedae-2.1.3.jar +/mods/experiencelib-1.21.1-1.2.1.jar +/mods/ExplorersCompass-1.21.1-3.4.0-neoforge.jar +"/mods/Explorify v1.6.5.mod.jar" +/mods/ExtendedAE-1.21-2.2.38-neoforge.jar +/mods/extended-industrialization-1.16.2-1.21.1.jar +/mods/ExtraDisks-1.21.1-4.0.15.jar +/mods/ExtraStorage-1.21.1-5.0.10.jar +/mods/ExtremeReactors2-1.21.1-2.4.28.jar +/mods/factory_blocks-neoforge-1.4.0+mc1.21.1.jar +/mods/FarmersDelight-1.21.1-1.3.4.jar +/mods/farmingforblockheads-neoforge-1.21.1-21.1.13.jar +/mods/FastFurnace-1.21.1-9.0.1.jar +/mods/FastSuite-1.21.1-6.0.7.jar +/mods/FastWorkbench-1.21.1-9.1.3.jar +/mods/ferritecore-7.0.3-neoforge.jar +/mods/fireproofboats-1.21.1-1.0.4.jar +/mods/flickerfix-1.21.1-6.1.0.jar +/mods/FluxNetworks-1.21.1-8.0.0.jar +/mods/forbidden_arcanus-2.6.1.jar +/mods/formations-1.0.4-neoforge-mc1.21.jar +/mods/formationsnether-1.0.5b-mc1.21+.jar +/mods/formationsoverworld-1.0.5c-mc1.21+.jar +/mods/FramedBlocks-10.6.2.jar +/mods/framework-neoforge-1.21.1-0.13.11.jar +/mods/ftb-chunks-neoforge-2101.1.22.jar +/mods/ftb-essentials-neoforge-2101.1.10.jar +/mods/ftb-filter-system-neoforge-21.1.4.jar +/mods/ftb-jei-extras-21.1.7.jar +/mods/ftb-library-neoforge-2101.1.36.jar +/mods/ftbquestslangsplitter-1.0.7.jar +/mods/ftb-quests-neoforge-2101.1.36.jar +/mods/ftb-ranks-neoforge-2101.1.5.jar +/mods/ftb-teams-neoforge-2101.1.11.jar +/mods/ftb-ultimine-neoforge-2101.1.15.jar +/mods/ftb-xmod-compat-neoforge-21.1.12.jar +/mods/fuelgoeshere-1.21.1-1.2.0.jar +/mods/functionalstorage-1.21.1-1.5.8.jar +/mods/fusion-1.3.15b-neoforge-mc1.21.1.jar +/mods/fzzy_config-0.7.7+1.21+neoforge.jar +/mods/GatewaysToEternity-1.21.1-5.1.0.jar +/mods/geckolib-neoforge-1.21.1-4.9.3.jar +/mods/generatorgalore-1.21.1-1.7.0.jar +/mods/getittogetherdrops-neoforge-1.21.5-1.4.jar +/mods/Glassential-renewed-1.21.1-3.4.7.jar +/mods/Glodium-1.21-2.2-neoforge.jar +/mods/GravitationalModulatingAdditionalUnit-1.21.1-6.4.jar +/mods/guideme-21.1.19.jar +/mods/handcrafted-neoforge-1.21.1-4.0.3.jar +/mods/hardenedarmadillos-1.21.1-0.2.0.jar +/mods/HellishTrials-neoforge-1.0.5.jar +/mods/herbsandharvest-1.21.1-21.jar +/mods/heyberryshutup-1.21.0-2.0.4.jar +/mods/HostileNeuralNetworks-1.21.1-6.5.1.jar +/mods/iceandfire-2.0-beta.17.jar +/mods/illagerwarship-1.0.1-neoforge-1.21.1.jar +/mods/imfast-NEOFORGE-1.21.1-1.0.3.jar +/mods/ImmediatelyFast-NeoForge-1.6.14+1.21.1.jar +/mods/Immersive-Energistics-1.1.0-beta.jar +/mods/ImmersiveEngineering-1.21.1-12.4.2-194.jar +/mods/incontrol-1.21-10.3.0.jar +/mods/industrialforegoing-1.21-3.6.39.jar +/mods/industrial-foregoing-souls-1.21.1-1.10.7.jar +/mods/industrialization_overdrive-1.14.0+1.21.1.jar +/mods/integratedcrafting-1.21.1-neoforge-1.7.2.jar +/mods/integrateddynamics-1.21.1-neoforge-1.37.1-2160.jar +/mods/integratedscripting-1.21.1-neoforge-1.0.27.jar +/mods/integratedterminals-1.21.1-neoforge-1.10.0.jar +/mods/integratedtunnels-1.21.1-neoforge-1.12.0-792.jar +/mods/interdimensionalwirelesstransmitter-neoforge-1.21.1-0.1.5.jar +/mods/invasiveopts-1.0.24.jar +/mods/invtweaks-1.21.1-1.3.2.jar +/mods/IrisSearch-1.8.1-neoforge.jar +/mods/ironfurnaces-neoforge-1.21.1-4.3.2.jar +/mods/IronJetpacks-1.21.1-8.0.11.jar +/mods/irons_jewelry-1.21.1-2.0.2.jar +/mods/irons_lib-1.21.1-2.1.1.jar +/mods/irons_spellbooks-1.21.1-3.16.3.jar +/mods/itemcollectors-1.1.10-neoforge-mc1.21.jar +/mods/Jade-1.21.1-NeoForge-15.10.6.jar +/mods/jearchaeology-1.21.1-1.2.0.jar +/mods/jei-1.21.1-neoforge-19.57.0.446.jar +/mods/jeistuff-1.21.1-1.2.1.jar +/mods/journeymap-neoforge-1.21.1-6.0.9.jar +/mods/jumpboat-1.21.0-1.0.5.jar +/mods/jupiter-2.3.7-1.21.1-neoforge.jar +/mods/justdirethings-1.5.7.jar +/mods/JustEnoughMekanismMultiblocks-1.21.1-7.21.jar +/mods/JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar +/mods/KeybindsPurger-1.4.0-neoforge-1.21.1.jar +/mods/kotlinforforge-5.12.0-all.jar +/mods/kubejs-neoforge-2101.7.2-build.374.jar +/mods/kubejstweaks-1.0.6.jar +"/mods/L_Ender's Cataclysm 1.21.1-3.33.jar" +/mods/laserbridges-1.21.1-neoforge-6.jar +/mods/laserio-1.9.11.jar +/mods/lionfishapi-3.1.jar +/mods/lithostitched-1.8.0-neoforge-21.1.jar +/mods/little-big-redstone-1.9.11-1.21.1.jar +/mods/livingthings-neoforge-1.21.1-2.3.2.jar +/mods/lmft-1.1.1+1.21.9-neoforge.jar +/mods/logisticsnetworks-1.21.1-1.16.3.jar +/mods/lootr-neoforge-1.21.1-1.11.38.126.jar +/mods/luminax-1.3.1_neoforge_1.21.1.jar +/mods/mahoutsukai-1.21.1-v1.36.28.jar +/mods/mcjtylib-1.21-9.0.21.jar +/mods/mcw-bridges-3.1.2-mc1.21.1neoforge.jar +/mods/mcw-doors-1.1.5-mc1.21.1neoforge.jar +/mods/mcw-furniture-3.4.1-mc1.21.1neoforge.jar +/mods/mcw-holidays-1.1.2-mc1.21.1neoforge.jar +/mods/mcw-lights-1.1.5-mc1.21.1neoforge.jar +/mods/mcw-mcwfences-1.2.1-mc1.21.1neoforge.jar +/mods/mcw-mcwpaths-1.1.1-mc1.21.1neoforge.jar +/mods/mcw-mcwstairs-1.0.2-mc1.21.1neoforge.jar +/mods/mcw-mcwwindows-2.4.2-mc1.21.1neoforge.jar +/mods/mcw-roofs-2.3.2-mc1.21.1neoforge.jar +/mods/mcw-trapdoors-1.1.5-mc1.21.1neoforge.jar +/mods/Measurements-neoforge-1.21.1-3.0.3.jar +/mods/megacells-4.11.0.jar +/mods/Mekanism-1.21.1-10.7.19.85.jar +/mods/mekanismcovers-1.3-BETA+1.21.jar +/mods/MekanismGenerators-1.21.1-10.7.19.85.jar +/mods/MekanismTools-1.21.1-10.7.19.85.jar +/mods/mekanisticrouters-1.2.0.jar +/mods/mekmm-1.21.1-1.4.1.jar +/mods/melody_neoforge_1.0.10_MC_1.21.jar +/mods/memorysettings-1.21-6.0.jar +/mods/merequester-neoforge-1.21.1-1.5.0.jar +/mods/merrymaking-1.21.1-16.jar +/mods/mezz_config-1.21.1-neoforge-0.6.3.jar +/mods/mffs-5.4.44.jar +/mods/mifa-neoforge-1.21.x-2.1.0.jar +/mods/minecolonies-1.1.1396-1.21.1-snapshot.jar +/mods/mininggadgets-1.18.8.jar +/mods/mob_grinding_utils-1.1.10+mc1.21.1.jar +/mods/Modern-Dynamics-0.9.6.jar +/mods/modernfix-neoforge-5.27.24+mc1.21.1.jar +/mods/Modern-Industrialization-2.5.8.jar +/mods/modonomicon-1.21.1-neoforge-1.120.4.jar +/mods/ModularBees-1.21.1-3.4-neoforge.jar +/mods/modular-routers-13.2.7+mc1.21.1.jar +/mods/monolib-neoforge-1.21.1-4.1.0.jar +/mods/MoogsEndStructures-universal-1.21-2.1.1.jar +/mods/MoogsNetherStructures-universal-1.21-3.1.1.jar +/mods/MoogsSoaringStructures-universal-1.21-2.2.0.jar +/mods/MoogsStructureLib-neoforge-1.21.1-3.3.1.jar +/mods/MoogsVoyagerStructures-universal-1.21-5.1.2.jar +/mods/moonlight-1.21.1-3.6.8-neoforge.jar +/mods/moredragoneggs-5.0.jar +/mods/morered-1.21.1-6.0.0.3.jar +/mods/MoreRed-CCT-Compat-1.21.1-1.3.0.jar +/mods/mostructures-neoforge-1.6.0+1.21.1.jar +/mods/MouseTweaks-neoforge-mc1.21-2.26.1.jar +/mods/multipiston-1.2.58-1.21.1.jar +/mods/MysticalAgradditions-1.21.1-8.0.14.jar +/mods/MysticalAgriculture-1.21.1-8.0.28.jar +/mods/MysticalCustomization-1.21.1-6.0.2.jar +/mods/NaturesAura-41.10.jar +/mods/NaturesCompass-1.21.1-3.4.0-neoforge.jar +/mods/neovitae-1.21.1-1.1.26.jar +/mods/netherportalfix-neoforge-1.21.1-21.1.1.jar +/mods/NoChatReports-NEOFORGE-1.21.1-v2.9.1.jar +/mods/not_enough_glyphs-1.21.1-4.6.2.jar +/mods/novillagerdm-1.21.1-6.0.0.jar +/mods/Nullscape_1.21.x_v1.2.14.jar +/mods/observable-5.4.4.jar +/mods/occultism_kubejs-1.21.1-neoforge-1.11.0.jar +/mods/occultism-1.21.1-neoforge-1.224.4.jar +/mods/OctoLib-NEOFORGE-0.6.2+1.21.jar +/mods/Oh-The-Biomes-Weve-Gone-NeoForge-2.6.0.jar +/mods/Oh-The-Trees-Youll-Grow-neoforge-1.21.1-5.3.2.jar +/mods/omegaconfig-neoforge-1.5.1.jar +/mods/OpenLoader-neoforge-1.21.1-21.1.5.jar +/mods/oracle_index-neoforge-1.4.0.jar +/mods/oritech-neoforge-1.21.1-1.2.12.jar +/mods/overlapless-1.21.1-1.2.0.jar +/mods/overloadedarmorbar-neoforge-1.21-2.jar +/mods/owo-lib-neoforge-0.12.15.5-beta.1+1.21.jar +/mods/pamhc2crops-NEOFORGE-1.21.1-1.0.9.jar +/mods/pamhc2foodcore-NEOFORGE-1.21.1-1.0.4.jar +/mods/pamhc2foodextended-NEOFORGE-1.21.1-1.0.0.jar +/mods/pamhc2trees-NEOFORGE-1.21.1-1.0.9.jar +/mods/Patchouli-1.21.1-93-NEOFORGE.jar +/mods/pipez-neoforge-1.21.1-1.2.31.jar +/mods/Placebo-1.21.1-9.9.2.jar +/mods/player-animation-lib-forge-2.0.4+1.21.1.jar +/mods/pneumaticcraft-repressurized-8.2.23+mc1.21.1.jar +/mods/pocketstorage-1.2.5+1.21.1-b3.jar +/mods/polyeng-0.4.1.jar +/mods/polylib-2100.1.0-build.183-neoforge.jar +/mods/polymorph-neoforge-1.2.0+1.21.1.jar +/mods/ponderjs-neoforge-1.21.1-2.4.0.jar +/mods/potionsmaster-21.1.113-1.0.1.jar +/mods/Powah-6.2.10.jar +/mods/prickle-neoforge-1.21.1-21.1.11.jar +/mods/productivebees-1.21.1-13.13.5.jar +/mods/productivemetalworks-1.21.1-1.15.1.jar +/mods/productivetrees-1.21.1-1.1.1.jar +/mods/pylons-1.21.1-5.4.2.jar +/mods/railcraft-reborn-1.21.1-1.2.10.jar +/mods/rangedpumps-1.3.0.jar +/mods/rebind_narrator-1.21.1-neoforge-2026.08.31.jar +/mods/rechiseled_chipped-2.0-1.21.1.jar +/mods/rechiseled-1.2.6-neoforge-mc1.21.jar +/mods/rechiseledcreate-1.1.1-neoforge-mc1.21.jar +/mods/redstonepen-1.21-neoforge-1.11.42.jar +/mods/refinedstorage-curios-integration-1.0.0.jar +/mods/refinedstorage-jei-integration-neoforge-1.0.0.jar +/mods/refinedstorage-mekanism-integration-1.1.1.jar +/mods/refinedstorage-neoforge-2.0.9.jar +/mods/refinedstorage-quartz-arsenal-neoforge-1.0.8.jar +/mods/refined-types-1.21.1-0.3.2.jar +/mods/refurbished_furniture-neoforge-1.21.1-1.0.22.jar +/mods/regions_unexplored-neoforge-1.21.1-0.5.9.jar +/mods/relics-1.21.1-0.12.8.jar +/mods/reliquary-1.21.1-2.0.80.1570.jar +/mods/reliquified_artifacts-1.21.1-1.0.8.jar +/mods/repeatable_trial_vaults-neoforge-1.21-1.0.3.jar +/mods/repurposed_structures-7.5.22+1.21.1-neoforge.jar +/mods/resourcefulconfig-neoforge-1.21-3.0.11.jar +/mods/resourcefullib-neoforge-1.21-3.0.12.jar +/mods/restrictions-1.21-7.0.3.jar +/mods/rftoolsbase-1.21-6.0.11.jar +/mods/rftoolsbuilder-1.21-7.0.6.jar +/mods/rftoolspower-1.21-7.0.6.jar +/mods/rftoolsstorage-1.21-6.0.5.jar +/mods/rftoolsutility-1.21-7.0.12.jar +/mods/rhino-2101.2.8-build.91.jar +/mods/RootsClassic-1.21.1-1.5.10.jar +/mods/sawmill-1.21-1.8.1-neoforge.jar +/mods/ScalableCatsForce-NeoForge-3.7.1-build-11-with-library.jar +/mods/Searchables-neoforge-1.21.1-1.0.2.jar +/mods/sgearmetalworks-1.21.1-1.5.0.jar +/mods/shiny-1.21.1-1.3.0.jar +/mods/shrink-2.0.1.47-neoforge.jar +/mods/silent-gear-1.21.1-neoforge-4.2.1.1.jar +/mods/silentgems-1.21.1-neoforge-5.1.3.jar +/mods/silent-lib-1.21.1-neoforge-10.6.0.jar +/mods/simplemagnets-1.1.12c-neoforge-mc1.21.jar +/mods/simplylight-1.5.3+1.21.1-b4.jar +/mods/SmartBrainLib-neoforge-1.21.1-1.16.11.jar +/mods/solcarrot-1.21.1-1.16.6.jar +/mods/sophisticatedbackpacks-1.21.1-3.26.3.2158.jar +/mods/sophisticatedbackpackscreateintegration-1.21.1-0.2.0.168.jar +/mods/sophisticatedcore-1.21.1-1.5.1.2341.jar +/mods/sophisticatedstorage-1.21.1-1.5.91.2127.jar +/mods/sophisticatedstoragecreateintegration-1.21.1-0.1.21.209.jar +/mods/sophisticatedstorageinmotion-1.21.1-0.10.34.358.jar +/mods/soulplied_energistics-1.0.3.jar +/mods/spark-1.10.124-neoforge.jar +/mods/starbunclemania-1.21.1-1.5.8.jar +/mods/stepcrafter-neoforge-1.21.1-0.1.9.jar +/mods/stevescarts-1.21-1.2.18-neoforge.jar +/mods/storagedelight-26.09.17-1.21-neoforge.jar +/mods/Structory_26.2_v1.3.7.jar +/mods/Structory_Towers_26.1_v1.0.16.jar +/mods/structurize-1.0.833-1.21.1-snapshot.jar +/mods/stylecolonies-1.15.59-1.21.1.jar +"/mods/Super Factory Manager (SFM)-MC1.21.1-4.34.0.jar" +/mods/supermartijn642configlib-1.1.8-neoforge-mc1.21.jar +/mods/supermartijn642corelib-1.1.24a-neoforge-mc1.21.jar +/mods/supplementaries-1.21.1-3.9.9-neoforge.jar +/mods/sushigocrafting-1.21-0.6.6.jar +/mods/sussysniffers-1.21.1-0.1.3.jar +/mods/t_and_t-fabric-neoforge-1.13.11.jar +/mods/tempad-1.21.1-3.0.4-all.jar +/mods/TerraBlender-neoforge-1.21.1-4.1.0.8.jar +/mods/tesseract-api-neoforge-1.12.16-1.21.1.jar +/mods/the_bumblezone-7.16.1+1.21.1-neoforge.jar +/mods/The_Undergarden-1.21.1-0.9.6.jar +/mods/theurgy_kubejs-1.21.1-neoforge-1.6.0.jar +/mods/theurgy-1.21.1-neoforge-1.76.1.jar +/mods/tiab-neoforge-6.5.4.jar +/mods/titanium-1.21-4.0.50.jar +/mods/ToastControl-1.21.1-9.0.1.jar +/mods/tombstone-neoforge-1.21.1-9.5.6.jar +/mods/ToolBelt-1.21.1-2.2.10.jar +/mods/torchmaster-neoforge-1.21.1-21.1.12.jar +/mods/towntalk-1.2.0.jar +/mods/transfer_labels-0.1.9.jar +/mods/trashcans-1.1.0-neoforge-mc1.21.jar +/mods/trashslot-neoforge-1.21.1-21.1.11.jar +/mods/treetap-1.21.1-0.4.6.jar +/mods/trophymanager-1.21.1-3.0.0.jar +/mods/twilightforest-1.21.1-4.8.3345-universal.jar +/mods/UndergroundVillages-neoforge-1.21.1-4.0.3.jar +/mods/universalgrid-neoforge-1.21.1-0.3.2.jar +/mods/uranus-2.4.1-bugfix-1.21.1-neoforge.jar +/mods/utilitarian-1.21.1-0.19.2.jar +/mods/utilityvest-1.3.0.jar +/mods/valhelsia_core-neoforge-1.21.1-1.1.5.jar +/mods/variantsandventures-neoforge-1.0.26+mc1.21.1.jar +/mods/villagesandpillages-neoforge-mc1.21.1-1.0.3.jar +/mods/waystones-neoforge-1.21.1-21.1.45.jar +/mods/wirelesschargers-1.0.9a-neoforge-mc1.21.jar +/mods/WitherSkeletonTweaks-1.21.1-10.1.1.jar +/mods/wits-neoforge-1.3.1.jar +/mods/xnet-1.21-7.0.7.jar +/mods/xtonesreworked-1.1.1-1.21.1.jar +/mods/xycraft_core-0.7.53-all.jar +/mods/xycraft_machines-0.7.53.jar +/mods/xycraft_override-0.7.53.jar +/mods/xycraft_world-0.7.53.jar +/mods/yeetusexperimentus-neoforge-87.0.0.jar +/mods/yet_another_config_lib_v3-3.8.2+1.21.1-neoforge.jar +/mods/YungsApi-1.21.1-NeoForge-5.1.9.jar +/mods/YungsBetterDesertTemples-1.21.1-NeoForge-4.1.5.jar +/mods/YungsBetterDungeons-1.21.1-NeoForge-5.1.4.jar +/mods/YungsBetterEndIsland-1.21.1-NeoForge-3.1.2.jar +/mods/YungsBetterJungleTemples-1.21.1-NeoForge-3.1.2.jar +/mods/YungsBetterMineshafts-1.21.1-NeoForge-5.1.1.jar +/mods/YungsBetterNetherFortresses-1.21.1-NeoForge-3.1.5.jar +/mods/YungsBetterOceanMonuments-1.21.1-NeoForge-4.1.2.jar +/mods/YungsBetterStrongholds-1.21.1-NeoForge-5.1.3.jar +/mods/YungsBetterWitchHuts-1.21.1-NeoForge-4.1.1.jar +/mods/YungsExtras-1.21.1-NeoForge-5.1.1.jar +/mods/ZeroCore2-1.21.1-2.4.21.jar diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..890acd1 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { url = 'https://maven.neoforged.net/releases' } + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.9.0' +} + +rootProject.name = 'neolitematica' diff --git a/src/main/java/de/lucas/litematica/NeoLitematica.java b/src/main/java/de/lucas/litematica/NeoLitematica.java new file mode 100644 index 0000000..87b19b4 --- /dev/null +++ b/src/main/java/de/lucas/litematica/NeoLitematica.java @@ -0,0 +1,69 @@ +package de.lucas.litematica; + +import de.lucas.litematica.config.ModConfig; +import de.lucas.litematica.dev.DevSmokeTest; +import de.lucas.litematica.gui.HudOverlay; +import de.lucas.litematica.input.InputHandler; +import de.lucas.litematica.input.KeyBindings; +import de.lucas.litematica.placement.PlacementManager; +import de.lucas.litematica.render.SchematicRenderer; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.ResourceManagerReloadListener; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.RegisterClientReloadListenersEvent; +import net.neoforged.neoforge.client.event.RegisterGuiLayersEvent; +import net.neoforged.neoforge.client.event.RenderLevelStageEvent; +import net.neoforged.neoforge.client.gui.ConfigurationScreen; +import net.neoforged.neoforge.client.gui.IConfigScreenFactory; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.level.ChunkEvent; + +/** + * Entry point. {@code dist = Dist.CLIENT} makes FML skip this class entirely on dedicated servers, and the mod + * registers no network payloads, so servers never need it installed. + */ +@Mod(value = NeoLitematica.MOD_ID, dist = Dist.CLIENT) +public final class NeoLitematica { + public static final String MOD_ID = "neolitematica"; + + public NeoLitematica(IEventBus modBus, ModContainer container) { + container.registerConfig(net.neoforged.fml.config.ModConfig.Type.CLIENT, ModConfig.SPEC); + container.registerExtensionPoint(IConfigScreenFactory.class, ConfigurationScreen::new); + + modBus.addListener(KeyBindings::register); + modBus.addListener((RegisterGuiLayersEvent event) -> 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)); + } +}