diff --git a/README.md b/README.md index 224a87f..0f0d2ce 100644 --- a/README.md +++ b/README.md @@ -3,58 +3,55 @@ ## 功能列表 -**复制准星目标 ID** +- **复制准星目标 ID** 复制准星所指的方块或实体 ID -**实体高亮边框** +- **实体高亮边框** 为名单中的实体类型添加原版发光描边效果 -**实体高亮边框-选择列表** +- **实体高亮边框-选择列表** 每行一个实体 ID -**实体高亮边框-颜色** +- **实体高亮边框-颜色** 设置名单中实体的描边颜色 -**实体渲染黑名单开关** +- **实体渲染黑名单开关** 跳过名单中实体的客户端渲染 -**实体渲染黑名单-列表** +- **实体渲染黑名单-列表** 每行一个实体 ID -**实体渲染黑名单-范围** +- **实体渲染黑名单-范围** 黑名单实体在该三维范围内会重新渲染,0 为始终不渲染 -**掏炉灰助手-开关** +- **掏炉灰助手-开关** 高亮输入槽含有无法烧制物品的普通熔炉 -**掏炉灰助手-范围设置** +- **掏炉灰助手-范围设置** 以玩家所在区块为中心,区块为范围的扫描半径 -**TNT爆炸预览** +- **TNT爆炸预览** 以TNT当前实时位置预览方块破坏范围,红色为保守范围,黄色为额外可能范围 -**TNT爆炸预览-显示模式** +- **TNT爆炸预览-显示模式** 选择是否显示可能被炸坏的方块 -**玩家追踪线** +- **投射物落点预测** -连线到附近玩家 - -**玩家追踪线颜色** - -设置追踪线的颜色 +显示支持投射物的预测落点 -**投射物落点预测** +- **投影实时预览** -显示支持投射物的预测落点 +在投影文件选择界面新增原理图预览窗口,实时显示当前选中的原理图 +![](docs/new.jpg) diff --git a/docs/new.jpg b/docs/new.jpg new file mode 100644 index 0000000..6eac466 Binary files /dev/null and b/docs/new.jpg differ diff --git a/gradle.properties b/gradle.properties index 8866ed3..4b60c12 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,8 +3,9 @@ org.gradle.jvmargs=-Xmx1G org.gradle.configuration-cache=false -mod_version=1.0.0 +mod_version=1.0.5 archives_base_name=ListMore-Fabric maven_group=com.listmore loom_version=1.17-SNAPSHOT preprocessor_version=c5abb4fb12 +conditionalmixin_version=0.6.4 diff --git a/src/main/java/com/listmore/config/ListMoreConfigGui.java b/src/main/java/com/listmore/config/ListMoreConfigGui.java index 9029cfe..e2af650 100644 --- a/src/main/java/com/listmore/config/ListMoreConfigGui.java +++ b/src/main/java/com/listmore/config/ListMoreConfigGui.java @@ -9,6 +9,7 @@ import fi.dy.masa.malilib.gui.button.ButtonGeneric; import fi.dy.masa.malilib.gui.button.IButtonActionListener; import fi.dy.masa.malilib.util.StringUtils; +import net.fabricmc.loader.api.FabricLoader; public class ListMoreConfigGui extends GuiConfigsBase { private static ConfigTab tab = ConfigTab.GENERIC; @@ -24,6 +25,9 @@ public void initGui() { int x = 10; for (ConfigTab currentTab : ConfigTab.values()) { + if (!currentTab.isAvailable()) { + continue; + } x += this.createButton(x, 26, -1, currentTab) + 2; } } @@ -56,7 +60,15 @@ protected boolean useKeybindSearch() { @Override public List getConfigs() { - return ConfigOptionWrapper.createFor(ListMoreConfigs.Generic.OPTIONS); + return ConfigOptionWrapper.createFor(switch (tab) { + case ALL -> FabricLoader.getInstance().isModLoaded("litematica") + ? ListMoreConfigs.Generic.OPTIONS + : ListMoreConfigs.Generic.GENERIC_OPTIONS; + case GENERIC -> ListMoreConfigs.Generic.GENERIC_OPTIONS; + case LITEMATICA -> FabricLoader.getInstance().isModLoaded("litematica") + ? ListMoreConfigs.Generic.LITEMATICA_OPTIONS + : List.of(); + }); } private record ButtonListener(ConfigTab tab, ListMoreConfigGui parent) implements IButtonActionListener { @@ -73,7 +85,8 @@ public void actionPerformedWithButton(ButtonBase button, int mouseButton) { private enum ConfigTab { ALL("malilib.gui.title.all"), - GENERIC("listmore.gui.tab.generic"); + GENERIC("listmore.gui.tab.generic"), + LITEMATICA("listmore.gui.tab.litematica"); private final String translationKey; @@ -84,5 +97,9 @@ private enum ConfigTab { public String getDisplayName() { return StringUtils.translate(this.translationKey); } + + public boolean isAvailable() { + return this != LITEMATICA || FabricLoader.getInstance().isModLoaded("litematica"); + } } } diff --git a/src/main/java/com/listmore/config/ListMoreConfigs.java b/src/main/java/com/listmore/config/ListMoreConfigs.java index 1a65950..c575a55 100644 --- a/src/main/java/com/listmore/config/ListMoreConfigs.java +++ b/src/main/java/com/listmore/config/ListMoreConfigs.java @@ -49,8 +49,9 @@ public static class Generic { public static final ConfigBoolean TNT_EXPLOSION_PREVIEW = createTntExplosionPreview(); public static final ConfigOptionList TNT_EXPLOSION_PREVIEW_MODE = createTntExplosionPreviewMode(); public static final ConfigBoolean PROJECTILE_LANDING_PREDICTION = createProjectileLandingPrediction(); + public static final ConfigBoolean SCHEMATIC_DYNAMIC_PREVIEW = createSchematicDynamicPreview(); - public static final ImmutableList OPTIONS = ImmutableList.of( + public static final ImmutableList GENERIC_OPTIONS = ImmutableList.of( COPY_TARGET_ID, ENTITY_HIGHLIGHT_OUTLINE, ENTITY_HIGHLIGHT_OUTLINE_LIST, @@ -64,6 +65,26 @@ public static class Generic { TNT_EXPLOSION_PREVIEW_MODE, PROJECTILE_LANDING_PREDICTION ); + + public static final ImmutableList LITEMATICA_OPTIONS = ImmutableList.of( + SCHEMATIC_DYNAMIC_PREVIEW + ); + + public static final ImmutableList OPTIONS = ImmutableList.of( + COPY_TARGET_ID, + ENTITY_HIGHLIGHT_OUTLINE, + ENTITY_HIGHLIGHT_OUTLINE_LIST, + ENTITY_HIGHLIGHT_OUTLINE_COLOR, + ENTITY_RENDERING_BLACKLIST, + ENTITY_RENDERING_BLACKLIST_LIST, + ENTITY_RENDERING_BLACKLIST_RANGE, + INVALID_FURNACE_INPUT_HIGHLIGHTER, + INVALID_FURNACE_INPUT_HIGHLIGHTER_RANGE, + TNT_EXPLOSION_PREVIEW, + TNT_EXPLOSION_PREVIEW_MODE, + PROJECTILE_LANDING_PREDICTION, + SCHEMATIC_DYNAMIC_PREVIEW + ); } private static ConfigHotkey createCopyTargetId() { @@ -120,6 +141,10 @@ private static ConfigBoolean createProjectileLandingPrediction() { return createConfig(new ConfigBoolean("projectileLandingPrediction", false)); } + private static ConfigBoolean createSchematicDynamicPreview() { + return createConfig(new ConfigBoolean("schematicDynamicPreview", false)); + } + private static > T createConfig(T config) { return createConfig(config, value -> INSTANCE.save()); } diff --git a/src/main/java/com/listmore/mixin/litematica/GuiSchematicBrowserBaseMixin.java b/src/main/java/com/listmore/mixin/litematica/GuiSchematicBrowserBaseMixin.java new file mode 100644 index 0000000..1a59c7f --- /dev/null +++ b/src/main/java/com/listmore/mixin/litematica/GuiSchematicBrowserBaseMixin.java @@ -0,0 +1,41 @@ +package com.listmore.mixin.litematica; + +import com.listmore.config.ListMoreConfigs; +import fi.dy.masa.litematica.gui.GuiSchematicBrowserBase; +import fi.dy.masa.litematica.gui.widgets.WidgetSchematicBrowser; +import fi.dy.masa.malilib.gui.interfaces.ISelectionListener; +import fi.dy.masa.malilib.gui.widgets.WidgetFileBrowserBase.DirectoryEntry; +import me.fallenbreath.conditionalmixin.api.annotation.Condition; +import me.fallenbreath.conditionalmixin.api.annotation.Restriction; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Pseudo +@Restriction(require = @Condition("litematica")) +@Mixin(value = GuiSchematicBrowserBase.class, remap = false) +public abstract class GuiSchematicBrowserBaseMixin { + @Shadow protected abstract ISelectionListener getSelectionListener(); + + @Inject(method = "createListWidget(II)Lfi/dy/masa/litematica/gui/widgets/WidgetSchematicBrowser;", + at = @At("HEAD"), cancellable = true, remap = false) + private void listmore$createPreviewBrowser(int listX, int listY, + CallbackInfoReturnable cir) { + if (!ListMoreConfigs.Generic.SCHEMATIC_DYNAMIC_PREVIEW.getBooleanValue()) { + return; + } + try { + Class browserClass = Class.forName("com.listmore.schematic.ListMoreSchematicBrowser"); + Object browser = browserClass.getConstructor(int.class, int.class, int.class, int.class, + GuiSchematicBrowserBase.class, ISelectionListener.class) + .newInstance(listX, listY, 100, 100, (GuiSchematicBrowserBase) (Object) this, + this.getSelectionListener()); + cir.setReturnValue((WidgetSchematicBrowser) browser); + } catch (ReflectiveOperationException ignored) { + // The version-specific preview browser is intentionally optional. + } + } +} diff --git a/src/main/java/com/listmore/schematic/ListMoreSchematicBrowser.java b/src/main/java/com/listmore/schematic/ListMoreSchematicBrowser.java new file mode 100644 index 0000000..adab9f2 --- /dev/null +++ b/src/main/java/com/listmore/schematic/ListMoreSchematicBrowser.java @@ -0,0 +1,193 @@ +package com.listmore.schematic; + +import java.util.Date; +import org.jetbrains.annotations.Nullable; + +import fi.dy.masa.litematica.gui.GuiSchematicBrowserBase; +import fi.dy.masa.litematica.gui.widgets.WidgetSchematicBrowser; +import fi.dy.masa.litematica.schematic.LitematicaSchematic; +import fi.dy.masa.litematica.schematic.SchematicMetadata; +import fi.dy.masa.litematica.schematic.SchematicSchema; +import fi.dy.masa.malilib.gui.interfaces.ISelectionListener; +import fi.dy.masa.malilib.gui.widgets.WidgetFileBrowserBase.DirectoryEntry; +//#if MC >= 1.21.11 +//$$ import fi.dy.masa.malilib.render.GuiContext; +//#else +import net.minecraft.client.gui.GuiGraphics; +//#endif +import fi.dy.masa.malilib.render.RenderUtils; +import fi.dy.masa.malilib.util.StringUtils; +import fi.dy.masa.malilib.util.data.Schema; +//#if MC >= 1.21.10 +//$$ import net.minecraft.client.input.MouseButtonEvent; +//#endif +import net.minecraft.core.Vec3i; +import org.apache.commons.lang3.tuple.Pair; + +public final class ListMoreSchematicBrowser extends WidgetSchematicBrowser { + private final SchematicPreviewSession previewSession = new SchematicPreviewSession(); + @Nullable private SchematicPreviewLayout previewLayout; + private int lastMouseX; + private int lastMouseY; + + public ListMoreSchematicBrowser(int x, int y, int width, int height, GuiSchematicBrowserBase parent, + @Nullable ISelectionListener selectionListener) { + super(x, y, width, height, parent, selectionListener); + } + + @Override + protected void drawAdditionalContents( + //#if MC >= 1.21.11 + //$$ GuiContext context, + //#else + GuiGraphics context, + //#endif + int mouseX, int mouseY) { + this.lastMouseX = mouseX; + this.lastMouseY = mouseY; + super.drawAdditionalContents(context, mouseX, mouseY); + } + + @Override + protected void drawSelectedSchematicInfo( + //#if MC >= 1.21.11 + //$$ GuiContext context, + //#else + GuiGraphics context, + //#endif + @Nullable DirectoryEntry entry) { + if (!isLitematic(entry)) { + this.previewLayout = null; + super.drawSelectedSchematicInfo(context, entry); + return; + } + + Pair metaPair = this.getSchematicVersionAndMetadata(entry); + if (metaPair == null || metaPair.getRight() == null) { + this.previewLayout = null; + super.drawSelectedSchematicInfo(context, entry); + return; + } + + int x = this.posX + this.totalWidth - this.infoWidth; + int y = this.posY; + int height = Math.min(this.infoHeight, this.parent.getMaxInfoHeight()); + RenderUtils.drawOutlinedBox(context, x, y, this.infoWidth, height, 0xA0000000, COLOR_HORIZONTAL_BAR); + x += 3; + y += 3; + int textColor = 0xC0C0C0C0; + int valueColor = 0xFFFFFFFF; + SchematicMetadata meta = metaPair.getRight(); + SchematicSchema version = metaPair.getLeft(); + + this.drawString(context, StringUtils.translate("litematica.gui.label.schematic_info.name"), x, y, textColor); + y += 12; + this.drawString(context, meta.getName(), x + 4, y, valueColor); + y += 12; + this.drawString(context, StringUtils.translate("litematica.gui.label.schematic_info.schematic_author", meta.getAuthor()), x, y, textColor); + y += 12; + this.drawString(context, StringUtils.translate("litematica.gui.label.schematic_info.time_created", DATE_FORMAT.format(new Date(meta.getTimeCreated()))), x, y, textColor); + y += 12; + if (meta.hasBeenModified()) { + this.drawString(context, StringUtils.translate("litematica.gui.label.schematic_info.time_modified", DATE_FORMAT.format(new Date(meta.getTimeModified()))), x, y, textColor); + y += 12; + } + this.drawString(context, StringUtils.translate("litematica.gui.label.schematic_info.region_count", meta.getRegionCount()), x, y, textColor); + y += 12; + if (this.parent.getScreenHeight() >= 340) { + this.drawString(context, StringUtils.translate("litematica.gui.label.schematic_info.total_volume", meta.getTotalVolume()), x, y, textColor); + y += 12; + if (meta.getTotalBlocks() > 0) { + this.drawString(context, StringUtils.translate("litematica.gui.label.schematic_info.total_blocks", meta.getTotalBlocks()), x, y, textColor); + y += 12; + } + this.drawString(context, StringUtils.translate("litematica.gui.label.schematic_info.enclosing_size"), x, y, textColor); + y += 12; + Vec3i areaSize = meta.getEnclosingSize(); + this.drawString(context, String.format("%d x %d x %d", areaSize.getX(), areaSize.getY(), areaSize.getZ()), x + 4, y, valueColor); + y += 12; + } else { + String size = String.format("%d x %d x %d", meta.getEnclosingSize().getX(), meta.getEnclosingSize().getY(), meta.getEnclosingSize().getZ()); + String total = meta.getTotalBlocks() > 0 + ? StringUtils.translate("litematica.gui.label.schematic_info.total_blocks_and_volume", meta.getTotalBlocks(), meta.getTotalVolume()) + : StringUtils.translate("litematica.gui.label.schematic_info.total_volume", meta.getTotalVolume()); + this.drawString(context, total, x, y, textColor); + y += 12; + this.drawString(context, StringUtils.translate("litematica.gui.label.schematic_info.enclosing_size_value", size), x, y, textColor); + y += 12; + } + if (version != null) { + this.drawString(context, StringUtils.translate("litematica.gui.label.schematic_info.version", version.litematicVersion()), x, y, textColor); + y += 12; + Schema schema = Schema.getSchemaByDataVersion(version.minecraftDataVersion()); + if (schema != null) { + String key = version.minecraftDataVersion() - LitematicaSchematic.MINECRAFT_DATA_VERSION > 100 + ? "litematica.gui.label.schematic_info.schema.newer" : "litematica.gui.label.schematic_info.schema"; + this.drawString(context, StringUtils.translate(key, schema.getString(), version.minecraftDataVersion()), x, y, textColor); + y += 12; + } + } + + y += 12; + int previewHeight = height - (y - this.posY) - 6; + this.previewLayout = previewHeight >= 64 ? new SchematicPreviewLayout(x + 4, y, this.infoWidth - 10, previewHeight) : null; + if (this.previewLayout != null) { + this.previewSession.setFile(entry.getFullPath()); + this.previewSession.update(); + SchematicPreviewOverlay.draw(context, this.previewLayout, this.previewSession, this.lastMouseX, this.lastMouseY); + } + } + + @Override + //#if MC >= 1.21.10 + //$$ public boolean onMouseClicked(MouseButtonEvent click, boolean doubleClick) { + //$$ if (click.input() == 0 && this.previewLayout != null) { + //$$ if (this.previewLayout.zoomOutBounds().contains(click.x(), click.y())) { + //$$ this.previewSession.transform().zoomOut(); + //$$ return true; + //$$ } + //$$ if (this.previewLayout.zoomInBounds().contains(click.x(), click.y())) { + //$$ this.previewSession.transform().zoomIn(); + //$$ return true; + //$$ } + //$$ for (SchematicPreviewDirection direction : SchematicPreviewDirection.values()) { + //$$ if (this.previewLayout.buttonBounds(direction).contains(click.x(), click.y())) { + //$$ this.previewSession.transform().applyDirection(direction); + //$$ return true; + //$$ } + //$$ } + //$$ } + //$$ return super.onMouseClicked(click, doubleClick); + //$$ } + //#else + public boolean onMouseClicked(int mouseX, int mouseY, int button) { + if (button == 0 && this.previewLayout != null) { + if (this.previewLayout.zoomOutBounds().contains(mouseX, mouseY)) { + this.previewSession.transform().zoomOut(); + return true; + } + if (this.previewLayout.zoomInBounds().contains(mouseX, mouseY)) { + this.previewSession.transform().zoomIn(); + return true; + } + for (SchematicPreviewDirection direction : SchematicPreviewDirection.values()) { + if (this.previewLayout.buttonBounds(direction).contains(mouseX, mouseY)) { + this.previewSession.transform().applyDirection(direction); + return true; + } + } + } + return super.onMouseClicked(mouseX, mouseY, button); + } + //#endif + + @Override + public void onClose() { + this.previewSession.close(); + super.onClose(); + } + + private static boolean isLitematic(@Nullable DirectoryEntry entry) { + return entry != null && entry.getName().endsWith(LitematicaSchematic.FILE_EXTENSION); + } +} diff --git a/src/main/java/com/listmore/schematic/SchematicPreviewDirection.java b/src/main/java/com/listmore/schematic/SchematicPreviewDirection.java new file mode 100644 index 0000000..227ebb7 --- /dev/null +++ b/src/main/java/com/listmore/schematic/SchematicPreviewDirection.java @@ -0,0 +1,19 @@ +package com.listmore.schematic; + +public enum SchematicPreviewDirection { + UP("↑"), + LEFT("←"), + RESET("↻"), + RIGHT("→"), + DOWN("↓"); + + private final String label; + + SchematicPreviewDirection(String label) { + this.label = label; + } + + public String label() { + return this.label; + } +} diff --git a/src/main/java/com/listmore/schematic/SchematicPreviewLayout.java b/src/main/java/com/listmore/schematic/SchematicPreviewLayout.java new file mode 100644 index 0000000..bff7c09 --- /dev/null +++ b/src/main/java/com/listmore/schematic/SchematicPreviewLayout.java @@ -0,0 +1,70 @@ +package com.listmore.schematic; + +// 原理图浏览器右侧预览区域及方向按钮的布局计算 +public final class SchematicPreviewLayout { + private static final int BUTTON_SIZE = 16; + private static final int BUTTON_GAP = 2; + private static final int EDGE_OFFSET = 4; + + private final int x; + private final int y; + private final int width; + private final int height; + + public SchematicPreviewLayout(int x, int y, int width, int height) { + this.x = x; + this.y = y; + this.width = Math.max(0, width); + this.height = Math.max(0, height); + } + + public int x() { return this.x; } + public int y() { return this.y; } + public int width() { return this.width; } + public int height() { return this.height; } + + // 实际可绘制的区域 + public int contentX() { return this.x + 1; } + public int contentY() { return this.y + 1; } + public int contentWidth() { return Math.max(0, this.width - 2); } + public int contentHeight() { return Math.max(0, this.height - 2); } + + public boolean contains(double mouseX, double mouseY) { + return mouseX >= this.x && mouseX < this.x + this.width + && mouseY >= this.y && mouseY < this.y + this.height; + } + + public ButtonBounds buttonBounds(SchematicPreviewDirection direction) { + int centerX = this.x + this.width / 2; + int centerY = this.y + this.height / 2; + return switch (direction) { + case UP -> new ButtonBounds(centerX - BUTTON_SIZE / 2, this.y + EDGE_OFFSET, + BUTTON_SIZE, BUTTON_SIZE); + case LEFT -> new ButtonBounds(this.x + EDGE_OFFSET, centerY - BUTTON_SIZE / 2, + BUTTON_SIZE, BUTTON_SIZE); + case RESET -> new ButtonBounds(this.x + this.width - BUTTON_SIZE - EDGE_OFFSET, + this.y + this.height - BUTTON_SIZE - EDGE_OFFSET, BUTTON_SIZE, BUTTON_SIZE); + case RIGHT -> new ButtonBounds(this.x + this.width - BUTTON_SIZE - EDGE_OFFSET, + centerY - BUTTON_SIZE / 2, BUTTON_SIZE, BUTTON_SIZE); + case DOWN -> new ButtonBounds(centerX - BUTTON_SIZE / 2, + this.y + this.height - BUTTON_SIZE - EDGE_OFFSET, BUTTON_SIZE, BUTTON_SIZE); + }; + } + + public ButtonBounds zoomOutBounds() { + return new ButtonBounds(this.x + this.width - BUTTON_SIZE * 2 - BUTTON_GAP - EDGE_OFFSET, + this.y + EDGE_OFFSET, BUTTON_SIZE, BUTTON_SIZE); + } + + public ButtonBounds zoomInBounds() { + return new ButtonBounds(this.x + this.width - BUTTON_SIZE - EDGE_OFFSET, + this.y + EDGE_OFFSET, BUTTON_SIZE, BUTTON_SIZE); + } + + public record ButtonBounds(int x, int y, int width, int height) { + public boolean contains(double mouseX, double mouseY) { + return mouseX >= this.x && mouseX < this.x + this.width + && mouseY >= this.y && mouseY < this.y + this.height; + } + } +} diff --git a/src/main/java/com/listmore/schematic/SchematicPreviewLoader.java b/src/main/java/com/listmore/schematic/SchematicPreviewLoader.java new file mode 100644 index 0000000..cda5ae7 --- /dev/null +++ b/src/main/java/com/listmore/schematic/SchematicPreviewLoader.java @@ -0,0 +1,32 @@ +package com.listmore.schematic; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import fi.dy.masa.litematica.schematic.LitematicaSchematic; + +// 后台读取 litematic 文件 +public final class SchematicPreviewLoader { + private static final ConcurrentMap> CACHE = new ConcurrentHashMap<>(); + + private SchematicPreviewLoader() { + } + + public static CompletableFuture load(Path file) { + Path normalizedFile = file.toAbsolutePath().normalize(); + CompletableFuture task = CACHE.computeIfAbsent(normalizedFile, SchematicPreviewLoader::startLoad); + task.whenComplete((schematic, throwable) -> { + if (throwable != null || schematic == null) { + CACHE.remove(normalizedFile, task); + } + }); + return task; + } + + private static CompletableFuture startLoad(Path file) { + return CompletableFuture.supplyAsync(() -> + LitematicaSchematic.createFromFile(file.getParent(), file.getFileName().toString())); + } +} diff --git a/src/main/java/com/listmore/schematic/SchematicPreviewModel.java b/src/main/java/com/listmore/schematic/SchematicPreviewModel.java new file mode 100644 index 0000000..729c8ed --- /dev/null +++ b/src/main/java/com/listmore/schematic/SchematicPreviewModel.java @@ -0,0 +1,162 @@ +package com.listmore.schematic; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import fi.dy.masa.litematica.schematic.LitematicaSchematic; +import fi.dy.masa.litematica.schematic.container.LitematicaBlockStateContainer; +import fi.dy.masa.litematica.selection.Box; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Vec3i; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; + +public final class SchematicPreviewModel { + private final Vec3i size; + private final float centerX; + private final float centerY; + private final float centerZ; + private final List blocks; + private final Map statesByPosition; + private final Map blockEntitiesByPosition; + + private SchematicPreviewModel(Vec3i size, float centerX, float centerY, float centerZ, List blocks, + Map statesByPosition, Map blockEntitiesByPosition) { + this.size = size; + this.centerX = centerX; + this.centerY = centerY; + this.centerZ = centerZ; + this.blocks = List.copyOf(blocks); + this.statesByPosition = Map.copyOf(statesByPosition); + this.blockEntitiesByPosition = Map.copyOf(blockEntitiesByPosition); + } + + public Vec3i size() { return this.size; } + public float centerX() { return this.centerX; } + public float centerY() { return this.centerY; } + public float centerZ() { return this.centerZ; } + public List blocks() { return this.blocks; } + + public Object blockEntityDataAt(int x, int y, int z) { + return this.blockEntitiesByPosition.get(packPosition(x, y, z)); + } + + // 查询预览坐标中的方块状态 + public BlockState blockStateAt(int x, int y, int z) { + if (x < 0 || y < 0 || z < 0 || x >= this.size.getX() || y >= this.size.getY() || z >= this.size.getZ()) { + return Blocks.AIR.defaultBlockState(); + } + return this.statesByPosition.getOrDefault(packPosition(x, y, z), Blocks.AIR.defaultBlockState()); + } + + // 从 Litematica 已解析的数据中提取非空气方块 + // 流程:遍历所有区域 -> 计算包围盒 -> 遍历容器提取非空气方块并转为相对坐标 -> 收集方块实体数据 + public static SchematicPreviewModel from(LitematicaSchematic schematic) { + Map areas = schematic.getAreas(); + if (areas.isEmpty()) { + return empty(); + } + + // 遍历所有区域计算包围盒 + int minX = Integer.MAX_VALUE; + int minY = Integer.MAX_VALUE; + int minZ = Integer.MAX_VALUE; + int maxXExclusive = Integer.MIN_VALUE; + int maxYExclusive = Integer.MIN_VALUE; + int maxZExclusive = Integer.MIN_VALUE; + for (Map.Entry entry : areas.entrySet()) { + LitematicaBlockStateContainer container = schematic.getSubRegionContainer(entry.getKey()); + Box area = entry.getValue(); + BlockPos first = area.getPos1(); + BlockPos second = area.getPos2(); + if (container == null || first == null || second == null) { + continue; + } + // 区域原点取两角点中较小者 + int originX = Math.min(first.getX(), second.getX()); + int originY = Math.min(first.getY(), second.getY()); + int originZ = Math.min(first.getZ(), second.getZ()); + Vec3i regionSize = container.getSize(); + // 更新全局包围盒:原点取最小,远端取 origin+size 的最大值 + minX = Math.min(minX, originX); + minY = Math.min(minY, originY); + minZ = Math.min(minZ, originZ); + maxXExclusive = Math.max(maxXExclusive, originX + regionSize.getX()); + maxYExclusive = Math.max(maxYExclusive, originY + regionSize.getY()); + maxZExclusive = Math.max(maxZExclusive, originZ + regionSize.getZ()); + } + if (minX == Integer.MAX_VALUE) { + return empty(); + } + + // 从每个区域中提取非空气方块,转换为相对坐标 + List blocks = new ArrayList<>(); + Map statesByPosition = new HashMap<>(); + Map blockEntitiesByPosition = new HashMap<>(); + for (Map.Entry entry : areas.entrySet()) { + LitematicaBlockStateContainer container = schematic.getSubRegionContainer(entry.getKey()); + BlockPos first = entry.getValue().getPos1(); + BlockPos second = entry.getValue().getPos2(); + if (container == null || first == null || second == null) { + continue; + } + Vec3i regionSize = container.getSize(); + int originX = Math.min(first.getX(), second.getX()); + int originY = Math.min(first.getY(), second.getY()); + int originZ = Math.min(first.getZ(), second.getZ()); + // 遍历容器内所有方块,只保留非空气方块 + for (int y = 0; y < regionSize.getY(); y++) { + for (int z = 0; z < regionSize.getZ(); z++) { + for (int x = 0; x < regionSize.getX(); x++) { + BlockState state = container.get(x, y, z); + if (!state.isAir()) { + // 绝对坐标 -> 相对坐标:减去全局包围盒原点 + int relativeX = x + originX - minX; + int relativeY = y + originY - minY; + int relativeZ = z + originZ - minZ; + long position = packPosition(relativeX, relativeY, relativeZ); + // putIfAbsent 防止重叠区域重复添加同一位置的方块 + if (statesByPosition.putIfAbsent(position, state) == null) { + blocks.add(new Block(relativeX, relativeY, relativeZ, state)); + } + } + } + } + } + // 收集方块实体数据(如箱子、告示牌等) + Map blockEntities = schematic.getBlockEntityMapForRegion(entry.getKey()); + if (blockEntities != null) { + for (Map.Entry blockEntity : blockEntities.entrySet()) { + if (blockEntity.getValue() == null) { + continue; + } + BlockPos position = blockEntity.getKey(); + int relativeX = position.getX() + originX - minX; + int relativeY = position.getY() + originY - minY; + int relativeZ = position.getZ() + originZ - minZ; + blockEntitiesByPosition.putIfAbsent(packPosition(relativeX, relativeY, relativeZ), blockEntity.getValue()); + } + } + } + + // 包围盒尺寸 = 远端 - 原点,中心 = 尺寸 * 0.5(几何中心) + Vec3i size = new Vec3i(maxXExclusive - minX, maxYExclusive - minY, maxZExclusive - minZ); + return new SchematicPreviewModel(size, size.getX() * 0.5F, size.getY() * 0.5F, size.getZ() * 0.5F, + blocks, statesByPosition, blockEntitiesByPosition); + } + + private static SchematicPreviewModel empty() { + return new SchematicPreviewModel(BlockPos.ZERO, 0.0F, 0.0F, 0.0F, List.of(), Map.of(), Map.of()); + } + + // 将坐标打包为 long + private static long packPosition(int x, int y, int z) { + return ((long) x << 42) | ((long) y << 21) | z; + } + + // 单个非空气方块及其相对原理图坐标 + public record Block(int x, int y, int z, BlockState state) { + } +} diff --git a/src/main/java/com/listmore/schematic/SchematicPreviewOverlay.java b/src/main/java/com/listmore/schematic/SchematicPreviewOverlay.java new file mode 100644 index 0000000..4896edd --- /dev/null +++ b/src/main/java/com/listmore/schematic/SchematicPreviewOverlay.java @@ -0,0 +1,137 @@ +package com.listmore.schematic; + +import fi.dy.masa.malilib.render.RenderUtils; +import fi.dy.masa.malilib.util.StringUtils; + +//#if MC >= 1.21.11 +//$$ import fi.dy.masa.malilib.render.GuiContext; +//#else +import net.minecraft.client.gui.GuiGraphics; +//#endif + +// 原理图浏览器右侧区域的GUI覆盖层 -> 渲染层 +public final class SchematicPreviewOverlay { + private static final int BACKGROUND_COLOR = 0xEE111820; + private static final int BORDER_COLOR = 0xFF5C7185; + private static final int BUTTON_COLOR = 0xFF263846; + private static final int BUTTON_HOVER_COLOR = 0xFF3E6572; + + private SchematicPreviewOverlay() { + } + + // 绘制预览背景、加载状态和方向操作按钮 + // 组件层级:背景 -> 边框 -> 状态提示/3D 模型/占位符 -> 方向按钮 -> 缩放按钮 + public static void draw( + //#if MC >= 1.21.11 + //$$ GuiContext context, + //#else + GuiGraphics context, + //#endif + SchematicPreviewLayout layout, SchematicPreviewSession session, int mouseX, int mouseY) { + RenderUtils.drawRect(context, layout.x(), layout.y(), layout.width(), layout.height(), BACKGROUND_COLOR); + drawBorder(context, layout); + + if (session.isLoading()) { + drawCentered(context, layout, StringUtils.translate("listmore.schematic_preview.loading")); + } else if (session.hasFailure()) { + drawCentered(context, layout, StringUtils.translate("listmore.schematic_preview.failed")); + } else if (session.model() != null) { + boolean rendered = session.renderer().render(context, layout, session.transform()); + if (!rendered) { + drawModelPlaceholder(context, layout, session.model().blocks().size()); + } + } else { + drawCentered(context, layout, StringUtils.translate("listmore.schematic_preview.empty")); + } + + for (SchematicPreviewDirection direction : SchematicPreviewDirection.values()) { + SchematicPreviewLayout.ButtonBounds button = layout.buttonBounds(direction); + drawButton(context, button, direction.label(), mouseX, mouseY); + } + drawButton(context, layout.zoomOutBounds(), "-", mouseX, mouseY); + drawButton(context, layout.zoomInBounds(), "+", mouseX, mouseY); + } + + private static void drawButton( + //#if MC >= 1.21.11 + //$$ GuiContext context, + //#else + GuiGraphics context, + //#endif + SchematicPreviewLayout.ButtonBounds button, String label, int mouseX, int mouseY) { + int color = button.contains(mouseX, mouseY) ? BUTTON_HOVER_COLOR : BUTTON_COLOR; + RenderUtils.drawRect(context, button.x(), button.y(), button.width(), button.height(), color); + drawText(context, button.x() + 5, button.y() + 4, 0xFFFFFFFF, label); + } + + private static void drawBorder( + //#if MC >= 1.21.11 + //$$ GuiContext context, + //#else + GuiGraphics context, + //#endif + SchematicPreviewLayout layout) { + RenderUtils.drawRect(context, layout.x(), layout.y(), layout.width(), 1, BORDER_COLOR); + RenderUtils.drawRect(context, layout.x(), layout.y() + layout.height() - 1, layout.width(), 1, BORDER_COLOR); + RenderUtils.drawRect(context, layout.x(), layout.y(), 1, layout.height(), BORDER_COLOR); + RenderUtils.drawRect(context, layout.x() + layout.width() - 1, layout.y(), 1, layout.height(), BORDER_COLOR); + } + + // 当渲染后端不可用时显示的占位符,绘制一个绿色方框和方块数量 + private static void drawModelPlaceholder( + //#if MC >= 1.21.11 + //$$ GuiContext context, + //#else + GuiGraphics context, + //#endif + SchematicPreviewLayout layout, int blockCount) { + int centerX = layout.x() + layout.width() / 2; + int centerY = layout.y() + Math.max(20, (layout.height() - 56) / 2); + int halfWidth = Math.min(36, Math.max(12, layout.width() / 4)); + int halfHeight = Math.min(25, Math.max(8, layout.height() / 6)); + RenderUtils.drawRect(context, centerX - halfWidth, centerY - halfHeight, halfWidth * 2, 1, 0xFF8CC6A2); + RenderUtils.drawRect(context, centerX - halfWidth, centerY + halfHeight, halfWidth * 2, 1, 0xFF8CC6A2); + RenderUtils.drawRect(context, centerX - halfWidth, centerY - halfHeight, 1, halfHeight * 2, 0xFF8CC6A2); + RenderUtils.drawRect(context, centerX + halfWidth, centerY - halfHeight, 1, halfHeight * 2, 0xFF8CC6A2); + drawCenteredAt(context, centerX, centerY - 4, 0xFFBFD9C7, + StringUtils.translate("listmore.schematic_preview.building")); + drawCenteredAt(context, centerX, centerY + 8, 0xFF9FB4C2, + StringUtils.translate("listmore.schematic_preview.blocks", blockCount)); + } + + private static void drawCentered( + //#if MC >= 1.21.11 + //$$ GuiContext context, + //#else + GuiGraphics context, + //#endif + SchematicPreviewLayout layout, String text) { + int x = layout.x() + Math.max(4, (layout.width() - text.length() * 6) / 2); + int y = layout.y() + Math.max(8, layout.height() / 2 - 4); + drawText(context, x, y, 0xFFB9C6D0, text); + } + + private static void drawCenteredAt( + //#if MC >= 1.21.11 + //$$ GuiContext context, + //#else + GuiGraphics context, + //#endif + int centerX, int y, int color, String text) { + drawText(context, centerX - text.length() * 3, y, color, text); + } + + private static void drawText( + //#if MC >= 1.21.11 + //$$ GuiContext context, + //#else + GuiGraphics context, + //#endif + int x, int y, int color, String text) { + //#if MC >= 1.21.11 + //$$ StringUtils.drawString(context, x, y, color, text); + //#else + StringUtils.drawString(x, y, color, text, context); + //#endif + } +} diff --git a/src/main/java/com/listmore/schematic/SchematicPreviewRenderBackend.java b/src/main/java/com/listmore/schematic/SchematicPreviewRenderBackend.java new file mode 100644 index 0000000..7899711 --- /dev/null +++ b/src/main/java/com/listmore/schematic/SchematicPreviewRenderBackend.java @@ -0,0 +1,15 @@ +package com.listmore.schematic; + +// 原理图预览的版本专属渲染后端,公共预览逻辑只通过这个接口调用 +public interface SchematicPreviewRenderBackend extends AutoCloseable { + default void setSchematic(Object schematic) { + } + + // 将当前快照绘制到预览区域;无法绘制时返回false + boolean render(Object context, SchematicPreviewLayout layout, SchematicPreviewTransform transform, + SchematicPreviewModel model, long revision); + + @Override + default void close() { + } +} diff --git a/src/main/java/com/listmore/schematic/SchematicPreviewRenderManager.java b/src/main/java/com/listmore/schematic/SchematicPreviewRenderManager.java new file mode 100644 index 0000000..c02e6a1 --- /dev/null +++ b/src/main/java/com/listmore/schematic/SchematicPreviewRenderManager.java @@ -0,0 +1,74 @@ +package com.listmore.schematic; + +//#if MC >= 1.21.11 +//$$ import fi.dy.masa.malilib.render.GuiContext; +//#else +import net.minecraft.client.gui.GuiGraphics; +//#endif + +public final class SchematicPreviewRenderManager implements AutoCloseable { + private SchematicPreviewModel model; + private Object schematic; + private long modelRevision; + private final SchematicPreviewRenderBackend backend = createBackend(); + + // 提交新的原理图快照,模型只会在 GUI 线程调用此方法 + public void setModel(SchematicPreviewModel model) { + if (this.model == model) { + return; + } + this.model = model; + this.modelRevision++; + } + + public void setSchematic(Object schematic) { + this.schematic = schematic; + if (this.backend != null) { + this.backend.setSchematic(schematic); + } + } + + // 返回当前快照的版本号 + public long modelRevision() { + return this.modelRevision; + } + + // 将预览模型绘制到右侧信息面板 + // 版本差异由渲染后端处理:通过接口委托给版本专属的 SchematicPreviewRenderer 实现 + public boolean render( + //#if MC >= 1.21.11 + //$$ GuiContext context, + //#else + GuiGraphics context, + //#endif + SchematicPreviewLayout layout, SchematicPreviewTransform transform) { + return this.backend != null && this.backend.render(context, layout, transform, this.model, this.modelRevision); + } + + public SchematicPreviewModel model() { + return this.model; + } + + // 关闭预览时释放渲染资源 + @Override + public void close() { + if (this.backend != null) { + this.backend.close(); + } + this.model = null; + this.schematic = null; + this.modelRevision++; + } + + // 通过反射加载版本专属的渲染后端,避免编译时硬依赖 + // 如果当前版本没有对应的 SchematicPreviewRenderer 类,回退到 null(GUI 占位预览) + private static SchematicPreviewRenderBackend createBackend() { + try { + Class backendClass = Class.forName("com.listmore.schematic.SchematicPreviewRenderer"); + return backendClass.asSubclass(SchematicPreviewRenderBackend.class).getConstructor().newInstance(); + } catch (ReflectiveOperationException ignored) { + // 当前版本尚未提供专属后端时保留 GUI 占位预览 + return null; + } + } +} diff --git a/src/main/java/com/listmore/schematic/SchematicPreviewRenderer.java b/src/main/java/com/listmore/schematic/SchematicPreviewRenderer.java new file mode 100644 index 0000000..1eae4ef --- /dev/null +++ b/src/main/java/com/listmore/schematic/SchematicPreviewRenderer.java @@ -0,0 +1,618 @@ +package com.listmore.schematic; + +import com.listmore.ListMore; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.OptionalInt; + +//#if MC >= 26.2 +//$$ import com.mojang.blaze3d.IndexType; +//$$ import com.mojang.blaze3d.PrimitiveTopology; +//$$ import com.mojang.blaze3d.GpuFormat; +//#else +import com.mojang.blaze3d.vertex.VertexFormat.IndexType; +//#endif +import com.mojang.blaze3d.ProjectionType; +import com.mojang.blaze3d.buffers.GpuBuffer; +import com.mojang.blaze3d.buffers.GpuBufferSlice; +import com.mojang.blaze3d.buffers.Std140Builder; +import com.mojang.blaze3d.pipeline.TextureTarget; +import com.mojang.blaze3d.platform.Lighting; +import com.mojang.blaze3d.systems.RenderPass; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.textures.AddressMode; +import com.mojang.blaze3d.textures.FilterMode; +//#if MC >= 1.21.11 +//$$ import com.mojang.blaze3d.textures.GpuSampler; +//#endif +import com.mojang.blaze3d.textures.GpuTextureView; +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.MeshData; +//#if MC < 26.2 +import com.mojang.blaze3d.vertex.VertexFormat; +//#endif +//#if MC >= 26.1 +//$$ import fi.dy.masa.litematica.render.schematic.BlockModelRendererSchematic; +//$$ import fi.dy.masa.litematica.render.schematic.IBlockOutputSchematic; +//$$ import fi.dy.masa.litematica.schematic.LitematicaSchematic; +//#else +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.renderer.ItemBlockRenderTypes; +import net.minecraft.util.RandomSource; +//#endif +//#if MC >= 1.21.11 +//$$ import fi.dy.masa.malilib.render.GuiContext; +//#else +import net.minecraft.client.gui.GuiGraphics; +//#endif +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.render.TextureSetup; +import net.minecraft.client.renderer.DynamicUniforms; +import net.minecraft.client.renderer.GlobalSettingsUniform; +//#if MC >= 26.1 +//$$ import net.minecraft.client.renderer.Projection; +//#endif +//#if MC >= 26.1 +//$$ import net.minecraft.client.renderer.block.FluidRenderer; +//$$ import net.minecraft.client.renderer.ProjectionMatrixBuffer; +//#else +import net.minecraft.client.renderer.PerspectiveProjectionMatrixBuffer; +//#endif +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.chunk.ChunkSectionLayer; +import net.minecraft.client.renderer.chunk.ChunkSectionLayerGroup; +//#if MC >= 26.1 +//$$ import net.minecraft.client.renderer.state.gui.BlitRenderState; +//#else +import net.minecraft.client.gui.render.state.BlitRenderState; +//#endif +import net.minecraft.client.renderer.texture.TextureAtlas; +//#if MC >= 26.1 +//$$ import net.minecraft.world.phys.Vec3; +//#endif +import net.minecraft.client.resources.model.ModelManager; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.util.Mth; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import org.joml.Matrix4f; +import org.joml.Matrix4fStack; +import org.joml.Matrix3x2f; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.joml.Vector4f; +import org.lwjgl.system.MemoryStack; + +public final class SchematicPreviewRenderer implements SchematicPreviewRenderBackend { + private final List meshes = new ArrayList<>(); + private long builtRevision = Long.MIN_VALUE; + //#if MC >= 26.1 + //$$ private LitematicaSchematic schematic; + //#endif + private SchematicPreviewWorld world; + private TextureTarget target; + //#if MC >= 1.21.11 + //$$ private GpuSampler sampler; + //#endif + //#if MC >= 26.1 + //$$ private ProjectionMatrixBuffer projection; + //#else + private PerspectiveProjectionMatrixBuffer projection; + //#endif +//#if MC >= 26.1 + //$$ private final Projection previewProjection = new Projection(); +//#endif + private GpuBuffer globalUniform; + private boolean rebuildFailureLogged; + private boolean renderFailureLogged; + //#if MC >= 26.1 + //$$ private final FluidRenderer fluidRenderer = new FluidRenderer(Minecraft.getInstance().getModelManager().getFluidStateModelSet()); + //#endif + @Override + public void setSchematic(Object schematic) { + //#if MC >= 26.1 + //$$ this.schematic = schematic instanceof LitematicaSchematic loaded ? loaded : null; + //#endif + } + + // 将原理图模型渲染到离屏帧缓冲,再将结果贴图绘制到 GUI 面板 + // 流程:校验模型 -> 确保帧缓冲尺寸 -> 增量重建网格 -> 清空帧缓冲 -> 3D 渲染 -> 贴图到 GUI + public boolean render(Object rawContext, SchematicPreviewLayout layout, SchematicPreviewTransform transform, + SchematicPreviewModel model, long revision) { + //#if MC >= 1.21.11 + //$$ GuiContext context = (GuiContext) rawContext; + //#else + GuiGraphics context = (GuiGraphics) rawContext; + //#endif + if (model == null || model.blocks().isEmpty() || layout.contentWidth() <= 0 || layout.contentHeight() <= 0) { + return false; + } + // 确保离屏帧缓冲尺寸与当前预览区域匹配 + this.ensureTarget(layout.contentWidth(), layout.contentHeight()); + // 仅当模型快照版本号变化时才重建网格,避免每帧重建 + if (this.builtRevision != revision) { + try { + this.rebuild(model, revision); + this.rebuildFailureLogged = false; + } catch (Throwable throwable) { + if (!this.rebuildFailureLogged) { + ListMore.LOGGER.error("Failed to build schematic preview meshes", throwable); + this.rebuildFailureLogged = true; + } + return false; + } + } + if (this.meshes.isEmpty()) { + return false; + } + + // 清空帧缓冲的颜色和深度,背景色为0xFF1B2028 + //#if MC >= 26.2 + //$$ RenderSystem.getDevice().createCommandEncoder().clearColorAndDepthTextures( + //$$ this.target.getColorTexture(), new org.joml.Vector4f(0.105F, 0.125F, 0.157F, 1.0F), + //$$ this.target.getDepthTexture(), 0.0D); + //#else + RenderSystem.getDevice().createCommandEncoder().clearColorAndDepthTextures( + this.target.getColorTexture(), 0xFF1B2028, this.target.getDepthTexture(), 1.0D); + //#endif + try { + // 3D渲染阶段:设置相机投影并绘制所有网格到离屏帧缓冲 + this.renderTarget(model, transform); + this.renderFailureLogged = false; + } catch (Throwable throwable) { + if (!this.renderFailureLogged) { + ListMore.LOGGER.error("Failed to render schematic preview", throwable); + this.renderFailureLogged = true; + } + return false; + } + // 将离屏帧缓冲的纹理贴图到 GUI 面板的指定区域 + BlitRenderState blit = new BlitRenderState( + RenderPipelines.GUI_TEXTURED, + //#if MC >= 1.21.11 + //$$ TextureSetup.singleTexture(this.target.getColorTextureView(), this.sampler), + //#else + TextureSetup.singleTexture(this.target.getColorTextureView()), + //#endif + new Matrix3x2f(context.pose()), + layout.contentX(), layout.contentY(), + layout.contentX() + layout.contentWidth(), layout.contentY() + layout.contentHeight(), + 0.0F, 1.0F, 1.0F, 0.0F, 0xFFFFFFFF, context.scissorStack.peek()); + //#if MC >= 1.21.11 + //$$ context.addSimpleElement(blit); + //#else + context.guiRenderState.submitBlitToCurrentLayer(blit); + //#endif + return true; + } + + private void ensureTarget(int width, int height) { + int scaledWidth = Math.max(1, Math.round(width * Minecraft.getInstance().getWindow().getGuiScale())); + int scaledHeight = Math.max(1, Math.round(height * Minecraft.getInstance().getWindow().getGuiScale())); + if (this.target == null) { + //#if MC >= 26.2 + //$$ this.target = new TextureTarget("ListMore schematic preview", scaledWidth, scaledHeight, true, GpuFormat.RGBA8_UNORM); + //#else + this.target = new TextureTarget("ListMore schematic preview", scaledWidth, scaledHeight, true); + //#endif + } else if (this.target.width != scaledWidth || this.target.height != scaledHeight) { + this.target.resize(scaledWidth, scaledHeight); + } + //#if MC >= 1.21.11 + //$$ if (this.sampler == null) { + //$$ this.sampler = RenderSystem.getDevice().createSampler(AddressMode.CLAMP_TO_EDGE, AddressMode.CLAMP_TO_EDGE, + //$$ FilterMode.LINEAR, FilterMode.LINEAR, 1, OptionalDouble.empty()); + //$$ } + //#endif + } + + // 从原理图模型重建所有网格数据并上传到 GPU + // 流程:释放旧网格 -> 创建虚拟世界视图 -> 遍历方块按区块分组 -> 调用原版渲染器生成网格 -> 构建 GPU 缓冲区 + private void rebuild(SchematicPreviewModel model, long revision) { + this.closeMeshes(); + this.builtRevision = revision; + //#if MC >= 26.1 + //$$ if (this.schematic == null) { + //$$ return; + //$$ } + //#endif + if (this.world == null) { + this.world = new SchematicPreviewWorld(Minecraft.getInstance()); + } + this.world.setModel(model); + SchematicPreviewWorld view = this.world; + //#if MC >= 26.1 + //$$ ModelManager modelManager = Minecraft.getInstance().getModelManager(); + //#endif + // 按区块位置分组构建网格,每个 ChunkPos 对应一个 ChunkMeshBuilder + Map chunks = new HashMap<>(); + //#if MC >= 26.1 + //$$ BlockModelRendererSchematic renderer = new BlockModelRendererSchematic(); + //$$ renderer.enableCache(); + //#endif + try { + // 遍历所有非空气方块,分别渲染流体和固体模型 + for (SchematicPreviewModel.Block block : model.blocks()) { + BlockState state = block.state(); + BlockPos position = new BlockPos(block.x(), block.y(), block.z()); + // >> 4 将方块坐标转为区块坐标,computeIfAbsent 保证每个区块只有一个 Builder + ChunkMeshBuilder chunk = chunks.computeIfAbsent(new ChunkPos(block.x() >> 4, block.z() >> 4), ignored -> new ChunkMeshBuilder()); + // 先渲染流体(水、岩浆等),再渲染固体方块模型 + //#if MC >= 26.1 + //$$ if (!state.getFluidState().isEmpty()) { + //$$ this.fluidRenderer.tesselate(view, position, chunk::builder, state, state.getFluidState()); + //$$ } + //#else + if (!state.getFluidState().isEmpty()) { + Minecraft.getInstance().getBlockRenderer().renderLiquid(position, view, + chunk.builder(ItemBlockRenderTypes.getRenderLayer(state.getFluidState())), state, state.getFluidState()); + } + //#endif + if (state.getRenderShape() != RenderShape.MODEL) { + continue; + } + //#if MC >= 26.1 + //$$ IBlockOutputSchematic output = (x, y, z, quad, instance) -> chunk.builder(quad.materialInfo().layer()) + //$$ .putBlockBakedQuad(x, y, z, quad, instance); + //$$ renderer.tessellateBlock(view, state, position, new Vec3(block.x() & 15, block.y(), block.z() & 15), + //$$ modelManager.getBlockStateModelSet().get(state), state.getSeed(position), output); + //#else + PoseStack pose = new PoseStack(); + // & 15 取区块内局部坐标(0-15),模型顶点需要相对区块原点的偏移 + pose.translate(block.x() & 15, block.y(), block.z() & 15); + Minecraft.getInstance().getBlockRenderer().renderBatched(state, position, view, pose, + chunk.builder(ItemBlockRenderTypes.getChunkRenderType(state)), true, + Minecraft.getInstance().getBlockRenderer().getBlockModel(state) + .collectParts(RandomSource.create(state.getSeed(position)))); + //#endif + } + } finally { + //#if MC >= 26.1 + //$$ renderer.disableCache(); + //#endif + } + // 所有方块遍历完毕后,将每个区块的网格数据上传到 GPU + chunks.forEach((position, chunk) -> { + ChunkMesh built = chunk.build(position); + if (built != null) { + this.meshes.add(built); + } + }); + } + + // 设置相机、投影矩阵和全局 Uniform,然后将所有网格渲染到离屏帧缓冲 + // 相机位置根据偏航角/俯仰角/缩放计算,围绕模型包围盒的中心旋转 + private void renderTarget(SchematicPreviewModel model, SchematicPreviewTransform transform) { + // 计算相机在模型包围球上的位置 + // 球坐标 -> 空间坐标:x = centerX - sin(yaw)*cos(pitch)*dist, y = centerY - sin(pitch)*dist, z = centerZ + cos(yaw)*cos(pitch)*dist + float distance = transform.distance(model.size().getX(), model.size().getY(), model.size().getZ()); + float yaw = transform.yaw() * Mth.DEG_TO_RAD; + float pitch = transform.pitch() * Mth.DEG_TO_RAD; + float horizontal = Mth.cos(pitch); + Vector3f camera = new Vector3f( + model.centerX() - Mth.sin(yaw) * horizontal * distance, + model.centerY() - Mth.sin(pitch) * distance, + model.centerZ() + Mth.cos(yaw) * horizontal * distance); + Matrix4fStack modelView = RenderSystem.getModelViewStack(); + modelView.pushMatrix(); + // 与 SchematicPreview 的轨道约定一致:UI 偏航角描述观察方向 + // 而地形视图矩阵使用相机偏航角的逆,因此取 -yaw 并共轭 + Quaternionf rotation = new Quaternionf().rotationYXZ(-yaw, pitch, 0.0F).conjugate(); + modelView.set(new Matrix4f().rotation(rotation)); + // 构建透视投影矩阵,近平面 0.05,远平面 4096,FOV 70° + //#if MC >= 26.1 + //$$ this.previewProjection.setupPerspective(0.05F, 4096.0F, 70.0F, + //$$ this.target.width, this.target.height); + //$$ Matrix4f projectionMatrix = this.previewProjection.getMatrix(new Matrix4f()); + //#else + Matrix4f projectionMatrix = new Matrix4f().perspective(70.0F * Mth.DEG_TO_RAD, + (float) this.target.width / this.target.height, 0.05F, 4096.0F); + //#endif + // 备份原版投影矩阵和全局 Uniform,渲染完成后恢复 + RenderSystem.backupProjectionMatrix(); + if (this.projection == null) { + //#if MC >= 26.1 + //$$ this.projection = new ProjectionMatrixBuffer("ListMore schematic preview"); + //#else + this.projection = new PerspectiveProjectionMatrixBuffer("ListMore schematic preview"); + //#endif + } + RenderSystem.setProjectionMatrix(this.projection.getBuffer(projectionMatrix), ProjectionType.PERSPECTIVE); + GpuBuffer previousGlobalUniform = RenderSystem.getGlobalSettingsUniform(); + this.writeGlobalUniform(camera); + try { + //#if MC >= 26.2 + //$$ Minecraft.getInstance().gameRenderer.lighting().setupFor(Lighting.Entry.LEVEL); + //#else + Minecraft.getInstance().gameRenderer.getLighting().setupFor(Lighting.Entry.LEVEL); + //#endif + this.renderMeshes(camera); + } finally { + RenderSystem.setGlobalSettingsUniform(previousGlobalUniform); + RenderSystem.restoreProjectionMatrix(); + modelView.popMatrix(); + } + } + + // 向 GPU 写入全局 Uniform 缓冲区,包含相机位置和窗口尺寸 + // 使用 Std140 布局,与着色器中的 GlobalSettings 结构体对齐 + private void writeGlobalUniform(Vector3f camera) { + if (this.globalUniform == null) { + this.globalUniform = RenderSystem.getDevice().createBuffer(() -> "ListMore preview global settings", + GpuBuffer.USAGE_UNIFORM | GpuBuffer.USAGE_COPY_DST, GlobalSettingsUniform.UBO_SIZE); + } + try (MemoryStack stack = MemoryStack.stackPush()) { + //#if MC >= 1.21.11 + //$$ int floorX = Mth.floor(camera.x); + //$$ int floorY = Mth.floor(camera.y); + //$$ int floorZ = Mth.floor(camera.z); + //$$ RenderSystem.getDevice().createCommandEncoder().writeToBuffer(this.globalUniform.slice(), + //$$ Std140Builder.onStack(stack, GlobalSettingsUniform.UBO_SIZE) + //$$ .putIVec3(floorX, floorY, floorZ) + //$$ .putVec3(floorX - camera.x, floorY - camera.y, floorZ - camera.z) + //$$ .putVec2(Minecraft.getInstance().getWindow().getWidth(), Minecraft.getInstance().getWindow().getHeight()) + //$$ .putFloat(1.0F).putFloat(0.0F).putInt(0).putInt(0) + //$$ .get()); + //#else + RenderSystem.getDevice().createCommandEncoder().writeToBuffer(this.globalUniform.slice(), + Std140Builder.onStack(stack, GlobalSettingsUniform.UBO_SIZE) + .putVec2(Minecraft.getInstance().getWindow().getWidth(), Minecraft.getInstance().getWindow().getHeight()) + .putFloat(1.0F).putFloat(0.0F).putInt(0) + .get()); + //#endif + } + RenderSystem.setGlobalSettingsUniform(this.globalUniform); + } + + // 按距离排序所有网格,为每个网格构建 Transform/SectionInfo Uniform,然后分不透明/半透明两层渲染 + private void renderMeshes(Vector3f camera) { + var atlas = Minecraft.getInstance().getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS).getTextureView(); + List orderedMeshes = new ArrayList<>(this.meshes); + // 按距离降序排列(远的先画),负号使 Comparator 从大到小 + orderedMeshes.sort(Comparator.comparingDouble(mesh -> -mesh.distanceTo(camera))); + //#if MC >= 1.21.11 + //$$ DynamicUniforms.ChunkSectionInfo[] infos = new DynamicUniforms.ChunkSectionInfo[orderedMeshes.size()]; + //$$ for (int i = 0; i < orderedMeshes.size(); i++) { + //$$ ChunkMesh mesh = orderedMeshes.get(i); + //$$ infos[i] = new DynamicUniforms.ChunkSectionInfo( + //#if MC >= 26.2 + //$$ RenderSystem.getModelViewMatrixCopy(), + //#else + //$$ new Matrix4f(RenderSystem.getModelViewMatrix()), + //#endif + //$$ mesh.chunk().getMinBlockX(), 0, + //$$ mesh.chunk().getMinBlockZ(), 1.0F, atlas.getWidth(0), atlas.getHeight(0)); + //$$ } + //$$ GpuBufferSlice[] sectionUniforms = RenderSystem.getDynamicUniforms().writeChunkSections(infos); + //#else + DynamicUniforms.Transform[] transforms = new DynamicUniforms.Transform[orderedMeshes.size()]; + for (int i = 0; i < orderedMeshes.size(); i++) { + ChunkMesh mesh = orderedMeshes.get(i); + //#if MC < 1.21.11 + transforms[i] = new DynamicUniforms.Transform(new Matrix4f(RenderSystem.getModelViewMatrix()), + new Vector4f(1.0F), new Vector3f(mesh.chunk().getMinBlockX() - camera.x, -camera.y, + mesh.chunk().getMinBlockZ() - camera.z), new Matrix4f(), 1.0F); + //#else + //$$ transforms[i] = new DynamicUniforms.Transform(new Matrix4f(RenderSystem.getModelViewMatrix()), + //$$ new Vector4f(1.0F), new Vector3f(mesh.chunk().getMinBlockX() - camera.x, -camera.y, + //$$ mesh.chunk().getMinBlockZ() - camera.z), new Matrix4f()); + //#endif + } + GpuBufferSlice[] transformUniforms = RenderSystem.getDynamicUniforms().writeTransforms(transforms); + //#endif + // 获取顺序索引缓冲区,供没有独立索引缓冲的网格共享使用 + RenderSystem.AutoStorageIndexBuffer sequential = RenderSystem.getSequentialBuffer( + //#if MC >= 26.2 + //$$ PrimitiveTopology.QUADS + //#else + VertexFormat.Mode.QUADS + //#endif + ); + // 找出所有网格中最大的顺序索引需求,确保共享缓冲区足够大 + int maxSequentialIndices = 0; + for (ChunkMesh chunk : orderedMeshes) { + for (SectionBuffers mesh : chunk.buffers().values()) { + if (mesh.indexBuffer() == null) { + maxSequentialIndices = Math.max(maxSequentialIndices, mesh.indexCount()); + } + } + } + GpuBuffer sharedIndex = maxSequentialIndices == 0 ? null : sequential.getBuffer(maxSequentialIndices); + IndexType sharedIndexType = maxSequentialIndices == 0 ? null : sequential.type(); + // 先画不透明层(reverse=false),再画半透明层(reverse=true,从远到近保证混合正确) + //#if MC >= 1.21.11 + //$$ this.renderLayerGroup(orderedMeshes, sectionUniforms, atlas, sharedIndex, sharedIndexType, + //$$ ChunkSectionLayerGroup.OPAQUE, false); + //$$ this.renderLayerGroup(orderedMeshes, sectionUniforms, atlas, sharedIndex, sharedIndexType, + //$$ ChunkSectionLayerGroup.TRANSLUCENT, true); + //#else + this.renderLayerGroup(orderedMeshes, transformUniforms, atlas, sharedIndex, sharedIndexType, + ChunkSectionLayerGroup.OPAQUE, false); + this.renderLayerGroup(orderedMeshes, transformUniforms, atlas, sharedIndex, sharedIndexType, + ChunkSectionLayerGroup.TRANSLUCENT, true); + //#endif + } + + // 为指定的渲染层组(不透明或半透明)创建 RenderPass 并绘制所有网格 + // 每个 ChunkSectionLayer 对应一种渲染管线(如 solid、cutout、translucent) + // reverse 参数控制绘制顺序:半透明层需要从远到近绘制以保证混合正确 + private void renderLayerGroup(List orderedMeshes, GpuBufferSlice[] meshUniforms, + GpuTextureView atlas, GpuBuffer sharedIndex, IndexType sharedIndexType, + ChunkSectionLayerGroup group, boolean reverse) { + try (RenderPass pass = RenderSystem.getDevice().createCommandEncoder().createRenderPass( + () -> "ListMore schematic preview " + group.label(), this.target.getColorTextureView(), + //#if MC >= 26.2 + //$$ Optional.empty(), + //#else + OptionalInt.empty(), + //#endif + this.target.getDepthTextureView(), OptionalDouble.empty())) { + RenderSystem.bindDefaultUniforms(pass); + // 绑定光照纹理(Sampler2),所有渲染管线都需要 + //#if MC >= 26.1 + //$$ pass.bindTexture("Sampler2", Minecraft.getInstance().gameRenderer.lightmap(), this.sampler); + //#elseif MC >= 1.21.11 + //$$ pass.bindTexture("Sampler2", Minecraft.getInstance().gameRenderer.lightTexture().getTextureView(), this.sampler); + //#else + pass.bindSampler("Sampler2", Minecraft.getInstance().gameRenderer.lightTexture().getTextureView()); + //#endif + for (ChunkSectionLayer layer : group.layers()) { + pass.setPipeline(layer.pipeline()); + // 绑定方块纹理图集(Sampler0) + //#if MC >= 26.1 + //$$ pass.bindTexture("Sampler0", atlas, this.sampler); + //#elseif MC >= 1.21.11 + //$$ pass.bindTexture("Sampler0", atlas, this.sampler); + //#else + pass.bindSampler("Sampler0", atlas); + //#endif + List> draws = new ArrayList<>(); + for (int i = 0; i < orderedMeshes.size(); i++) { + SectionBuffers mesh = orderedMeshes.get(i).buffers().get(layer); + if (mesh == null || mesh.indexCount() <= 0 || mesh.vertexBuffer().isClosed()) continue; + // 捕获 i 到局部变量,lambda 内不能直接引用循环变量 + int uniformIndex = i; + //#if MC >= 26.2 + //$$ draws.add(new RenderPass.Draw<>(0, mesh.vertexBuffer(), mesh.indexBuffer(), + //$$ mesh.indexBuffer() == null ? null : mesh.indexType(), 0, mesh.indexCount(), 0, + //$$ (slices, uploader) -> uploader.upload("ChunkSection", slices[uniformIndex]))); + //#elseif MC >= 26.1 + //$$ draws.add(new RenderPass.Draw<>(0, mesh.vertexBuffer(), mesh.indexBuffer(), + //$$ mesh.indexBuffer() == null ? null : mesh.indexType(), 0, mesh.indexCount(), 0, + //$$ (slices, uploader) -> uploader.upload("ChunkSection", slices[uniformIndex]))); + //#elseif MC >= 1.21.11 + //$$ draws.add(new RenderPass.Draw<>(0, mesh.vertexBuffer(), mesh.indexBuffer(), + //$$ mesh.indexBuffer() == null ? null : mesh.indexType(), 0, mesh.indexCount(), + //$$ (slices, uploader) -> uploader.upload("ChunkSection", slices[uniformIndex]))); + //#else + draws.add(new RenderPass.Draw<>(0, mesh.vertexBuffer(), mesh.indexBuffer(), + mesh.indexBuffer() == null ? null : mesh.indexType(), 0, mesh.indexCount(), + (slices, uploader) -> uploader.upload("DynamicTransforms", slices[uniformIndex]))); + //#endif + } + if (!draws.isEmpty()) { + // 半透明层需要反转绘制顺序(远 -> 近),不透明层保持原序(近 -> 远,利用深度测试) + if (reverse) draws = draws.reversed(); + //#if MC >= 1.21.11 + //$$ pass.drawMultipleIndexed(draws, sharedIndex, sharedIndexType, List.of("ChunkSection"), meshUniforms); + //#else + pass.drawMultipleIndexed(draws, sharedIndex, sharedIndexType, List.of("DynamicTransforms"), meshUniforms); + //#endif + } + } + } + } + + private void closeMeshes() { + this.meshes.forEach(ChunkMesh::close); + this.meshes.clear(); + } + + // 快照变化时重建 GPU 缓冲区 + private record SectionBuffers(GpuBuffer vertexBuffer, GpuBuffer indexBuffer, int indexCount, + IndexType indexType) implements AutoCloseable { + @Override + public void close() { + this.vertexBuffer.close(); + if (this.indexBuffer != null) { + this.indexBuffer.close(); + } + } + } + + // 每个区块的网格构建器,按 ChunkSectionLayer 分组管理 BufferBuilder + private static final class ChunkMeshBuilder implements AutoCloseable { + private final Map allocators = new EnumMap<>(ChunkSectionLayer.class); + private final Map builders = new EnumMap<>(ChunkSectionLayer.class); + + private BufferBuilder builder(ChunkSectionLayer layer) { + return this.builders.computeIfAbsent(layer, ignored -> new BufferBuilder( + this.allocators.computeIfAbsent(layer, unused -> new ByteBufferBuilder(4096)), + //#if MC >= 26.2 + //$$ layer.pipeline().getPrimitiveTopology(), layer.vertexFormat() + //#else + layer.pipeline().getVertexFormatMode(), layer.pipeline().getVertexFormat() + //#endif + )); + } + + // 将 BufferBuilder 中的网格数据上传到 GPU,创建顶点和索引缓冲区 + // 返回包含所有 GPU 缓冲区和原始 MeshData 的 ChunkMesh + private ChunkMesh build(ChunkPos position) { + Map buffers = new EnumMap<>(ChunkSectionLayer.class); + List meshData = new ArrayList<>(); + this.builders.forEach((layer, builder) -> { + // BufferBuilder.build() 将 CPU 端顶点数据打包为 MeshData + MeshData mesh = builder.build(); + if (mesh == null) { + return; + } + meshData.add(mesh); + // 将 MeshData 上传到 GPU,创建顶点和索引缓冲区 + GpuBuffer vertex = RenderSystem.getDevice().createBuffer(() -> "ListMore preview vertex buffer", + GpuBuffer.USAGE_VERTEX | GpuBuffer.USAGE_COPY_DST, mesh.vertexBuffer()); + // 索引缓冲区可能为空(使用共享顺序索引时) + GpuBuffer index = mesh.indexBuffer() == null ? null : RenderSystem.getDevice().createBuffer( + () -> "ListMore preview index buffer", GpuBuffer.USAGE_INDEX | GpuBuffer.USAGE_COPY_DST, + mesh.indexBuffer()); + buffers.put(layer, new SectionBuffers(vertex, index, mesh.drawState().indexCount(), mesh.drawState().indexType())); + }); + if (buffers.isEmpty()) { + // 所有层都为空时释放资源并返回 null + meshData.forEach(MeshData::close); + this.close(); + return null; + } + return new ChunkMesh(position, buffers, meshData, new ArrayList<>(this.allocators.values())); + } + + @Override + public void close() { + this.allocators.values().forEach(ByteBufferBuilder::close); + this.allocators.clear(); + } + } + + // 一个区块位置的完整网格数据,包含各渲染层的 GPU 缓冲区和原始构建数据 + private record ChunkMesh(ChunkPos chunk, Map buffers, + List meshData, List allocators) implements AutoCloseable { + // 计算区块中心到相机的距离平方,用于排序(远到近) + private double distanceTo(Vector3f camera) { + float x = this.chunk.getMinBlockX() + 8.0F; + float z = this.chunk.getMinBlockZ() + 8.0F; + return Mth.square(x - camera.x) + Mth.square(z - camera.z); + } + + @Override + public void close() { + this.allocators.forEach(ByteBufferBuilder::close); + this.meshData.forEach(MeshData::close); + this.buffers.values().forEach(SectionBuffers::close); + } + } + + @Override + public void close() { + this.closeMeshes(); + this.builtRevision = Long.MIN_VALUE; + //#if MC >= 26.1 + //$$ this.schematic = null; + //#endif + this.world = null; + if (this.target != null) { this.target.destroyBuffers(); this.target = null; } + //#if MC >= 1.21.11 + //$$ if (this.sampler != null) { this.sampler.close(); this.sampler = null; } + //#endif + if (this.projection != null) { this.projection.close(); this.projection = null; } + if (this.globalUniform != null) { this.globalUniform.close(); this.globalUniform = null; } + } +} diff --git a/src/main/java/com/listmore/schematic/SchematicPreviewSession.java b/src/main/java/com/listmore/schematic/SchematicPreviewSession.java new file mode 100644 index 0000000..435c1e3 --- /dev/null +++ b/src/main/java/com/listmore/schematic/SchematicPreviewSession.java @@ -0,0 +1,117 @@ +package com.listmore.schematic; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicLong; + +import fi.dy.masa.litematica.schematic.LitematicaSchematic; +import net.minecraft.core.Vec3i; + +// 单个原理图浏览器的预览状态 +public final class SchematicPreviewSession { + private final AtomicLong generation = new AtomicLong(); + private final SchematicPreviewTransform transform = new SchematicPreviewTransform(); + private final SchematicPreviewRenderManager renderer = new SchematicPreviewRenderManager(); + private Path file; + private CompletableFuture loadingTask; + private SchematicPreviewModel model; + private Throwable loadFailure; + // 后台加载线程写入,GUI绘制线程读取 + private volatile LoadResult pendingResult; + private boolean closed; + + public SchematicPreviewTransform transform() { + return this.transform; + } + + // 返回当前浏览器专属的预览渲染器 + public SchematicPreviewRenderManager renderer() { + return this.renderer; + } + + public SchematicPreviewModel model() { + return this.model; + } + + public Vec3i size() { + return this.model != null ? this.model.size() : null; + } + + public boolean isLoading() { + return this.loadingTask != null && !this.loadingTask.isDone(); + } + + public boolean hasFailure() { + return this.loadFailure != null; + } + + // 仅在列表中选中新的 litematic 文件时启动新的加载任务 + // 流程:路径规范化 -> 去重检查 -> 重置旧状态 -> 启动异步加载 -> 结果写入 pendingResult + // generation 防止旧请求的结果覆盖新请求 + public void setFile(Path file) { + Path normalizedFile = file.toAbsolutePath().normalize(); + if (this.closed || normalizedFile.equals(this.file)) { + return; + } + + // 重置所有状态,准备新文件加载 + this.file = normalizedFile; + this.model = null; + this.renderer.close(); + this.loadFailure = null; + this.pendingResult = null; + this.transform.reset(); + // 递增 generation 使旧请求的回调失效 + long requestGeneration = this.generation.incrementAndGet(); + CompletableFuture task = SchematicPreviewLoader.load(normalizedFile); + this.loadingTask = task; + task.whenComplete((loaded, throwable) -> { + // 检查:未关闭 + generation 匹配 + 未被新任务替换 + if (this.closed || requestGeneration != this.generation.get() || task != this.loadingTask) { + return; + } + this.pendingResult = new LoadResult(requestGeneration, loaded, throwable); + }); + } + + // 在 GUI 绘制线程调用,消费后台加载线程写入的 pendingResult + // 检查 generation 确保只处理最新请求的结果,忽略已过期的加载任务 + // 加载成功后从 LitematicaSchematic 提取 SchematicPreviewModel 并提交给渲染器 + public void update() { + LoadResult result = this.pendingResult; + if (result == null || result.generation() != this.generation.get()) { + return; + } + + this.pendingResult = null; + if (result.throwable() != null) { + this.loadFailure = result.throwable(); + return; + } + if (result.schematic() == null) { + this.loadFailure = new IllegalStateException("Litematica returned no schematic"); + return; + } + + try { + // 加载成功 -> 提取模型并提交给渲染器 + this.model = SchematicPreviewModel.from(result.schematic()); + this.renderer.setSchematic(result.schematic()); + this.renderer.setModel(this.model); + } catch (Throwable throwable) { + this.loadFailure = throwable; + } + } + + public void close() { + this.closed = true; + this.generation.incrementAndGet(); + this.loadingTask = null; + this.pendingResult = null; + this.model = null; + this.renderer.close(); + } + + private record LoadResult(long generation, LitematicaSchematic schematic, Throwable throwable) { + } +} diff --git a/src/main/java/com/listmore/schematic/SchematicPreviewTransform.java b/src/main/java/com/listmore/schematic/SchematicPreviewTransform.java new file mode 100644 index 0000000..77178b4 --- /dev/null +++ b/src/main/java/com/listmore/schematic/SchematicPreviewTransform.java @@ -0,0 +1,72 @@ +package com.listmore.schematic; + +// 固定预览方向的相机状态 +public final class SchematicPreviewTransform { + private static final float DEFAULT_YAW = 45.0F; + private static final float DEFAULT_PITCH = -25.0F; + private static final float ROTATION_STEP = 45.0F; + private static final float MAX_PITCH = 89.0F; + private static final float ZOOM_STEP = 1.25F; + private static final float MIN_ZOOM = 0.5F; + private static final float MAX_ZOOM = 2.0F; + + private float yaw = DEFAULT_YAW; + private float pitch = DEFAULT_PITCH; + private float zoom = 1.0F; + + public float yaw() { + return this.yaw; + } + + public float pitch() { + return this.pitch; + } + + // 根据模型包围盒尺寸计算相机距离,半径取包围盒对角线的一半,乘以 2.4 倍再除以缩放因子 + // 最小距离 4.0 防止模型过小时相机穿透 + public float distance(float sizeX, float sizeY, float sizeZ) { + float radius = (float) Math.sqrt(sizeX * sizeX + sizeY * sizeY + sizeZ * sizeZ) * 0.5F; + return Math.max(4.0F, radius * 2.4F) / this.zoom; + } + + public void reset() { + this.yaw = DEFAULT_YAW; + this.pitch = DEFAULT_PITCH; + this.zoom = 1.0F; + } + + public void zoomIn() { + this.zoom = Math.min(MAX_ZOOM, this.zoom * ZOOM_STEP); + } + + public void zoomOut() { + this.zoom = Math.max(MIN_ZOOM, this.zoom / ZOOM_STEP); + } + + // 将 UI 方向按钮映射为相机旋转,每次旋转 45° + // UP/DOWN 改变俯仰角,LEFT/RIGHT 改变偏航角,RESET 恢复默认视角 + public void applyDirection(SchematicPreviewDirection direction) { + if (direction == SchematicPreviewDirection.RESET) { + this.reset(); + return; + } + + switch (direction) { + case UP -> this.rotate(0.0F, -ROTATION_STEP); + case LEFT -> this.rotate(ROTATION_STEP, 0.0F); + case RIGHT -> this.rotate(-ROTATION_STEP, 0.0F); + case DOWN -> this.rotate(0.0F, ROTATION_STEP); + case RESET -> { } + } + } + //TODO:话说如果未来要换成鼠标拖曳的话还是四元数好点 + + // 偏航角累加后归一化到 [0, 360),俯仰角限制在 [-89°, 89°] 防止万向节锁 + private void rotate(float yawDelta, float pitchDelta) { + this.yaw = (this.yaw + yawDelta) % 360.0F; + if (this.yaw < 0.0F) { + this.yaw += 360.0F; + } + this.pitch = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, this.pitch + pitchDelta)); + } +} diff --git a/src/main/java/com/listmore/schematic/SchematicPreviewWorld.java b/src/main/java/com/listmore/schematic/SchematicPreviewWorld.java new file mode 100644 index 0000000..a27461d --- /dev/null +++ b/src/main/java/com/listmore/schematic/SchematicPreviewWorld.java @@ -0,0 +1,90 @@ +package com.listmore.schematic; + +import fi.dy.masa.litematica.world.FakeLightingProvider; + +//#if MC >= 26.1 +//$$ import net.minecraft.client.renderer.block.BlockAndTintGetter; +//#else +import net.minecraft.world.level.BlockAndTintGetter; +//#endif +import net.minecraft.client.Minecraft; +import net.minecraft.core.BlockPos; +//#if MC >= 26.1 +//$$ import net.minecraft.world.level.CardinalLighting; +//#else +import net.minecraft.core.Direction; +//#endif +import net.minecraft.world.level.ColorResolver; +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.lighting.LevelLightEngine; +import net.minecraft.world.level.material.FluidState; +import org.jetbrains.annotations.Nullable; + +// 构建预览方块网格时使用的只读世界视图 +final class SchematicPreviewWorld implements BlockAndTintGetter { + private final Minecraft minecraft; + private final LevelLightEngine lightEngine; + private SchematicPreviewModel model; + + SchematicPreviewWorld(Minecraft minecraft) { + this.minecraft = minecraft; + this.lightEngine = new FakeLightingProvider(minecraft.level.getChunkSource()); + } + + public void setModel(SchematicPreviewModel model) { + this.model = model; + } + + @Override + public BlockState getBlockState(BlockPos pos) { + if (this.model == null) { + return Blocks.AIR.defaultBlockState(); + } + return this.model.blockStateAt(pos.getX(), pos.getY(), pos.getZ()); + } + + @Override + public FluidState getFluidState(BlockPos pos) { + return this.getBlockState(pos).getFluidState(); + } + + @Nullable + @Override + public BlockEntity getBlockEntity(BlockPos pos) { + return null; + } + + @Override + public int getHeight() { + return this.model != null ? Math.max(1, this.model.size().getY()) : 1; + } + + @Override + public int getMinY() { + return 0; + } + + @Override + public LevelLightEngine getLightEngine() { + return this.lightEngine; + } + + //#if MC >= 26.1 + //$$ @Override + //$$ public CardinalLighting cardinalLighting() { + //$$ return CardinalLighting.DEFAULT; + //$$ } + //#else + @Override + public float getShade(Direction direction, boolean shaded) { + return this.minecraft.level.getShade(direction, shaded); + } + //#endif + + @Override + public int getBlockTint(BlockPos pos, ColorResolver resolver) { + return resolver.getColor(this.minecraft.level.getBiome(pos).value(), pos.getX(), pos.getZ()); + } +} diff --git a/src/main/resources/assets/listmore/lang/en_us.json b/src/main/resources/assets/listmore/lang/en_us.json index bad3f23..c865974 100644 --- a/src/main/resources/assets/listmore/lang/en_us.json +++ b/src/main/resources/assets/listmore/lang/en_us.json @@ -4,6 +4,7 @@ "listmore.hotkeys.category.generic": "General Hotkeys", "listmore.gui.tab.all": "All", "listmore.gui.tab.generic": "General", + "listmore.gui.tab.litematica": "Litematica", "listmore.config.generic.name.projectileLandingPrediction": "Projectile Landing Prediction", "listmore.config.generic.prettyName.projectileLandingPrediction": "Projectile Landing Prediction", "listmore.config.generic.comment.projectileLandingPrediction": "Show the predicted landing point for supported projectiles", @@ -41,5 +42,13 @@ "listmore.config.generic.prettyName.tntExplosionPreviewMode": "TNT Explosion Preview - Display Mode", "listmore.config.generic.comment.tntExplosionPreviewMode": "Choose whether to display possible destruction blocks", "listmore.label.tntExplosionPreviewMode.full": "Full Preview", - "listmore.label.tntExplosionPreviewMode.partial": "Partial Preview" + "listmore.label.tntExplosionPreviewMode.partial": "Partial Preview", + "listmore.config.generic.name.schematicDynamicPreview": "Schematic Dynamic Preview", + "listmore.config.generic.prettyName.schematicDynamicPreview": "Schematic Dynamic Preview", + "listmore.config.generic.comment.schematicDynamicPreview": "Replace Litematica's static schematic thumbnail with a real-time 3D preview", + "listmore.schematic_preview.loading": "Loading preview...", + "listmore.schematic_preview.failed": "Preview load failed", + "listmore.schematic_preview.empty": "Select a litematic file", + "listmore.schematic_preview.building": "Building model...", + "listmore.schematic_preview.blocks": "%d blocks" } diff --git a/src/main/resources/assets/listmore/lang/zh_cn.json b/src/main/resources/assets/listmore/lang/zh_cn.json index a4aa27e..7331499 100644 --- a/src/main/resources/assets/listmore/lang/zh_cn.json +++ b/src/main/resources/assets/listmore/lang/zh_cn.json @@ -4,6 +4,7 @@ "listmore.hotkeys.category.generic": "通用快捷键", "listmore.gui.tab.all": "全部", "listmore.gui.tab.generic": "通用", + "listmore.gui.tab.litematica": "Litematica", "listmore.config.generic.name.projectileLandingPrediction": "投射物落点预测", "listmore.config.generic.prettyName.projectileLandingPrediction": "投射物落点预测", "listmore.config.generic.comment.projectileLandingPrediction": "显示支持投射物的预测落点", @@ -41,5 +42,13 @@ "listmore.config.generic.prettyName.tntExplosionPreviewMode": "TNT爆炸预览-显示模式", "listmore.config.generic.comment.tntExplosionPreviewMode": "选择是否显示可能被炸坏的方块", "listmore.label.tntExplosionPreviewMode.full": "全预览", - "listmore.label.tntExplosionPreviewMode.partial": "半预览" + "listmore.label.tntExplosionPreviewMode.partial": "半预览", + "listmore.config.generic.name.schematicDynamicPreview": "投影实时预览", + "listmore.config.generic.prettyName.schematicDynamicPreview": "投影实时预览", + "listmore.config.generic.comment.schematicDynamicPreview": "将 Litematica 的静态投影缩略图替换为实时 3D 预览", + "listmore.schematic_preview.loading": "正在加载预览...", + "listmore.schematic_preview.failed": "预览加载失败", + "listmore.schematic_preview.empty": "请选择一个 litematic 文件", + "listmore.schematic_preview.building": "正在构建模型...", + "listmore.schematic_preview.blocks": "%d 个方块" } diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index ace3e13..41fc1eb 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -33,6 +33,7 @@ "malilib": "*" }, "custom": { + "conditional-mixin": ">=0.2.0", "modmenu:clientsideOnly": true } } diff --git a/src/main/resources/listmore.mixins.json b/src/main/resources/listmore.mixins.json index 0791700..9378b0e 100644 --- a/src/main/resources/listmore.mixins.json +++ b/src/main/resources/listmore.mixins.json @@ -5,7 +5,8 @@ "client": [ "MinecraftMixin", "EntityMixin", - "EntityRenderDispatcherMixin" + "EntityRenderDispatcherMixin", + "litematica.GuiSchematicBrowserBaseMixin" ], "mixins": [ "WidgetConfigOptionMixin", diff --git a/versions/1.21.10-fabric/gradle.properties b/versions/1.21.10-fabric/gradle.properties index b6dd07b..5524ded 100644 --- a/versions/1.21.10-fabric/gradle.properties +++ b/versions/1.21.10-fabric/gradle.properties @@ -7,3 +7,4 @@ fabric_api_version=0.138.4+1.21.10 dependencies.api.malilib_version=com.github.sakura-ryoko:malilib:1.21.10-0.26.6 dependencies.api.malilib_version.use_sakura_fork=true dependencies.api.modmenu_version=17.0.0 +litematica_version=fi.dy.masa.litematica:litematica-fabric-1.21.10:0.24.5 diff --git a/versions/1.21.11-fabric/gradle.properties b/versions/1.21.11-fabric/gradle.properties index 4f9452a..28a2f47 100644 --- a/versions/1.21.11-fabric/gradle.properties +++ b/versions/1.21.11-fabric/gradle.properties @@ -7,3 +7,4 @@ fabric_api_version=0.141.4+1.21.11 dependencies.api.malilib_version=fi.dy.masa.malilib:malilib-fabric-1.21.11:0.27.10 dependencies.api.malilib_version.use_sakura_fork=false dependencies.api.modmenu_version=17.0.0-beta.2 +litematica_version=fi.dy.masa.litematica:litematica-fabric-1.21.11:0.26.12 diff --git a/versions/1.21.8-fabric/build.gradle b/versions/1.21.8-fabric/build.gradle index 3609c99..a0d3cc3 100644 --- a/versions/1.21.8-fabric/build.gradle +++ b/versions/1.21.8-fabric/build.gradle @@ -61,6 +61,7 @@ repositories { dependencies { def autoImplementation = { Object... args -> unobfuscated ? implementation(*args) : modImplementation(*args) } + def autoCompileOnly = { Object... args -> unobfuscated ? compileOnly(*args) : modCompileOnly(*args) } def useSakuraMalilib = project.findProperty('dependencies.api.malilib_version.use_sakura_fork')?.toString()?.toBoolean() ?: false def malilibFabricGa = useSakuraMalilib ? 'com.github.sakura-ryoko:malilib' : 'maven.modrinth:malilib' def malilibVersion = project.findProperty('dependencies.api.malilib_version') @@ -75,6 +76,11 @@ dependencies { autoImplementation "net.fabricmc:fabric-loader:${project.loader_version}" autoImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" autoImplementation malilibCoordinate + autoCompileOnly(project.litematica_version) { + // Litematica 的旧 POM 会传递一个不可用的 malilib 版本,使用项目已配置的版本。 + exclude group: 'com.github.sakura-ryoko', module: 'malilib' + } + autoCompileOnly "me.fallenbreath:conditional-mixin-fabric:${project.conditionalmixin_version}" def modMenuVersion = project.findProperty('dependencies.api.modmenu_version') if (modMenuVersion != null) { diff --git a/versions/1.21.8-fabric/gradle.properties b/versions/1.21.8-fabric/gradle.properties index b477043..cf9a9bc 100644 --- a/versions/1.21.8-fabric/gradle.properties +++ b/versions/1.21.8-fabric/gradle.properties @@ -7,6 +7,7 @@ fabric_api_version=0.136.1+1.21.8 dependencies.api.malilib_version=com.github.sakura-ryoko:malilib:1.21.8-0.25.7 dependencies.api.malilib_version.use_sakura_fork=true dependencies.api.modmenu_version=15.0.0 +litematica_version=fi.dy.masa.litematica:litematica-fabric-1.21.8:0.23.5 # https://github.com/sakura-ryoko/malilib/releases diff --git a/versions/26.1-fabric/gradle.properties b/versions/26.1-fabric/gradle.properties index 9739aa8..f01b2bf 100644 --- a/versions/26.1-fabric/gradle.properties +++ b/versions/26.1-fabric/gradle.properties @@ -7,3 +7,4 @@ fabric_api_version=0.148.2+26.1.2 dependencies.api.malilib_version=fi.dy.masa.malilib:malilib-fabric-26.1.2:0.28.4 dependencies.api.malilib_version.use_sakura_fork=false dependencies.api.modmenu_version=20.0.0-beta.4 +litematica_version=fi.dy.masa.litematica:litematica-fabric-26.1.2:0.27.4 diff --git a/versions/26.2-fabric/gradle.properties b/versions/26.2-fabric/gradle.properties index 917e6d7..f4a2e8a 100644 --- a/versions/26.2-fabric/gradle.properties +++ b/versions/26.2-fabric/gradle.properties @@ -7,3 +7,4 @@ fabric_api_version=0.154.2+26.2 dependencies.api.malilib_version=fi.dy.masa.malilib:malilib-fabric-26.2:0.29.0 dependencies.api.malilib_version.use_sakura_fork=false dependencies.api.modmenu_version=20.0.0-beta.4 +litematica_version=fi.dy.masa.litematica:litematica-fabric-26.2:0.28.0