first commit
This commit is contained in:
commit
07047c6ee4
53 files changed
+6267
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<RenderMode> 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 <T> void set(ModConfigSpec.ConfigValue<T> value, T newValue) {
|
||||
value.set(newValue);
|
||||
value.save();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<Path> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Component> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.<RenderMode>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<SchematicPlacement> 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.Entry> {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
@@ -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.<Rotation>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<Component> infoLines() {
|
||||
List<Component> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Path, Object> 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<Path> 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<Path> files) {
|
||||
this.importFiles(files);
|
||||
}
|
||||
|
||||
private void importFiles(List<Path> 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<Component> 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.Entry> {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> onConfirm;
|
||||
private EditBox input;
|
||||
|
||||
public TextInputScreen(Screen parent, Component title, String initial, Consumer<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<KeyMapping> 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<SchematicPlacement> placements = new ArrayList<>();
|
||||
@Nullable
|
||||
private UUID selected;
|
||||
@Nullable
|
||||
private String worldKey;
|
||||
private int saveCountdown = -1;
|
||||
|
||||
public static PlacementManager get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public List<SchematicPlacement> all() {
|
||||
return Collections.unmodifiableList(this.placements);
|
||||
}
|
||||
|
||||
/** Placements in the player's current dimension. */
|
||||
public List<SchematicPlacement> 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<SchematicPlacement> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<world>.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<SchematicPlacement> 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<SchematicPlacement> 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<SchematicPlacement> 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());
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
* <p>
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<SchematicRegion> 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;
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<RenderType, ByteBufferBuilder> memory = new HashMap<>();
|
||||
private final Map<RenderType, BufferBuilder> 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<RenderType, BufferBuilder> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<BlockEntityType<?>> 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<RenderSection> sections = new Long2ObjectOpenHashMap<>();
|
||||
private final Long2ObjectMap<Object> 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<RenderSection> 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<RenderSection> 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<RenderSection> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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<UUID, PlacementRenderer> renderers = new HashMap<>();
|
||||
private final ConcurrentLinkedQueue<SectionMeshBuilder.Result> results = new ConcurrentLinkedQueue<>();
|
||||
private final ConcurrentLinkedQueue<Long> 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<RenderSection> candidates = new ArrayList<>();
|
||||
Map<RenderSection, PlacementRenderer> 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<UUID> 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<RenderSection> candidates, Map<RenderSection, PlacementRenderer> 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<RenderSection> visible = new ArrayList<>();
|
||||
List<PlacementRenderer> 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<RenderSection> 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<RenderSection> 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<RenderSection> sections, List<PlacementRenderer> 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<BlockEntity> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Block> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.lucas.litematica.schematic;
|
||||
|
||||
/**
|
||||
* Read-only view of Litematica's packed block state index array.
|
||||
* <p>
|
||||
* 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)));
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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<RawRegion> raws = new ArrayList<>();
|
||||
Set<String> 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<SchematicRegion> 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<String> 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<CompoundTag> 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<CompoundTag> blockEntities) {
|
||||
}
|
||||
}
|
||||
@@ -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<SchematicRegion> regions;
|
||||
private final Vec3i size;
|
||||
private final long nonAirBlocks;
|
||||
private final Set<String> unknownBlocks;
|
||||
|
||||
LitematicSchematic(Path file, SchematicMetadata metadata, List<SchematicRegion> regions, Vec3i size, long nonAirBlocks, Set<String> 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<SchematicRegion> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Path> list() throws IOException {
|
||||
Path dir = directory();
|
||||
try (Stream<Path> 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("[\\\\/:*?\"<>|]", "_");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Path, Cached> cache = new ConcurrentHashMap<>();
|
||||
private final Map<Path, CompletableFuture<LitematicSchematic>> pending = new ConcurrentHashMap<>();
|
||||
|
||||
public static SchematicManager get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
/** Loads (or returns the cached) schematic. The future completes on the loader thread. */
|
||||
public CompletableFuture<LitematicSchematic> 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<LitematicSchematic> 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<SchematicMetadata> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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<CompoundTag> 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user