first commit

This commit is contained in:
lucashahmann committed 2026-11-04 13:36:01 +01:00
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;
}
}