mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-25 21:40:05 +00:00
Port the v3 map as iris std map
- iris std map opens the v3 map window; noise, palettes, objects, procedural and image maps are views in it - Rivers view draws v4 hydrology, with an empty state when the pack has rivers off - 3D views fall back to software when LWJGL isn't on the classpath; modded jars use the game's LWJGL - FlatLaf and LWJGL join core; v4's separate studio windows are removed
This commit is contained in:
1 parent
2d211cf106
commit
29cca99947
214 files changed
+27582
-4368
No files matched your search
@@ -60,6 +60,7 @@ import art.arcane.iris.integration.MultiverseCoreLink;
|
||||
import art.arcane.iris.platform.bukkit.nms.INMS;
|
||||
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
|
||||
import art.arcane.iris.studio.view.BukkitGuiHost;
|
||||
import art.arcane.iris.studio.view.GuiHost;
|
||||
import art.arcane.iris.studio.view.PregeneratorJob;
|
||||
import art.arcane.iris.platform.bukkit.BoardSVC;
|
||||
import art.arcane.iris.command.CommandSVC;
|
||||
@@ -988,6 +989,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
public void onDisable() {
|
||||
GuiHost.shutdown();
|
||||
preservePendingBlockDrops();
|
||||
papiLifecycleActive = false;
|
||||
if (updateNotifier != null) {
|
||||
|
||||
@@ -21,10 +21,10 @@ package art.arcane.iris.command;
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.studio.map.MapWindow;
|
||||
import art.arcane.iris.studio.map.NoiseExplorerGUI;
|
||||
import art.arcane.iris.studio.map.PaletteExplorerStart;
|
||||
import art.arcane.iris.studio.view.GuiHost;
|
||||
import art.arcane.iris.studio.view.ImageMapStudioGUI;
|
||||
import art.arcane.iris.studio.view.NoiseExplorerGUI;
|
||||
import art.arcane.iris.studio.view.VisionGUI;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.studio.workspace.IrisProject;
|
||||
import art.arcane.iris.studio.workspace.IrisCodeWorkspace;
|
||||
@@ -255,15 +255,19 @@ public class CommandStudio implements DirectorExecutor {
|
||||
long seed
|
||||
) {
|
||||
if (noGUI()) return;
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
|
||||
|
||||
if (generator == null) {
|
||||
NoiseExplorerGUI.launch();
|
||||
Engine playerEngine = engine();
|
||||
Engine engine = playerEngine != null ? playerEngine : GuiHost.get().findActiveEngine();
|
||||
if (generator != null) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
|
||||
NoiseExplorerGUI.launchGeneratorKey(engine, generator.getLoadKey(), generator, seed);
|
||||
return;
|
||||
}
|
||||
|
||||
String generatorKey = generator.getLoadKey();
|
||||
NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed);
|
||||
if (engine == null) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD));
|
||||
return;
|
||||
}
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
|
||||
MapWindow.openNoise(playerEngine != null ? mapStartIn(player().getWorld()) : mapStartWithoutWorld(engine));
|
||||
}
|
||||
|
||||
@Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"})
|
||||
@@ -279,8 +283,7 @@ public class CommandStudio implements DirectorExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
Engine activeEngine = IrisToolbelt.access(world).getEngine();
|
||||
ImageMapStudioGUI.launch(activeEngine);
|
||||
MapWindow.openImageMaps(mapStartIn(world));
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO));
|
||||
}
|
||||
|
||||
@@ -420,11 +423,27 @@ public class CommandStudio implements DirectorExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
|
||||
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId);
|
||||
MapWindow.openMap(mapStartIn(world));
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
|
||||
}
|
||||
|
||||
// The map opens where the player stands when they are in that world, otherwise at the world origin.
|
||||
private PaletteExplorerStart mapStartIn(World world) {
|
||||
Location center = sender().isPlayer() && player().getWorld().equals(world) ? player().getLocation() : null;
|
||||
return new PaletteExplorerStart(
|
||||
IrisToolbelt.access(world).getEngine(),
|
||||
world.getUID(),
|
||||
sender().isPlayer() ? player().getUniqueId() : null,
|
||||
null,
|
||||
center == null ? 0D : center.getX(),
|
||||
center == null ? 0D : center.getZ()
|
||||
);
|
||||
}
|
||||
|
||||
private PaletteExplorerStart mapStartWithoutWorld(Engine engine) {
|
||||
return new PaletteExplorerStart(engine, null, sender().isPlayer() ? player().getUniqueId() : null, null, 0D, 0D);
|
||||
}
|
||||
|
||||
@Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER)
|
||||
public void profile(
|
||||
@Param(description = "The dimension to profile", descriptionKey = "iris.director.commandstudio.param.dimension_profile", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
|
||||
|
||||
+42
-6
@@ -23,6 +23,8 @@ import art.arcane.iris.world.event.IrisEngineHotloadEvent;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
|
||||
import art.arcane.iris.studio.StudioSVC;
|
||||
import art.arcane.iris.studio.workspace.IrisProject;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.event.EventHandler;
|
||||
@@ -40,16 +42,21 @@ public final class BukkitGuiHost implements GuiHost.Provider {
|
||||
GuiHost.set(new BukkitGuiHost());
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine of the open studio project, which reads the pack folder being edited, or else the first
|
||||
* Iris world. A normal Iris world runs on its own copy of the pack, made when the world was created.
|
||||
*/
|
||||
@Override
|
||||
public Engine findActiveEngine() {
|
||||
Engine studioEngine = findStudioEngine();
|
||||
if (studioEngine != null) {
|
||||
return studioEngine;
|
||||
}
|
||||
try {
|
||||
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
|
||||
try {
|
||||
PlatformChunkGenerator access = IrisToolbelt.access(world);
|
||||
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
|
||||
return access.getEngine();
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
Engine engine = engineOf(world);
|
||||
if (engine != null) {
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
@@ -57,6 +64,35 @@ public final class BukkitGuiHost implements GuiHost.Provider {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Engine findEngine(UUID worldId) {
|
||||
World world = worldId == null ? null : Bukkit.getWorld(worldId);
|
||||
return world == null ? null : engineOf(world);
|
||||
}
|
||||
|
||||
private static Engine findStudioEngine() {
|
||||
try {
|
||||
StudioSVC studio = Iris.service(StudioSVC.class);
|
||||
IrisProject project = studio == null || !studio.isProjectOpen() ? null : studio.getActiveProject();
|
||||
PlatformChunkGenerator provider = project == null ? null : project.getActiveProvider();
|
||||
Engine engine = provider == null ? null : provider.getEngine();
|
||||
return engine == null || engine.isClosed() ? null : engine;
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Engine engineOf(World world) {
|
||||
try {
|
||||
PlatformChunkGenerator access = IrisToolbelt.access(world);
|
||||
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
|
||||
return access.getEngine();
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerHotloadHook(Runnable onHotload) {
|
||||
Listener listener = new HotloadListener(onHotload);
|
||||
|
||||
+41
-30
@@ -18,23 +18,19 @@
|
||||
|
||||
package art.arcane.iris.studio.view;
|
||||
|
||||
import art.arcane.volmlib.nativelib.view.WorldMarker;
|
||||
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.studio.render.RenderType;
|
||||
import art.arcane.iris.world.IrisWorld;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.volmlib.util.format.Form;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -56,7 +52,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
|
||||
private final AtomicLong teleportSequence = new AtomicLong();
|
||||
private final AtomicReference<VisionTeleportRequest> latestTeleport = new AtomicReference<>();
|
||||
private volatile List<WorldMarker> playerMarkers = List.of();
|
||||
private volatile List<GuiMarker> playerMarkers = List.of();
|
||||
|
||||
public BukkitVisionOverlay(Engine engine, UUID openerId) {
|
||||
this.engine = engine;
|
||||
@@ -68,7 +64,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
* built by a server thread.
|
||||
*/
|
||||
@Override
|
||||
public List<WorldMarker> players() {
|
||||
public List<GuiMarker> players() {
|
||||
queuePlayerRefresh();
|
||||
return playerMarkers;
|
||||
}
|
||||
@@ -80,10 +76,10 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
|
||||
boolean scheduled = J.runGlobal(() -> {
|
||||
try {
|
||||
List<WorldMarker> markers = new ArrayList<>();
|
||||
List<GuiMarker> markers = new ArrayList<>();
|
||||
for (Player player : BukkitWorldBinding.players(engine.getWorld())) {
|
||||
Location at = player.getLocation();
|
||||
markers.add(WorldMarker.player(player.getName(), at.getX(), at.getZ()));
|
||||
markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ(), at.getYaw()));
|
||||
}
|
||||
playerMarkers = List.copyOf(markers);
|
||||
} finally {
|
||||
@@ -97,7 +93,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestEntities(Consumer<List<WorldMarker>> sink) {
|
||||
public void requestEntities(Consumer<List<GuiMarker>> sink) {
|
||||
J.runGlobal(() -> {
|
||||
IrisWorld target = engine.getWorld();
|
||||
World world = BukkitWorldBinding.world(target);
|
||||
@@ -118,7 +114,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
return;
|
||||
}
|
||||
|
||||
List<WorldMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
|
||||
List<GuiMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
|
||||
AtomicInteger pending = new AtomicInteger(living.size());
|
||||
Runnable complete = () -> {
|
||||
if (pending.decrementAndGet() == 0) {
|
||||
@@ -144,22 +140,22 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
});
|
||||
}
|
||||
|
||||
private WorldMarker marker(LivingEntity entity, Location at) {
|
||||
private GuiMarker marker(LivingEntity entity, Location at) {
|
||||
String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " "));
|
||||
double maxHealth = 0;
|
||||
try {
|
||||
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return WorldMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
|
||||
return GuiMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void teleport(double worldX, double worldZ) {
|
||||
VisionTeleportRequest request = new VisionTeleportRequest(
|
||||
teleportSequence.incrementAndGet(),
|
||||
VisionGUI.floorWorldCoordinate(worldX),
|
||||
VisionGUI.floorWorldCoordinate(worldZ));
|
||||
(int) StrictMath.floor(worldX),
|
||||
(int) StrictMath.floor(worldZ));
|
||||
latestTeleport.set(request);
|
||||
startTeleport(request);
|
||||
}
|
||||
@@ -212,6 +208,36 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
|
||||
if (openerId == null) {
|
||||
sink.accept(OpenerPosition.OFFLINE);
|
||||
return;
|
||||
}
|
||||
boolean scheduled = J.runGlobal(() -> {
|
||||
Player player = Bukkit.getPlayer(openerId);
|
||||
if (player == null) {
|
||||
sink.accept(OpenerPosition.OFFLINE);
|
||||
return;
|
||||
}
|
||||
World world = BukkitWorldBinding.world(engine.getWorld());
|
||||
if (!J.runEntity(player, () -> sink.accept(positionOf(player, world)))) {
|
||||
sink.accept(OpenerPosition.OFFLINE);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
sink.accept(OpenerPosition.OFFLINE);
|
||||
}
|
||||
}
|
||||
|
||||
private static OpenerPosition positionOf(Player player, World world) {
|
||||
if (world == null || player.getWorld() != world) {
|
||||
return OpenerPosition.IN_OTHER_WORLD;
|
||||
}
|
||||
Location at = player.getLocation();
|
||||
return OpenerPosition.inWorld(at.getX(), at.getY(), at.getZ());
|
||||
}
|
||||
|
||||
private Player selectPlayer(List<Player> players) {
|
||||
if (openerId == null) {
|
||||
return players.isEmpty() ? null : players.get(0);
|
||||
@@ -316,21 +342,6 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
+ blockX + "," + blockZ + ".", failure);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String openInEditor(double worldX, double worldZ, RenderType type) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
File file = switch (type) {
|
||||
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
|
||||
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
|
||||
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
default -> null;
|
||||
};
|
||||
return file == null ? null : file.getName();
|
||||
}
|
||||
|
||||
private static final class VisionTeleportRequest {
|
||||
private final long sequence;
|
||||
private final int blockX;
|
||||
|
||||
@@ -131,6 +131,8 @@ configurations.named('bundle').configure {
|
||||
exclude(group: 'de.crazydev22.slimjar.helper')
|
||||
exclude(group: 'de.crazydev22.slimjar')
|
||||
exclude(group: 'io.github.slimjar')
|
||||
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
|
||||
exclude(group: 'org.lwjgl')
|
||||
}
|
||||
|
||||
configurations.compileClasspath.extendsFrom(configurations.devBundle)
|
||||
@@ -216,7 +218,9 @@ dependencies {
|
||||
libs.caffeine,
|
||||
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
||||
libs.dom4j,
|
||||
libs.jaxen
|
||||
libs.jaxen,
|
||||
libs.flatlaf,
|
||||
libs.flatlaf.extras
|
||||
]
|
||||
shared.each { Object notation ->
|
||||
['bundle', 'devBundle'].each { String configuration ->
|
||||
|
||||
@@ -152,12 +152,16 @@ configurations.named('bundle').configure {
|
||||
exclude(group: 'de.crazydev22.slimjar.helper')
|
||||
exclude(group: 'de.crazydev22.slimjar')
|
||||
exclude(group: 'io.github.slimjar')
|
||||
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
|
||||
exclude(group: 'org.lwjgl')
|
||||
}
|
||||
|
||||
configurations.named('runBundle').configure {
|
||||
exclude(group: 'de.crazydev22.slimjar.helper')
|
||||
exclude(group: 'de.crazydev22.slimjar')
|
||||
exclude(group: 'io.github.slimjar')
|
||||
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
|
||||
exclude(group: 'org.lwjgl')
|
||||
}
|
||||
|
||||
configurations.compileClasspath.extendsFrom(configurations.devBundle)
|
||||
@@ -215,7 +219,9 @@ dependencies {
|
||||
libs.caffeine,
|
||||
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
||||
libs.dom4j,
|
||||
libs.jaxen
|
||||
libs.jaxen,
|
||||
libs.flatlaf,
|
||||
libs.flatlaf.extras
|
||||
]
|
||||
shared.each { Object notation ->
|
||||
['bundle', 'devBundle', 'runBundle'].each { String configuration ->
|
||||
|
||||
@@ -227,6 +227,7 @@ public final class ModdedEngineBootstrap {
|
||||
failure = runStopStage(failure, "wand service", ModdedWandService::clearAll);
|
||||
failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear);
|
||||
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear);
|
||||
failure = runStopStage(failure, "gui windows", GuiHost::shutdown);
|
||||
failure = runStopStage(failure, "gui host", ModdedGuiHost::clear);
|
||||
failure = runStopStage(failure, "services", () -> services().disableAll());
|
||||
reportStopFailure("stopping", failure);
|
||||
|
||||
+15
-9
@@ -23,10 +23,10 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldView;
|
||||
|
||||
import art.arcane.iris.modded.ModdedIrisLog;
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.studio.map.MapWindow;
|
||||
import art.arcane.iris.studio.map.NoiseExplorerGUI;
|
||||
import art.arcane.iris.studio.map.PaletteExplorerStart;
|
||||
import art.arcane.iris.studio.view.GuiHost;
|
||||
import art.arcane.iris.studio.view.ImageMapStudioGUI;
|
||||
import art.arcane.iris.studio.view.NoiseExplorerGUI;
|
||||
import art.arcane.iris.studio.view.VisionGUI;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.pack.StructurePackageClosure;
|
||||
import art.arcane.iris.pack.ImageMapPackageClosure;
|
||||
@@ -247,8 +247,9 @@ public final class ModdedStudioCommands {
|
||||
NativeProtocolPlayer player = source.player();
|
||||
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
|
||||
}
|
||||
if (generatorKey == null || generatorKey.isBlank()) {
|
||||
NoiseExplorerGUI.launch();
|
||||
Engine mapEngine = engine != null ? engine : GuiHost.get().findActiveEngine();
|
||||
if ((generatorKey == null || generatorKey.isBlank()) && mapEngine != null) {
|
||||
MapWindow.openNoise(mapStart(mapEngine, source.player()));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_ON_SERVER_DISPLAY));
|
||||
return 1;
|
||||
}
|
||||
@@ -262,7 +263,7 @@ public final class ModdedStudioCommands {
|
||||
return 0;
|
||||
}
|
||||
String selectedGeneratorKey = generatorKey.trim();
|
||||
NoiseExplorerGUI.launchGeneratorKey(selectedGeneratorKey, generator, seed);
|
||||
NoiseExplorerGUI.launchGeneratorKey(engine, selectedGeneratorKey, generator, seed);
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_GENERATOR_SEED, MessageArgument.untrusted("value", generatorKey.trim()), MessageArgument.untrusted("seed", seed)));
|
||||
return 1;
|
||||
}
|
||||
@@ -280,11 +281,16 @@ public final class ModdedStudioCommands {
|
||||
}
|
||||
NativeProtocolPlayer player = source.player();
|
||||
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
|
||||
VisionGUI.launch(engine, player == null ? null : player.id());
|
||||
MapWindow.openMap(mapStart(engine, player));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_VISION_MAP_ON_SERVER_DISPLAY, MessageArgument.untrusted("value", level.name())));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Modded worlds have no Bukkit world id; the map finds the engine again through the bound GUI context.
|
||||
private static PaletteExplorerStart mapStart(Engine engine, NativeProtocolPlayer player) {
|
||||
return new PaletteExplorerStart(engine, null, player == null ? null : player.id(), null, 0D, 0D);
|
||||
}
|
||||
|
||||
private static int imageMap(NativeCommandSource source) {
|
||||
NativeWorld level = source.world();
|
||||
Engine engine = IrisModdedCommands.engineFor(level);
|
||||
@@ -294,13 +300,13 @@ public final class ModdedStudioCommands {
|
||||
));
|
||||
return 0;
|
||||
}
|
||||
if (!ImageMapStudioGUI.isAvailable()) {
|
||||
if (!GuiHost.isAvailable() || !IrisSettings.get().getGui().isUseServerLaunchedGuis()) {
|
||||
IrisModdedCommands.fail(source, guiUnavailableMessage());
|
||||
return 0;
|
||||
}
|
||||
NativeProtocolPlayer player = source.player();
|
||||
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
|
||||
ImageMapStudioGUI.launch(engine);
|
||||
MapWindow.openImageMaps(mapStart(engine, player));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_IMAGE_MAP_STUDIO_ON_SERVER_DISPLAY,
|
||||
MessageArgument.untrusted("value", level.name())
|
||||
|
||||
+11
-27
@@ -18,17 +18,12 @@
|
||||
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.studio.view.GuiHost;
|
||||
import art.arcane.volmlib.nativelib.view.WorldMarker;
|
||||
import art.arcane.iris.studio.view.GuiMarker;
|
||||
import art.arcane.iris.studio.view.GuiOverlay;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.studio.render.RenderType;
|
||||
import art.arcane.iris.modded.ModdedIrisLog;
|
||||
import art.arcane.iris.modded.ModdedTeleportDeadline;
|
||||
|
||||
import java.awt.Desktop;
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import art.arcane.volmlib.nativelib.view.WorldView;
|
||||
@@ -46,14 +41,20 @@ public final class ModdedVisionOverlay implements GuiOverlay {
|
||||
opener = context.opener();
|
||||
}
|
||||
|
||||
// WorldView doesn't report which way a player faces, so their markers all face south.
|
||||
@Override
|
||||
public List<WorldMarker> players() {
|
||||
return world.players();
|
||||
public List<GuiMarker> players() {
|
||||
return world.players().stream()
|
||||
.map(player -> GuiMarker.player(player.label(), player.worldX(), player.worldZ(), 0F))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestEntities(Consumer<List<WorldMarker>> sink) {
|
||||
world.requestEntities(sink);
|
||||
public void requestEntities(Consumer<List<GuiMarker>> sink) {
|
||||
world.requestEntities(entities -> sink.accept(entities.stream()
|
||||
.map(entity -> GuiMarker.entity(entity.label(), entity.worldX(), entity.worldY(), entity.worldZ(),
|
||||
entity.health(), entity.maxHealth()))
|
||||
.toList()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -80,23 +81,6 @@ public final class ModdedVisionOverlay implements GuiOverlay {
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public String openInEditor(double worldX, double worldZ, RenderType type) {
|
||||
if (!GuiHost.isAvailable() || !Desktop.isDesktopSupported()) {
|
||||
return null;
|
||||
}
|
||||
IrisComplex complex = engine.getComplex();
|
||||
File file = switch (type) {
|
||||
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
|
||||
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
|
||||
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
default -> null;
|
||||
};
|
||||
return file == null ? null : file.getName();
|
||||
}
|
||||
public record Context(WorldView world, Engine engine, UUID opener) {
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,8 @@ configurations.named('bundle').configure {
|
||||
exclude(group: 'de.crazydev22.slimjar.helper')
|
||||
exclude(group: 'de.crazydev22.slimjar')
|
||||
exclude(group: 'io.github.slimjar')
|
||||
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
|
||||
exclude(group: 'org.lwjgl')
|
||||
}
|
||||
|
||||
configurations.compileClasspath.extendsFrom(configurations.devBundle)
|
||||
@@ -173,7 +175,9 @@ dependencies {
|
||||
libs.caffeine,
|
||||
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
||||
libs.dom4j,
|
||||
libs.jaxen
|
||||
libs.jaxen,
|
||||
libs.flatlaf,
|
||||
libs.flatlaf.extras
|
||||
]
|
||||
add('bundle', libs.asm)
|
||||
add('devBundle', 'org.ow2.asm:asm') {
|
||||
|
||||
@@ -136,6 +136,13 @@ dependencies {
|
||||
implementation('com.moandjiezana.toml:toml4j:0.7.2') {
|
||||
transitive = false
|
||||
}
|
||||
// Shaded: the runtime downloader can neither fetch the natives classifiers nor relocate lwjgl's own jar.
|
||||
implementation(libs.lwjgl)
|
||||
implementation(libs.lwjgl.opengl)
|
||||
implementation(libs.lwjgl.egl)
|
||||
implementation(libs.lwjgl.jawt)
|
||||
runtimeOnly(variantOf(libs.lwjgl) { classifier('natives-linux') })
|
||||
runtimeOnly(variantOf(libs.lwjgl.opengl) { classifier('natives-linux') })
|
||||
// Dynamically Loaded
|
||||
slim(libs.gson)
|
||||
slim(libs.lru)
|
||||
@@ -161,6 +168,8 @@ dependencies {
|
||||
slim(libs.byteBuddy.agent)
|
||||
slim(libs.dom4j)
|
||||
slim(libs.jaxen)
|
||||
slim(libs.flatlaf)
|
||||
slim(libs.flatlaf.extras)
|
||||
|
||||
testFixturesImplementation('junit:junit:4.13.2')
|
||||
testFixturesImplementation('org.mockito:mockito-core:5.23.0')
|
||||
|
||||
@@ -373,6 +373,7 @@ public class IrisSettings {
|
||||
public boolean useServerLaunchedGuis = true;
|
||||
public boolean maximumPregenGuiFPS = false;
|
||||
public boolean colorMode = true;
|
||||
public String theme = "auto";
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -580,6 +580,10 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
|
||||
return IrisBiomeLayerGenerator.getLayerSeaHeightGenerators(this, rng, data);
|
||||
}
|
||||
|
||||
public KList<CNG> getCaveCeilingLayerHeightGenerators(RNG rng, IrisData data) {
|
||||
return IrisBiomeLayerGenerator.getCaveCeilingLayerHeightGenerators(this, rng, data);
|
||||
}
|
||||
|
||||
public NativeBlockState getSurfaceBlock(int x, int z, RNG rng, IrisData idm) {
|
||||
return IrisBiomeLayerGenerator.getSurfaceBlock(this, x, z, rng, idm);
|
||||
}
|
||||
|
||||
@@ -148,8 +148,7 @@ final class IrisBiomeLayerGenerator {
|
||||
return data;
|
||||
}
|
||||
|
||||
KList<CNG> heightGenerators = getHeightGenerators(
|
||||
biome.getLayerCeilingHeightGenerators(), ceilingLayers, 7235, random, rdata);
|
||||
KList<CNG> heightGenerators = getCaveCeilingLayerHeightGenerators(biome, random, rdata);
|
||||
|
||||
for (int i = 0; i < layerCount; i++) {
|
||||
IrisBiomePaletteLayer layer = ceilingLayers.get(i);
|
||||
@@ -317,6 +316,10 @@ final class IrisBiomeLayerGenerator {
|
||||
return getHeightGenerators(biome.getLayerSeaHeightGenerators(), biome.getSeaLayers(), 7735, rng, data);
|
||||
}
|
||||
|
||||
static KList<CNG> getCaveCeilingLayerHeightGenerators(IrisBiome biome, RNG rng, IrisData data) {
|
||||
return getHeightGenerators(biome.getLayerCeilingHeightGenerators(), biome.getCaveCeilingLayers(), 7235, rng, data);
|
||||
}
|
||||
|
||||
private static KList<CNG> getHeightGenerators(IrisBiomeLayerHeightCache cache,
|
||||
KList<IrisBiomePaletteLayer> layers,
|
||||
int seedOffset, RNG rng, IrisData data) {
|
||||
|
||||
@@ -1176,33 +1176,118 @@ public class IrisComplex implements DataProvider {
|
||||
InferredType inferredType,
|
||||
IrisBiome emptyBiome
|
||||
) {
|
||||
ProceduralStream<IrisBiome> picked = new RarityStream<>(biomePickNoiseStream(engine.getDimension(), region, inferredType),
|
||||
loadInferredBiomes(biomeKeys(region, inferredType), inferredType));
|
||||
return inferredType == InferredType.CAVE ? picked.onNull(emptyBiome) : picked;
|
||||
}
|
||||
|
||||
private ProceduralStream<Double> biomePickNoiseStream(IrisDimension dimension, IrisRegion region, InferredType inferredType) {
|
||||
return switch (inferredType) {
|
||||
case CAVE -> new RarityStream<>(engine.getDimension().getCaveBiomeStyle()
|
||||
case CAVE -> dimension.getCaveBiomeStyle()
|
||||
.create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
|
||||
.zoom(engine.getDimension().getBiomeZoom())
|
||||
.zoom(region.getCaveBiomeZoom()),
|
||||
loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE))
|
||||
.onNull(emptyBiome);
|
||||
case LAND -> new RarityStream<>(engine.getDimension().getLandBiomeStyle()
|
||||
.zoom(dimension.getBiomeZoom())
|
||||
.zoom(region.getCaveBiomeZoom());
|
||||
case LAND -> dimension.getLandBiomeStyle()
|
||||
.create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream()
|
||||
.zoom(engine.getDimension().getBiomeZoom())
|
||||
.zoom(engine.getDimension().getLandZoom())
|
||||
.zoom(region.getLandBiomeZoom()),
|
||||
loadInferredBiomes(region.getLandBiomes(), InferredType.LAND));
|
||||
case SEA -> new RarityStream<>(engine.getDimension().getSeaBiomeStyle()
|
||||
.zoom(dimension.getBiomeZoom())
|
||||
.zoom(dimension.getLandZoom())
|
||||
.zoom(region.getLandBiomeZoom());
|
||||
case SEA -> dimension.getSeaBiomeStyle()
|
||||
.create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream()
|
||||
.zoom(engine.getDimension().getBiomeZoom())
|
||||
.zoom(engine.getDimension().getSeaZoom())
|
||||
.zoom(region.getSeaBiomeZoom()),
|
||||
loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA));
|
||||
case SHORE -> new RarityStream<>(engine.getDimension().getShoreBiomeStyle()
|
||||
.zoom(dimension.getBiomeZoom())
|
||||
.zoom(dimension.getSeaZoom())
|
||||
.zoom(region.getSeaBiomeZoom());
|
||||
case SHORE -> dimension.getShoreBiomeStyle()
|
||||
.create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream()
|
||||
.zoom(engine.getDimension().getBiomeZoom())
|
||||
.zoom(region.getShoreBiomeZoom()),
|
||||
loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE));
|
||||
.zoom(dimension.getBiomeZoom())
|
||||
.zoom(region.getShoreBiomeZoom());
|
||||
};
|
||||
}
|
||||
|
||||
private static KList<String> biomeKeys(IrisRegion region, InferredType inferredType) {
|
||||
return switch (inferredType) {
|
||||
case CAVE -> region.getCaveBiomes();
|
||||
case LAND -> region.getLandBiomes();
|
||||
case SEA -> region.getSeaBiomes();
|
||||
case SHORE -> region.getShoreBiomes();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The noise that picks one of the region's biomes of this type, before rarity splits it. Image maps and the
|
||||
* studio biome buffet bypass it.
|
||||
*/
|
||||
public ProceduralStream<Double> biomePickNoiseStream(IrisRegion region, InferredType inferredType) {
|
||||
return biomePickNoiseStream(terrainEngine.getDimension(), region, inferredType);
|
||||
}
|
||||
|
||||
public double biomePickNoise(IrisRegion region, InferredType inferredType, double x, double z) {
|
||||
return biomePickNoiseStream(region, inferredType).getDouble(x, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* The region's biomes of this type in the order their rarities split the picking noise.
|
||||
*/
|
||||
public KList<IrisBiome> noisePickedBiomes(IrisRegion region, InferredType inferredType) {
|
||||
return loadInferredBiomes(biomeKeys(region, inferredType), inferredType);
|
||||
}
|
||||
|
||||
/**
|
||||
* The biome the noise picks from the region's biomes of this type, before child biomes or the coast replace it.
|
||||
*/
|
||||
public IrisBiome biomePickedByNoise(IrisRegion region, InferredType inferredType, double x, double z) {
|
||||
return createInferredBiomeStream(region, inferredType).get(x, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dimension's regions in the order their rarities split the region noise.
|
||||
*/
|
||||
public KList<IrisRegion> noisePickedRegions() {
|
||||
KList<IrisRegion> regions = new KList<>();
|
||||
for (IrisRegion region : data.getRegionLoader().loadAll(terrainEngine.getDimension().getRegions())) {
|
||||
if (!region.isCompatExcluded()) {
|
||||
regions.add(region);
|
||||
}
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base biome a spot gets in the given region, before the terrain height can move it to another biome list,
|
||||
* and the list it was picked from.
|
||||
*/
|
||||
public BiomePick basePickIn(IrisRegion region, double x, double z) {
|
||||
if (focusBiome != null) {
|
||||
return new BiomePick(focusBiome, focusBiome.getInferredType());
|
||||
}
|
||||
InferredType type = bridgeStream.get(x, z);
|
||||
return new BiomePick(implode(createInferredBiomeStream(region, type).get(x, z), x, z), type);
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface biome a spot would get if it belonged to the given region, and the biome list it came from, for
|
||||
* previewing one region across the map.
|
||||
*/
|
||||
public BiomePick surfacePickAsIfIn(IrisRegion region, double x, double z) {
|
||||
BiomePick base = basePickIn(region, x, z);
|
||||
if (focusBiome != null) {
|
||||
return base;
|
||||
}
|
||||
IrisBiome surface = resolveSurfaceBiome(heightStream.getDouble(x, z), base.biome(), region, x, z, fluidHeight,
|
||||
createInferredBiomeStream(region, InferredType.LAND),
|
||||
createInferredBiomeStream(region, InferredType.SEA),
|
||||
createInferredBiomeStream(region, InferredType.SHORE),
|
||||
shoreSlopeStream);
|
||||
return surface == base.biome() ? base : new BiomePick(implode(surface, x, z), surface.getInferredType());
|
||||
}
|
||||
|
||||
public IrisBiome surfaceBiomeAsIfIn(IrisRegion region, double x, double z) {
|
||||
return surfacePickAsIfIn(region, x, z).biome();
|
||||
}
|
||||
|
||||
public record BiomePick(IrisBiome biome, InferredType type) {
|
||||
}
|
||||
|
||||
private static boolean hydrologyActive(IrisHydrology hydrology) {
|
||||
if (hydrology == null) {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
|
||||
/**
|
||||
* IntelliJ's profiler gauge, how heavy the measured bake is, a meter and the time it took, all in the weight's colour.
|
||||
*/
|
||||
final class BakeMeter extends JComponent {
|
||||
private static final long serialVersionUID = 4120925066373218716L;
|
||||
private static final String PROFILER_ICON = "iris/icons/profiler.svg";
|
||||
private static final String TIMING_LABEL = "Timing";
|
||||
private static final String WIDEST_TIME = "999 ms";
|
||||
private static final int ICON_SIZE = 16;
|
||||
private static final int GAP = 6;
|
||||
private static final int BAR_HEIGHT = 4;
|
||||
private static final int HEIGHT = 20;
|
||||
|
||||
private final FlatSVGIcon icon = new FlatSVGIcon(PROFILER_ICON, ICON_SIZE, ICON_SIZE, BakeMeter.class.getClassLoader());
|
||||
private BakeTiming timing;
|
||||
private boolean timingUnderWay;
|
||||
|
||||
BakeMeter() {
|
||||
icon.setColorFilter(new FlatSVGIcon.ColorFilter(drawn -> color()));
|
||||
}
|
||||
|
||||
void showTiming() {
|
||||
timing = null;
|
||||
timingUnderWay = true;
|
||||
setToolTipText("Baking every variant five times over");
|
||||
repaint();
|
||||
}
|
||||
|
||||
void show(BakeTiming measured) {
|
||||
timing = measured;
|
||||
timingUnderWay = false;
|
||||
setToolTipText(measured.explain());
|
||||
repaint();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
timing = null;
|
||||
timingUnderWay = false;
|
||||
setToolTipText(null);
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
return new Dimension(0, StudioTheme.scale(HEIGHT));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
icon.paintIcon(this, canvas, 0, (getHeight() - icon.getIconHeight()) / 2);
|
||||
int labelX = icon.getIconWidth() + StudioTheme.scale(GAP);
|
||||
int barX = drawLabel(canvas, labelX) + StudioTheme.scale(GAP);
|
||||
int timeX = getWidth() - timeWidth(canvas);
|
||||
drawBar(canvas, barX, timeX - StudioTheme.scale(GAP) - barX);
|
||||
drawTime(canvas, timeX);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private Color color() {
|
||||
return timing == null ? StudioTheme.MUTED_TEXT : weight().color();
|
||||
}
|
||||
|
||||
private BakeWeight weight() {
|
||||
return BakeWeight.of(timing.totalMillis());
|
||||
}
|
||||
|
||||
// Returns where the label ends.
|
||||
private int drawLabel(Graphics2D canvas, int x) {
|
||||
canvas.setFont(StudioTheme.boldFont());
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
canvas.setColor(color());
|
||||
canvas.drawString(labelText(), x, baseline(metrics));
|
||||
int widestLabelWidth = metrics.stringWidth(TIMING_LABEL);
|
||||
for (BakeWeight each : BakeWeight.values()) {
|
||||
widestLabelWidth = Math.max(widestLabelWidth, metrics.stringWidth(each.label()));
|
||||
}
|
||||
return x + widestLabelWidth;
|
||||
}
|
||||
|
||||
private String labelText() {
|
||||
if (timing != null) {
|
||||
return weight().label();
|
||||
}
|
||||
return timingUnderWay ? TIMING_LABEL : "—";
|
||||
}
|
||||
|
||||
private void drawBar(Graphics2D canvas, int x, int width) {
|
||||
int barHeight = StudioTheme.scale(BAR_HEIGHT);
|
||||
int y = (getHeight() - barHeight) / 2;
|
||||
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
||||
canvas.fillRoundRect(x, y, width, barHeight, barHeight, barHeight);
|
||||
if (timing == null) {
|
||||
return;
|
||||
}
|
||||
int filledWidth = (int) Math.round(width * BakeWeight.meterFill(timing.totalMillis()));
|
||||
canvas.setColor(color());
|
||||
canvas.fillRoundRect(x, y, Math.max(barHeight, filledWidth), barHeight, barHeight, barHeight);
|
||||
}
|
||||
|
||||
private int timeWidth(Graphics2D canvas) {
|
||||
return canvas.getFontMetrics(StudioTheme.smallFont()).stringWidth(WIDEST_TIME);
|
||||
}
|
||||
|
||||
private void drawTime(Graphics2D canvas, int x) {
|
||||
if (timing == null) {
|
||||
return;
|
||||
}
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
String time = BakeTiming.formatMillis(timing.totalMillis());
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
canvas.drawString(time, x + timeWidth(canvas) - metrics.stringWidth(time), baseline(metrics));
|
||||
}
|
||||
|
||||
private int baseline(FontMetrics metrics) {
|
||||
return (getHeight() + metrics.getAscent() - metrics.getDescent()) / 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
/**
|
||||
* How long one bake of a procedural object's variants took.
|
||||
*/
|
||||
record BakeRun(long totalNanos, int variantCount) {
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* A procedural object's variants baked five times over, keeping the median run. The first run is slow while the JIT
|
||||
* compiles the baker, and the garbage collector or chunk generation can slow any other, so neither ends up in the middle.
|
||||
*/
|
||||
record BakeTiming(BakeRun median) {
|
||||
private static final int RUN_COUNT = 5;
|
||||
private static final double NANOS_PER_MILLI = 1_000_000D;
|
||||
|
||||
/** Null when {@code bake} finds nothing to bake, or once the timing is no longer wanted. */
|
||||
static BakeTiming measure(Supplier<BakeRun> bake, BooleanSupplier stillWanted) {
|
||||
List<BakeRun> runs = new ArrayList<>();
|
||||
for (int run = 0; run < RUN_COUNT; run++) {
|
||||
if (!stillWanted.getAsBoolean()) {
|
||||
return null;
|
||||
}
|
||||
BakeRun measured = bake.get();
|
||||
if (measured == null) {
|
||||
return null;
|
||||
}
|
||||
runs.add(measured);
|
||||
}
|
||||
runs.sort(Comparator.comparingLong(BakeRun::totalNanos));
|
||||
return new BakeTiming(runs.get(runs.size() / 2));
|
||||
}
|
||||
|
||||
double totalMillis() {
|
||||
return median.totalNanos() / NANOS_PER_MILLI;
|
||||
}
|
||||
|
||||
/** How long the bake took and when world generation pays for it. */
|
||||
String explain() {
|
||||
String variants = median.variantCount() == 1 ? "1 variant" : median.variantCount() + " variants";
|
||||
return "<html>" + variants + " in " + formatMillis(totalMillis()) + " · median of " + RUN_COUNT + " runs<br>"
|
||||
+ "Paid once per pack load, by the first chunk placing it<br>"
|
||||
+ BakeWeight.scale() + "</html>";
|
||||
}
|
||||
|
||||
static String formatMillis(double millis) {
|
||||
if (millis >= 1000D) {
|
||||
return String.format(Locale.ROOT, "%.1f s", millis / 1000D);
|
||||
}
|
||||
return String.format(Locale.ROOT, millis < 10D ? "%.1f ms" : "%.0f ms", millis);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.awt.Color;
|
||||
|
||||
/**
|
||||
* How long a procedural object's bake stalls the chunk that first places it, against a server tick of 50 ms.
|
||||
*/
|
||||
enum BakeWeight {
|
||||
LIGHT("Light", 1D, 50D, StudioTheme.SUCCESS),
|
||||
MODERATE("Moderate", 50D, 500D, StudioTheme.WARNING),
|
||||
HEAVY("Heavy", 500D, 5_000D, StudioTheme.ERROR);
|
||||
|
||||
private final String label;
|
||||
private final double fromMillis;
|
||||
private final double meterFullAtMillis;
|
||||
private final Color color;
|
||||
|
||||
BakeWeight(String label, double fromMillis, double meterFullAtMillis, Color color) {
|
||||
this.label = label;
|
||||
this.fromMillis = fromMillis;
|
||||
this.meterFullAtMillis = meterFullAtMillis;
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
static BakeWeight of(double millis) {
|
||||
for (BakeWeight weight : values()) {
|
||||
if (millis < weight.meterFullAtMillis) {
|
||||
return weight;
|
||||
}
|
||||
}
|
||||
return HEAVY;
|
||||
}
|
||||
|
||||
/**
|
||||
* How full the meter is for a bake of {@code millis}: each weight takes an equal share of the meter, filled on a
|
||||
* logarithmic scale, since bakes span thousands of times.
|
||||
*/
|
||||
static double meterFill(double millis) {
|
||||
BakeWeight weight = of(millis);
|
||||
double clamped = Math.clamp(millis, weight.fromMillis, weight.meterFullAtMillis);
|
||||
double withinWeight = Math.log(clamped / weight.fromMillis) / Math.log(weight.meterFullAtMillis / weight.fromMillis);
|
||||
return (weight.ordinal() + withinWeight) / values().length;
|
||||
}
|
||||
|
||||
static String scale() {
|
||||
return "Light under " + BakeTiming.formatMillis(MODERATE.fromMillis) + ", heavy from " + BakeTiming.formatMillis(HEAVY.fromMillis);
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
Color color() {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.PickMeasuring.TypedSpread;
|
||||
import art.arcane.iris.studio.map.components.PickDisplay;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.terrain.InferredType;
|
||||
import art.arcane.iris.generation.terrain.IrisRegion;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* The biome view's explorer: every region picks the biomes on its ground from its own biome lists, so for each region
|
||||
* in view it shows how its noise picks them.
|
||||
*/
|
||||
final class BiomeExplorer {
|
||||
private final Supplier<Engine> engineSource;
|
||||
private final PickInspectorPanel inspector;
|
||||
private final LatestMeasurement<Measurement> measurer = new LatestMeasurement<>("Iris Biome Explorer");
|
||||
private MapCensus.ViewArea latestArea;
|
||||
private MapCensus.ViewArea measuredArea;
|
||||
|
||||
/**
|
||||
* @param onDisplayChosen receives how the map should draw the picks
|
||||
*/
|
||||
BiomeExplorer(Supplier<Engine> engineSource, Consumer<PickDisplay> onDisplayChosen) {
|
||||
this.engineSource = engineSource;
|
||||
this.inspector = new PickInspectorPanel("Biomes", onDisplayChosen);
|
||||
}
|
||||
|
||||
PickInspectorPanel inspector() {
|
||||
return inspector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the regions in this area and how they pick their biomes, unless that was already done.
|
||||
*/
|
||||
void measure(MapCensus.ViewArea area) {
|
||||
latestArea = area;
|
||||
if (area == null || area.equals(measuredArea)) {
|
||||
return;
|
||||
}
|
||||
measuredArea = area;
|
||||
measurer.measure(() -> measurePicks(area), this::show);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pack changed, so the regions and their biomes are read again.
|
||||
*/
|
||||
void refresh() {
|
||||
measuredArea = null;
|
||||
measure(latestArea);
|
||||
}
|
||||
|
||||
void close() {
|
||||
measurer.close();
|
||||
}
|
||||
|
||||
private void show(Measurement measurement) {
|
||||
List<PickInspectorPanel.Section> sections = new ArrayList<>();
|
||||
for (RegionPicks region : measurement.regions()) {
|
||||
List<JComponent> charts = region.spreads().stream()
|
||||
.map(spread -> (JComponent) inspector.noiseChart(spread, "Biomes", region.key() + "/" + spread.title()))
|
||||
.toList();
|
||||
sections.add(new PickInspectorPanel.Section(region.key(), region.name(), region.color(), region.share(),
|
||||
biomeCountText(region.biomeCount()), charts));
|
||||
}
|
||||
inspector.showRegionSections("Each region splits its own biome noise by rarity", sections);
|
||||
}
|
||||
|
||||
private static String biomeCountText(int count) {
|
||||
return count == 1 ? "1 biome" : count + " biomes";
|
||||
}
|
||||
|
||||
private Measurement measurePicks(MapCensus.ViewArea area) {
|
||||
Engine engine = engineSource.get();
|
||||
IrisComplex complex = engine == null ? null : engine.getComplex();
|
||||
if (complex == null) {
|
||||
return null;
|
||||
}
|
||||
Map<IrisRegion, RegionSamples> samplesByRegion = new IdentityHashMap<>();
|
||||
int[] total = {0};
|
||||
PickMeasuring.forEachSample(area, (x, z) -> {
|
||||
IrisRegion region = complex.getRegionStream().get(x, z);
|
||||
if (region == null) {
|
||||
return;
|
||||
}
|
||||
total[0]++;
|
||||
samplesByRegion.computeIfAbsent(region, subject -> new RegionSamples(engine, complex, subject)).add(x, z);
|
||||
});
|
||||
List<RegionPicks> regions = samplesByRegion.values().stream()
|
||||
.sorted(Comparator.comparingInt(RegionSamples::count).reversed())
|
||||
.map(samples -> samples.picks(engine, total[0]))
|
||||
.toList();
|
||||
return new Measurement(regions);
|
||||
}
|
||||
|
||||
/**
|
||||
* The spots in view one region owns. The biome view shows each spot's base biome, which the region picks from the
|
||||
* biome list of the spot's continent type.
|
||||
*/
|
||||
private static final class RegionSamples {
|
||||
private final IrisComplex complex;
|
||||
private final IrisRegion region;
|
||||
private final List<TypedSpread> spreads;
|
||||
private final Map<InferredType, ProceduralStream<Double>> noiseByType = new EnumMap<>(InferredType.class);
|
||||
private int count;
|
||||
|
||||
private RegionSamples(Engine engine, IrisComplex complex, IrisRegion region) {
|
||||
this.complex = complex;
|
||||
this.region = region;
|
||||
this.spreads = PickMeasuring.biomeSpreads(engine, complex, region);
|
||||
}
|
||||
|
||||
private void add(double x, double z) {
|
||||
count++;
|
||||
InferredType type = complex.getBridgeStream().get(x, z);
|
||||
for (TypedSpread typed : spreads) {
|
||||
if (typed.type() == type) {
|
||||
typed.spread().add(noiseByType.computeIfAbsent(type, shown -> complex.biomePickNoiseStream(region, shown)).get(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int count() {
|
||||
return count;
|
||||
}
|
||||
|
||||
private RegionPicks picks(Engine engine, int totalCount) {
|
||||
double share = totalCount == 0 ? 0D : count / (double) totalCount;
|
||||
String key = region.getLoadKey();
|
||||
String name = region.getName();
|
||||
int color = PickMeasuring.regionColor(engine, region);
|
||||
List<TypedSpread> inView = spreads.stream().filter(typed -> typed.spread().sampleCount() > 0).toList();
|
||||
int biomeCount = (int) inView.stream()
|
||||
.flatMap(typed -> typed.spread().rows().stream())
|
||||
.map(row -> row.option().key())
|
||||
.distinct()
|
||||
.count();
|
||||
return new RegionPicks(key, name, color, share, biomeCount, inView.stream().map(TypedSpread::spread).toList());
|
||||
}
|
||||
}
|
||||
|
||||
private record RegionPicks(String key, String name, int color, double share, int biomeCount,
|
||||
List<NoisePickSpread> spreads) {
|
||||
}
|
||||
|
||||
private record Measurement(List<RegionPicks> regions) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
// A copy of Minecraft's Biome.BIOME_INFO_NOISE, the 2D simplex noise swamps use to pick between their two grass colours.
|
||||
// Minecraft seeds it through a LegacyRandomSource, which draws the same numbers as java.util.Random.
|
||||
final class BiomeInfoNoise {
|
||||
private static final long SEED = 2345L;
|
||||
private static final int PERMUTATION_SIZE = 256;
|
||||
private static final int GRADIENT_COUNT = 12;
|
||||
private static final double SKEW = 0.5D * (Math.sqrt(3D) - 1D);
|
||||
private static final double UNSKEW = (3D - Math.sqrt(3D)) / 6D;
|
||||
private static final double CORNER_RADIUS = 0.5D;
|
||||
private static final double OUTPUT_SCALE = 70D;
|
||||
private static final int[][] GRADIENTS = {
|
||||
{1, 1}, {-1, 1}, {1, -1}, {-1, -1},
|
||||
{1, 0}, {-1, 0}, {1, 0}, {-1, 0},
|
||||
{0, 1}, {0, -1}, {0, 1}, {0, -1}
|
||||
};
|
||||
private static final int[] PERMUTATION = createPermutation();
|
||||
|
||||
private BiomeInfoNoise() {
|
||||
}
|
||||
|
||||
static float at(double x, double z) {
|
||||
double skewOffset = (x + z) * SKEW;
|
||||
int cellX = (int) Math.floor(x + skewOffset);
|
||||
int cellZ = (int) Math.floor(z + skewOffset);
|
||||
double unskewOffset = (cellX + cellZ) * UNSKEW;
|
||||
double x0 = x - (cellX - unskewOffset);
|
||||
double z0 = z - (cellZ - unskewOffset);
|
||||
int stepX = x0 > z0 ? 1 : 0;
|
||||
int stepZ = 1 - stepX;
|
||||
double x1 = x0 - stepX + UNSKEW;
|
||||
double z1 = z0 - stepZ + UNSKEW;
|
||||
double x2 = x0 - 1D + 2D * UNSKEW;
|
||||
double z2 = z0 - 1D + 2D * UNSKEW;
|
||||
int wrappedX = cellX & 0xFF;
|
||||
int wrappedZ = cellZ & 0xFF;
|
||||
int gradient0 = permute(wrappedX + permute(wrappedZ)) % GRADIENT_COUNT;
|
||||
int gradient1 = permute(wrappedX + stepX + permute(wrappedZ + stepZ)) % GRADIENT_COUNT;
|
||||
int gradient2 = permute(wrappedX + 1 + permute(wrappedZ + 1)) % GRADIENT_COUNT;
|
||||
return (float) (OUTPUT_SCALE * (corner(gradient0, x0, z0) + corner(gradient1, x1, z1) + corner(gradient2, x2, z2)));
|
||||
}
|
||||
|
||||
private static double corner(int gradient, double x, double z) {
|
||||
double falloff = CORNER_RADIUS - x * x - z * z;
|
||||
if (falloff < 0D) {
|
||||
return 0D;
|
||||
}
|
||||
falloff *= falloff;
|
||||
return falloff * falloff * (GRADIENTS[gradient][0] * x + GRADIENTS[gradient][1] * z);
|
||||
}
|
||||
|
||||
private static int permute(int index) {
|
||||
return PERMUTATION[index & 0xFF];
|
||||
}
|
||||
|
||||
// Minecraft draws three offsets first and discards them for this noise, so they are drawn here too to stay in step.
|
||||
private static int[] createPermutation() {
|
||||
Random random = new Random(SEED);
|
||||
for (int discardedOffset = 0; discardedOffset < 3; discardedOffset++) {
|
||||
random.nextDouble();
|
||||
}
|
||||
int[] permutation = new int[PERMUTATION_SIZE];
|
||||
for (int i = 0; i < PERMUTATION_SIZE; i++) {
|
||||
permutation[i] = i;
|
||||
}
|
||||
for (int i = 0; i < PERMUTATION_SIZE; i++) {
|
||||
int swap = i + random.nextInt(PERMUTATION_SIZE - i);
|
||||
int previous = permutation[i];
|
||||
permutation[i] = permutation[swap];
|
||||
permutation[swap] = previous;
|
||||
}
|
||||
return permutation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.biome.IrisBiomePaletteLayer;
|
||||
import art.arcane.iris.generation.block.IrisBlockData;
|
||||
import art.arcane.iris.generation.decoration.IrisDecorator;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* One of the block palettes a biome places: a surface, sea or cave ceiling layer, its wall or slab blocks, or a
|
||||
* decorator.
|
||||
*/
|
||||
record BiomePalette(String biomeKey, Kind kind, int index) {
|
||||
private static final String ID_PREFIX = "palette:";
|
||||
private static final char ID_SEPARATOR = '#';
|
||||
private static final String SNIPPET_PREFIX = "snippet/";
|
||||
|
||||
enum Kind {
|
||||
LAYER("Layer", "layers", SnippetFolder.BLOCK_PALETTES, true, true),
|
||||
SEA_LAYER("Sea layer", "seaLayers", SnippetFolder.BLOCK_PALETTES, true, true),
|
||||
CAVE_CEILING_LAYER("Cave ceiling layer", "caveCeilingLayers", SnippetFolder.BLOCK_PALETTES, true, true),
|
||||
WALL("Wall", "wall", SnippetFolder.BLOCK_PALETTES, false, false),
|
||||
SLAB("Slab", "slab", SnippetFolder.BLOCK_PALETTES, false, false),
|
||||
DECORATOR("Decorator", "decorators", SnippetFolder.DECORATORS, false, true);
|
||||
|
||||
private final String label;
|
||||
private final String jsonKey;
|
||||
private final SnippetFolder snippetFolder;
|
||||
private final boolean stacked;
|
||||
private final boolean numbered;
|
||||
|
||||
Kind(String label, String jsonKey, SnippetFolder snippetFolder, boolean stacked, boolean numbered) {
|
||||
this.label = label;
|
||||
this.jsonKey = jsonKey;
|
||||
this.snippetFolder = snippetFolder;
|
||||
this.stacked = stacked;
|
||||
this.numbered = numbered;
|
||||
}
|
||||
|
||||
SnippetFolder snippetFolder() {
|
||||
return snippetFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the palette is a stack of blocks with a thickness, rather than single blocks on walls or slopes.
|
||||
*/
|
||||
boolean isStacked() {
|
||||
return stacked;
|
||||
}
|
||||
|
||||
/**
|
||||
* The biome file's key holding these palettes: a list of them, or a single one for walls and slabs.
|
||||
*/
|
||||
String jsonKey() {
|
||||
return jsonKey;
|
||||
}
|
||||
|
||||
// A biome without wall or slab blocks still has those palettes, just empty.
|
||||
List<IrisBiomePaletteLayer> layersOf(IrisBiome biome) {
|
||||
return switch (this) {
|
||||
case LAYER -> biome.getLayers();
|
||||
case SEA_LAYER -> biome.getSeaLayers();
|
||||
case CAVE_CEILING_LAYER -> biome.getCaveCeilingLayers();
|
||||
case WALL -> biome.getWall().getPalette().isEmpty() ? List.of() : List.of(biome.getWall());
|
||||
case SLAB -> biome.getSlab().getPalette().isEmpty() ? List.of() : List.of(biome.getSlab());
|
||||
case DECORATOR -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
// A decorator snippet that couldn't be found loads as an empty entry, which places nothing.
|
||||
List<KList<IrisBlockData>> blockListsOf(IrisBiome biome) {
|
||||
if (this != DECORATOR) {
|
||||
return layersOf(biome).stream().map(IrisBiomePaletteLayer::getPalette).toList();
|
||||
}
|
||||
return biome.getDecorators().stream()
|
||||
.map(decorator -> decorator == null ? new KList<IrisBlockData>() : decorator.getPalette())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders under snippet/ that palettes can come from, named after the Iris type they hold.
|
||||
*/
|
||||
enum SnippetFolder {
|
||||
BLOCK_PALETTES("biome-palette", "Block palettes"),
|
||||
DECORATORS("decorator", "Decorators");
|
||||
|
||||
private final String folder;
|
||||
private final String label;
|
||||
|
||||
SnippetFolder(String folder, String label) {
|
||||
this.folder = folder;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
String folder() {
|
||||
return folder;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The snippet's path inside this folder. Packs may leave the folder out, the way the snippet loader allows.
|
||||
*/
|
||||
String pathOf(String reference) {
|
||||
String path = reference.startsWith(SNIPPET_PREFIX) ? reference.substring(SNIPPET_PREFIX.length()) : reference;
|
||||
return path.startsWith(folder + "/") ? path.substring(folder.length() + 1) : path;
|
||||
}
|
||||
}
|
||||
|
||||
static List<BiomePalette> allOf(String biomeKey, IrisBiome biome) {
|
||||
List<BiomePalette> palettes = new ArrayList<>();
|
||||
for (Kind kind : Kind.values()) {
|
||||
List<KList<IrisBlockData>> blockLists = kind.blockListsOf(biome);
|
||||
for (int index = 0; index < blockLists.size(); index++) {
|
||||
if (!blockLists.get(index).isEmpty()) {
|
||||
palettes.add(new BiomePalette(biomeKey, kind, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(palettes);
|
||||
}
|
||||
|
||||
static BiomePalette fromId(String id) {
|
||||
if (id == null || !id.startsWith(ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
int indexSeparator = id.lastIndexOf(ID_SEPARATOR);
|
||||
int kindSeparator = id.lastIndexOf(ID_SEPARATOR, indexSeparator - 1);
|
||||
if (kindSeparator < ID_PREFIX.length()) {
|
||||
return null;
|
||||
}
|
||||
return new BiomePalette(
|
||||
id.substring(ID_PREFIX.length(), kindSeparator),
|
||||
Kind.valueOf(id.substring(kindSeparator + 1, indexSeparator)),
|
||||
Integer.parseInt(id.substring(indexSeparator + 1))
|
||||
);
|
||||
}
|
||||
|
||||
static boolean belongsTo(String id, String biomeKey) {
|
||||
return id != null && id.startsWith(ID_PREFIX + biomeKey + ID_SEPARATOR);
|
||||
}
|
||||
|
||||
String id() {
|
||||
return ID_PREFIX + biomeKey + ID_SEPARATOR + kind.name() + ID_SEPARATOR + index;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return kind.numbered ? kind.label + " " + index : kind.label;
|
||||
}
|
||||
|
||||
String lowercaseLabel() {
|
||||
return label().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette's blocks in the given copy of the biome, or null when a hotload removed it.
|
||||
*/
|
||||
KList<IrisBlockData> blocksIn(IrisBiome biome) {
|
||||
List<KList<IrisBlockData>> blockLists = kind.blockListsOf(biome);
|
||||
return index < blockLists.size() ? blockLists.get(index) : null;
|
||||
}
|
||||
|
||||
IrisBiomePaletteLayer layerIn(IrisBiome biome) {
|
||||
return kind.layersOf(biome).get(index);
|
||||
}
|
||||
|
||||
IrisDecorator decoratorIn(IrisBiome biome) {
|
||||
return biome.getDecorators().get(index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.generation.decoration.IrisDecorationPart;
|
||||
import art.arcane.iris.generation.decoration.IrisEngineDecorator;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.biome.IrisBiomePaletteLayer;
|
||||
import art.arcane.iris.generation.block.IrisBlockData;
|
||||
import art.arcane.iris.generation.decoration.IrisDecorator;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.noise.CNG;
|
||||
|
||||
/**
|
||||
* How thick one biome palette is at a spot and which noise picks its block there, sampled exactly the way the
|
||||
* generator does for that kind of palette, with the same random sources the terrain, carving, post and decorator
|
||||
* passes use.
|
||||
*/
|
||||
abstract class BiomePalettePlacement {
|
||||
protected final IrisData data;
|
||||
|
||||
private BiomePalettePlacement(IrisData data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
static BiomePalettePlacement of(Engine engine, IrisBiome biome, BiomePalette palette) {
|
||||
IrisData data = engine.getData();
|
||||
RNG terrainRandom = new RNG(engine.getSeedManager().getTerrain());
|
||||
RNG carveRandom = new RNG(engine.getSeedManager().getCarve());
|
||||
RNG postRandom = new RNG(engine.getSeedManager().getPost());
|
||||
int seaLevel = seaLevel(engine.getDimension());
|
||||
int index = palette.index();
|
||||
IrisBiomePaletteLayer layer = palette.layerIn(biome);
|
||||
return switch (palette.kind()) {
|
||||
case LAYER -> new StackedLayer(layer, index, biome.getLayerHeightGenerators(terrainRandom, data), terrainRandom, data);
|
||||
case SEA_LAYER -> new StackedLayer(layer, index, biome.getLayerSeaHeightGenerators(terrainRandom, data), terrainRandom, data);
|
||||
case CAVE_CEILING_LAYER -> new StackedLayer(layer, index, biome.getCaveCeilingLayerHeightGenerators(carveRandom, data),
|
||||
carveRandom, data);
|
||||
case WALL -> new SingleBlocks(layer, data, carveRandom, seaLevel);
|
||||
case SLAB -> new SingleBlocks(layer, data, postRandom, seaLevel);
|
||||
case DECORATOR -> new Decorations(palette.decoratorIn(biome), engine.getSeedManager().getComponent(), seaLevel, data);
|
||||
};
|
||||
}
|
||||
|
||||
private static int seaLevel(IrisDimension dimension) {
|
||||
return dimension == null ? 0 : dimension.getFluidHeight();
|
||||
}
|
||||
|
||||
abstract KList<IrisBlockData> blocks();
|
||||
|
||||
abstract double zoom();
|
||||
|
||||
int minThickness() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
int maxThickness() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many blocks of this palette are stacked at the spot.
|
||||
*/
|
||||
abstract int thickness(int blockX, int blockZ);
|
||||
|
||||
abstract double noise(int blockX, int blockZ, int depth);
|
||||
|
||||
private abstract static class LayerPlacement extends BiomePalettePlacement {
|
||||
protected final IrisBiomePaletteLayer layer;
|
||||
|
||||
private LayerPlacement(IrisBiomePaletteLayer layer, IrisData data) {
|
||||
super(data);
|
||||
this.layer = layer;
|
||||
}
|
||||
|
||||
@Override
|
||||
KList<IrisBlockData> blocks() {
|
||||
return layer.getPalette();
|
||||
}
|
||||
|
||||
@Override
|
||||
double zoom() {
|
||||
return layer.getZoom();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface, sea and cave ceiling layers. Each layer's thickness comes from its own noise in the list the generator
|
||||
* builds, and every depth reseeds the block noise, sampled through the zoom twice.
|
||||
*/
|
||||
private static final class StackedLayer extends LayerPlacement {
|
||||
private final int index;
|
||||
private final RNG random;
|
||||
private final CNG thicknessNoise;
|
||||
|
||||
private StackedLayer(IrisBiomePaletteLayer layer, int index, KList<CNG> thicknessNoises, RNG random, IrisData data) {
|
||||
super(layer, data);
|
||||
this.index = index;
|
||||
this.random = random;
|
||||
this.thicknessNoise = index < thicknessNoises.size() ? thicknessNoises.get(index) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
int minThickness() {
|
||||
return layer.getMinHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
int maxThickness() {
|
||||
return layer.getMaxHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
int thickness(int blockX, int blockZ) {
|
||||
if (thicknessNoise == null) {
|
||||
return 0;
|
||||
}
|
||||
double zoom = layer.getZoom();
|
||||
return Math.max(0, thicknessNoise.fit(layer.getMinHeight(), layer.getMaxHeight(), blockX / zoom, blockZ / zoom));
|
||||
}
|
||||
|
||||
@Override
|
||||
double noise(int blockX, int blockZ, int depth) {
|
||||
double zoom = layer.getZoom();
|
||||
return layer.getLayerGenerator(random, index + depth, data)
|
||||
.noise((blockX + depth) / zoom / zoom, depth / zoom, (blockZ - depth) / zoom / zoom);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall and slab blocks are picked one at a time with noise that also changes with height.
|
||||
*/
|
||||
private static final class SingleBlocks extends LayerPlacement {
|
||||
private final RNG random;
|
||||
private final int sampleHeight;
|
||||
|
||||
private SingleBlocks(IrisBiomePaletteLayer layer, IrisData data, RNG random, int sampleHeight) {
|
||||
super(layer, data);
|
||||
this.random = random;
|
||||
this.sampleHeight = sampleHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
int thickness(int blockX, int blockZ) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
double noise(int blockX, int blockZ, int depth) {
|
||||
double zoom = layer.getZoom();
|
||||
return layer.getLayerGenerator(random, data).noise(blockX / zoom, sampleHeight / zoom, blockZ / zoom);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A decorator places where its style noise stays under its chance, and its variance noise picks the block there.
|
||||
* Surface and ceiling decorators pick single blocks from flat noise; sea and shore decorators pick from noise that
|
||||
* also changes with height.
|
||||
*/
|
||||
private static final class Decorations extends BiomePalettePlacement {
|
||||
private final IrisDecorator decorator;
|
||||
private final CNG placementNoise;
|
||||
private final CNG blockNoise;
|
||||
private final boolean picksFromFlatNoise;
|
||||
private final int sampleHeight;
|
||||
|
||||
private Decorations(IrisDecorator decorator, long componentSeed, int sampleHeight, IrisData data) {
|
||||
super(data);
|
||||
this.decorator = decorator;
|
||||
RNG partRandom = new RNG(IrisEngineDecorator.seedForPart(componentSeed, decorator.getPartOf()));
|
||||
this.placementNoise = decorator.getGenerator(partRandom, data);
|
||||
this.blockNoise = decorator.getVarianceGenerator(partRandom, data);
|
||||
this.picksFromFlatNoise = decorator.getPartOf() == IrisDecorationPart.NONE
|
||||
|| decorator.getPartOf() == IrisDecorationPart.CEILING;
|
||||
this.sampleHeight = sampleHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
KList<IrisBlockData> blocks() {
|
||||
return decorator.getPalette();
|
||||
}
|
||||
|
||||
@Override
|
||||
double zoom() {
|
||||
return decorator.getStyle().getZoom();
|
||||
}
|
||||
|
||||
@Override
|
||||
int thickness(int blockX, int blockZ) {
|
||||
double zoom = zoom();
|
||||
return placementNoise.fitDouble(0D, 1D, blockX / zoom, blockZ / zoom) <= decorator.getChance() ? 1 : 0;
|
||||
}
|
||||
|
||||
// The generator swaps X and Z when it picks the block.
|
||||
@Override
|
||||
double noise(int blockX, int blockZ, int depth) {
|
||||
return picksFromFlatNoise ? blockNoise.noiseFast2D(blockZ, blockX) : blockNoise.noise(blockZ, sampleHeight, blockX);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
enum BiomeTint {
|
||||
GRASS,
|
||||
FOLIAGE,
|
||||
DRY_FOLIAGE,
|
||||
WATER
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
// The colours one Minecraft biome multiplies onto tinted blocks, as worked out by Biome#getGrassColor and friends.
|
||||
record BiomeTintColors(int grass, GrassColorModifier grassModifier, int foliage, int dryFoliage, int water) {
|
||||
int colorAt(BiomeTint tint, int blockX, int blockZ) {
|
||||
return switch (tint) {
|
||||
case GRASS -> grassModifier.modify(blockX, blockZ, grass);
|
||||
case FOLIAGE -> foliage;
|
||||
case DRY_FOLIAGE -> dryFoliage;
|
||||
case WATER -> water;
|
||||
};
|
||||
}
|
||||
|
||||
boolean variesByPosition() {
|
||||
return grassModifier == GrassColorModifier.SWAMP;
|
||||
}
|
||||
|
||||
enum GrassColorModifier {
|
||||
NONE,
|
||||
DARK_FOREST,
|
||||
SWAMP;
|
||||
|
||||
private static final double SWAMP_NOISE_SCALE = 0.0225D;
|
||||
private static final double SWAMP_WET_THRESHOLD = -0.1D;
|
||||
private static final int SWAMP_WET_GRASS = 0x4C763C;
|
||||
private static final int SWAMP_DRY_GRASS = 0x6A7039;
|
||||
private static final int DARK_FOREST_SHIFT = 0x28340A;
|
||||
|
||||
static GrassColorModifier named(String name) {
|
||||
return switch (name.toLowerCase(Locale.ROOT)) {
|
||||
case "dark_forest" -> DARK_FOREST;
|
||||
case "swamp" -> SWAMP;
|
||||
default -> NONE;
|
||||
};
|
||||
}
|
||||
|
||||
int modify(int blockX, int blockZ, int baseColor) {
|
||||
return switch (this) {
|
||||
case NONE -> baseColor;
|
||||
case DARK_FOREST -> ((baseColor & 0xFEFEFE) + DARK_FOREST_SHIFT) >> 1;
|
||||
case SWAMP -> BiomeInfoNoise.at(blockX * SWAMP_NOISE_SCALE, blockZ * SWAMP_NOISE_SCALE) < SWAMP_WET_THRESHOLD
|
||||
? SWAMP_WET_GRASS
|
||||
: SWAMP_DRY_GRASS;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.biome.IrisBiomeCustom;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
// The tint Minecraft draws on each column of one Iris biome. Iris picks the Minecraft biome once per chunk
|
||||
// (see IrisBiomeActuator), and the client averages the colours of the columns around each block.
|
||||
final class BiomeTintField {
|
||||
private static final int MINECRAFT_DEFAULT_BLEND_RADIUS = 2;
|
||||
private static final int BLENDED_COLUMN_COUNT = (MINECRAFT_DEFAULT_BLEND_RADIUS * 2 + 1) * (MINECRAFT_DEFAULT_BLEND_RADIUS * 2 + 1);
|
||||
private static final int BLENDED_TINT_CACHE_SIZE = 1 << 16;
|
||||
private static final int CHUNK_COLORS_CACHE_SIZE = 1 << 10;
|
||||
|
||||
private final IrisBiome biome;
|
||||
private final RNG biomeRandom;
|
||||
private final MinecraftBiomeColors minecraftColors;
|
||||
private final BiomeTintColors sameColorsEverywhere;
|
||||
private final Map<IrisBiomeCustom, BiomeTintColors> colorsByCustomBiome = new ConcurrentHashMap<>();
|
||||
private final BlendedTint[] blendedTintCache = new BlendedTint[BLENDED_TINT_CACHE_SIZE];
|
||||
private final ChunkColors[] chunkColorsCache = new ChunkColors[CHUNK_COLORS_CACHE_SIZE];
|
||||
|
||||
BiomeTintField(IrisBiome biome, RNG biomeRandom, MinecraftBiomeColors minecraftColors) {
|
||||
this.biome = biome;
|
||||
this.biomeRandom = biomeRandom;
|
||||
this.minecraftColors = minecraftColors;
|
||||
BiomeTintColors onlyColors = onlyPossibleColors();
|
||||
this.sameColorsEverywhere = onlyColors == null || onlyColors.variesByPosition() ? null : onlyColors;
|
||||
}
|
||||
|
||||
int colorAt(BiomeTint tint, int blockX, int blockZ) {
|
||||
if (sameColorsEverywhere != null) {
|
||||
return sameColorsEverywhere.colorAt(tint, blockX, blockZ);
|
||||
}
|
||||
int cacheSlot = cacheSlot(blockX, blockZ, tint.ordinal(), BLENDED_TINT_CACHE_SIZE);
|
||||
BlendedTint cached = blendedTintCache[cacheSlot];
|
||||
if (cached != null && cached.x() == blockX && cached.z() == blockZ && cached.tint() == tint) {
|
||||
return cached.color();
|
||||
}
|
||||
int color = blendedColor(tint, blockX, blockZ);
|
||||
blendedTintCache[cacheSlot] = new BlendedTint(blockX, blockZ, tint, color);
|
||||
return color;
|
||||
}
|
||||
|
||||
// Minecraft's ClientLevel#calculateBlockTint at the default biome blend radius.
|
||||
private int blendedColor(BiomeTint tint, int blockX, int blockZ) {
|
||||
int red = 0;
|
||||
int green = 0;
|
||||
int blue = 0;
|
||||
for (int x = blockX - MINECRAFT_DEFAULT_BLEND_RADIUS; x <= blockX + MINECRAFT_DEFAULT_BLEND_RADIUS; x++) {
|
||||
for (int z = blockZ - MINECRAFT_DEFAULT_BLEND_RADIUS; z <= blockZ + MINECRAFT_DEFAULT_BLEND_RADIUS; z++) {
|
||||
int color = chunkColors(x >> 4, z >> 4).colorAt(tint, x, z);
|
||||
red += (color >>> 16) & 0xFF;
|
||||
green += (color >>> 8) & 0xFF;
|
||||
blue += color & 0xFF;
|
||||
}
|
||||
}
|
||||
return (red / BLENDED_COLUMN_COUNT) << 16 | (green / BLENDED_COLUMN_COUNT) << 8 | (blue / BLENDED_COLUMN_COUNT);
|
||||
}
|
||||
|
||||
private BiomeTintColors chunkColors(int chunkX, int chunkZ) {
|
||||
int cacheSlot = cacheSlot(chunkX, chunkZ, 0, CHUNK_COLORS_CACHE_SIZE);
|
||||
ChunkColors cached = chunkColorsCache[cacheSlot];
|
||||
if (cached != null && cached.chunkX() == chunkX && cached.chunkZ() == chunkZ) {
|
||||
return cached.colors();
|
||||
}
|
||||
BiomeTintColors colors = colorsForChunkAt(chunkX << 4, chunkZ << 4);
|
||||
chunkColorsCache[cacheSlot] = new ChunkColors(chunkX, chunkZ, colors);
|
||||
return colors;
|
||||
}
|
||||
|
||||
private BiomeTintColors colorsForChunkAt(int originX, int originZ) {
|
||||
if (biome.isCustom()) {
|
||||
return customColors(biome.getCustomBiome(biomeRandom, originX, 0, originZ));
|
||||
}
|
||||
return vanillaColors(biome.getSkyBiomeKey(biomeRandom, originX, 0, originZ));
|
||||
}
|
||||
|
||||
// Follows the same choices as IrisBiome#getSkyBiomeKey and #getCustomBiome, returning null when chunks can differ.
|
||||
private BiomeTintColors onlyPossibleColors() {
|
||||
if (biome.isCustom()) {
|
||||
KList<IrisBiomeCustom> variants = biome.getCustomDerivitives();
|
||||
return variants.size() == 1 ? customColors(variants.get(0)) : null;
|
||||
}
|
||||
KList<String> skyBiomes = biome.getBiomeSkyScatter();
|
||||
if (!skyBiomes.isEmpty()) {
|
||||
return skyBiomes.size() == 1 ? vanillaColors(skyBiomes.get(0)) : null;
|
||||
}
|
||||
KList<String> groundBiomes = biome.getBiomeScatter();
|
||||
if (groundBiomes.isEmpty()) {
|
||||
return vanillaColors(biome.getDerivativeKey());
|
||||
}
|
||||
return groundBiomes.size() == 1 ? vanillaColors(groundBiomes.get(0)) : null;
|
||||
}
|
||||
|
||||
private BiomeTintColors customColors(IrisBiomeCustom customBiome) {
|
||||
return colorsByCustomBiome.computeIfAbsent(customBiome, minecraftColors::custom);
|
||||
}
|
||||
|
||||
private BiomeTintColors vanillaColors(String vanillaBiomeKey) {
|
||||
return minecraftColors.vanilla(vanillaBiomeKey.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
private static int cacheSlot(int x, int z, int salt, int cacheSize) {
|
||||
long hash = x * 0x9E3779B97F4A7C15L ^ z * 0xC2B2AE3D27D4EB4FL ^ salt * 0x165667B19E3779F9L;
|
||||
return (int) ((hash ^ (hash >>> 29)) & (cacheSize - 1));
|
||||
}
|
||||
|
||||
private record BlendedTint(int x, int z, BiomeTint tint, int color) {
|
||||
}
|
||||
|
||||
private record ChunkColors(int chunkX, int chunkZ, BiomeTintColors colors) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.function.IntUnaryOperator;
|
||||
|
||||
/**
|
||||
* The visible faces of noise terrain as columns, with neighbouring columns merged into one face where they show the
|
||||
* same stretch: tops of one height along X, and sides of one height range along the side. A face runs {@code columns}
|
||||
* columns from its first one and reaches {@code height} across: one column along Z for tops, blocks up for sides.
|
||||
* <p>
|
||||
* Hole walls follow the surface: a side from the floor up for each column next to where a {@link TerrainHole} may end,
|
||||
* shown in place of the usual side while the column beside it is left out. No face crosses a hole's cell edge, so one
|
||||
* set of faces serves every hole.
|
||||
* <p>
|
||||
* Faces depend on neither the camera nor the hole, so they are found once per terrain rather than every frame.
|
||||
*/
|
||||
final class BlockFaces {
|
||||
private static final int INITIAL_CAPACITY = 1024;
|
||||
private static final Facing[] FACINGS = Facing.values();
|
||||
private static final Kind[] KINDS = Kind.values();
|
||||
|
||||
private final NoiseTerrain terrain;
|
||||
private final int floorY;
|
||||
// Faces are kept kind by kind, and facing by facing within a kind.
|
||||
private final int[] firstOfGroup = new int[KINDS.length * FACINGS.length + 1];
|
||||
private int count;
|
||||
private int[] firstColumn = new int[INITIAL_CAPACITY];
|
||||
private int[] firstRow = new int[INITIAL_CAPACITY];
|
||||
private int[] bottomY = new int[INITIAL_CAPACITY];
|
||||
private int[] columns = new int[INITIAL_CAPACITY];
|
||||
private int[] height = new int[INITIAL_CAPACITY];
|
||||
private byte[] facing = new byte[INITIAL_CAPACITY];
|
||||
|
||||
private BlockFaces(NoiseTerrain terrain, int floorY) {
|
||||
this.terrain = terrain;
|
||||
this.floorY = floorY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param floorY where sides on the edge, next to a gap or into a hole end
|
||||
*/
|
||||
static BlockFaces of(NoiseTerrain terrain, int floorY) {
|
||||
BlockFaces faces = new BlockFaces(terrain, floorY);
|
||||
for (Kind kind : KINDS) {
|
||||
for (Facing facing : FACINGS) {
|
||||
faces.firstOfGroup[group(kind, facing)] = faces.count;
|
||||
faces.add(kind, facing);
|
||||
}
|
||||
}
|
||||
faces.firstOfGroup[KINDS.length * FACINGS.length] = faces.count;
|
||||
faces.trimToCount();
|
||||
return faces;
|
||||
}
|
||||
|
||||
private static int group(Kind kind, Facing facing) {
|
||||
return kind.ordinal() * FACINGS.length + facing.ordinal();
|
||||
}
|
||||
|
||||
private void add(Kind kind, Facing facing) {
|
||||
if (kind == Kind.HOLE_WALL) {
|
||||
addHoleWalls(facing);
|
||||
} else if (facing == Facing.TOP) {
|
||||
addTops();
|
||||
} else {
|
||||
addSides(facing);
|
||||
}
|
||||
}
|
||||
|
||||
// A height of 64 places blocks up to y 64, so the column's top face is at 65.
|
||||
static int blockTop(double height) {
|
||||
return (int) Math.floor(height) + 1;
|
||||
}
|
||||
|
||||
int count() {
|
||||
return count;
|
||||
}
|
||||
|
||||
// All faces of one kind and facing follow on from the first.
|
||||
int first(Kind kind, Facing facing) {
|
||||
return firstOfGroup[group(kind, facing)];
|
||||
}
|
||||
|
||||
int count(Kind kind, Facing facing) {
|
||||
return firstOfGroup[group(kind, facing) + 1] - firstOfGroup[group(kind, facing)];
|
||||
}
|
||||
|
||||
boolean isHoleWall(int face) {
|
||||
return face >= first(Kind.HOLE_WALL, FACINGS[0]);
|
||||
}
|
||||
|
||||
int bottomY(int face) {
|
||||
return bottomY[face];
|
||||
}
|
||||
|
||||
Facing facing(int face) {
|
||||
return FACINGS[facing[face]];
|
||||
}
|
||||
|
||||
int columns(int face) {
|
||||
return columns[face];
|
||||
}
|
||||
|
||||
int height(int face) {
|
||||
return height[face];
|
||||
}
|
||||
|
||||
// The column and row of the face's first column; the others follow along its run.
|
||||
int firstColumn(int face) {
|
||||
return firstColumn[face];
|
||||
}
|
||||
|
||||
int firstRow(int face) {
|
||||
return firstRow[face];
|
||||
}
|
||||
|
||||
// The corner the face runs and reaches from; east and south sides lie on the far edge of their column.
|
||||
double originX(int face) {
|
||||
return terrain.worldX(firstColumn[face] + (facing(face).normalX > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
double originY(int face) {
|
||||
return bottomY[face];
|
||||
}
|
||||
|
||||
double originZ(int face) {
|
||||
return terrain.worldZ(firstRow[face] + (facing(face).normalZ > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
double runX(int face) {
|
||||
return facing(face).runsAlongX() ? (double) columns[face] * terrain.step() : 0D;
|
||||
}
|
||||
|
||||
double runZ(int face) {
|
||||
return facing(face).runsAlongX() ? 0D : (double) columns[face] * terrain.step();
|
||||
}
|
||||
|
||||
double reachY(int face) {
|
||||
return facing(face) == Facing.TOP ? 0D : height[face];
|
||||
}
|
||||
|
||||
double reachZ(int face) {
|
||||
return facing(face) == Facing.TOP ? (double) height[face] * terrain.step() : 0D;
|
||||
}
|
||||
|
||||
private boolean hasColumn(int column, int row) {
|
||||
return terrain.hasHeight(column, row);
|
||||
}
|
||||
|
||||
private static boolean startsCell(int columnOrRow) {
|
||||
return columnOrRow % TerrainHole.CELL_COLUMNS == 0;
|
||||
}
|
||||
|
||||
private void addTops() {
|
||||
for (int row = 0; row < terrain.rows(); row++) {
|
||||
int column = 0;
|
||||
while (column < terrain.columns()) {
|
||||
if (!hasColumn(column, row)) {
|
||||
column++;
|
||||
continue;
|
||||
}
|
||||
int top = blockTop(terrain.height(column, row));
|
||||
int runEnd = column + 1;
|
||||
while (runEnd < terrain.columns() && !startsCell(runEnd) && hasColumn(runEnd, row)
|
||||
&& blockTop(terrain.height(runEnd, row)) == top) {
|
||||
runEnd++;
|
||||
}
|
||||
add(Facing.TOP, column, row, top, runEnd - column, 1);
|
||||
column = runEnd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addSides(Facing side) {
|
||||
for (int lane = 0; lane < lanes(side); lane++) {
|
||||
int sideLane = lane;
|
||||
addSideRuns(side, lane, step -> sideBottom(side, sideLane, step));
|
||||
}
|
||||
}
|
||||
|
||||
// A hole only ends on the lines between cells, so walls only stand along those inside the terrain.
|
||||
private void addHoleWalls(Facing side) {
|
||||
if (side == Facing.TOP) {
|
||||
return;
|
||||
}
|
||||
int towardNeighbour = side.runsAlongX() ? side.normalZ : side.normalX;
|
||||
for (int lane = 0; lane < lanes(side); lane++) {
|
||||
int neighbourLane = lane + towardNeighbour;
|
||||
boolean facesNextCell = lane / TerrainHole.CELL_COLUMNS != neighbourLane / TerrainHole.CELL_COLUMNS;
|
||||
if (neighbourLane >= 0 && neighbourLane < lanes(side) && facesNextCell) {
|
||||
addSideRuns(side, lane, step -> floorY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int lanes(Facing side) {
|
||||
return side.runsAlongX() ? terrain.rows() : terrain.columns();
|
||||
}
|
||||
|
||||
private void addSideRuns(Facing side, int lane, IntUnaryOperator bottomAt) {
|
||||
int steps = side.runsAlongX() ? terrain.columns() : terrain.rows();
|
||||
int step = 0;
|
||||
while (step < steps) {
|
||||
int top = sideTop(side, lane, step);
|
||||
int bottom = bottomAt.applyAsInt(step);
|
||||
if (bottom >= top) {
|
||||
step++;
|
||||
continue;
|
||||
}
|
||||
int runEnd = step + 1;
|
||||
while (runEnd < steps && !startsCell(runEnd) && sideTop(side, lane, runEnd) == top && bottomAt.applyAsInt(runEnd) == bottom) {
|
||||
runEnd++;
|
||||
}
|
||||
add(side, side.column(lane, step), side.row(lane, step), bottom, runEnd - step, top - bottom);
|
||||
step = runEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// No column means no side, which the caller sees as a side with no height.
|
||||
private int sideTop(Facing side, int lane, int step) {
|
||||
int column = side.column(lane, step);
|
||||
int row = side.row(lane, step);
|
||||
return hasColumn(column, row) ? blockTop(terrain.height(column, row)) : Integer.MIN_VALUE;
|
||||
}
|
||||
|
||||
// Columns on the edge, or next to a gap, show their side all the way down to the floor.
|
||||
private int sideBottom(Facing side, int lane, int step) {
|
||||
int column = side.column(lane, step) + side.normalX;
|
||||
int row = side.row(lane, step) + side.normalZ;
|
||||
return hasColumn(column, row) ? blockTop(terrain.height(column, row)) : floorY;
|
||||
}
|
||||
|
||||
private void add(Facing faceFacing, int column, int row, int bottom, int runColumns, int reach) {
|
||||
if (count == facing.length) {
|
||||
grow();
|
||||
}
|
||||
firstColumn[count] = column;
|
||||
firstRow[count] = row;
|
||||
bottomY[count] = bottom;
|
||||
columns[count] = runColumns;
|
||||
height[count] = reach;
|
||||
facing[count] = (byte) faceFacing.ordinal();
|
||||
count++;
|
||||
}
|
||||
|
||||
private void grow() {
|
||||
resizeTo(facing.length * 2);
|
||||
}
|
||||
|
||||
// Large terrain keeps millions of faces, so the room left over from growing would be a lot of memory.
|
||||
private void trimToCount() {
|
||||
resizeTo(count);
|
||||
}
|
||||
|
||||
private void resizeTo(int capacity) {
|
||||
firstColumn = Arrays.copyOf(firstColumn, capacity);
|
||||
firstRow = Arrays.copyOf(firstRow, capacity);
|
||||
bottomY = Arrays.copyOf(bottomY, capacity);
|
||||
columns = Arrays.copyOf(columns, capacity);
|
||||
height = Arrays.copyOf(height, capacity);
|
||||
facing = Arrays.copyOf(facing, capacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which way a face looks. Tops and north and south sides run along X; east and west sides run along Z.
|
||||
*/
|
||||
enum Facing {
|
||||
TOP(0, 1, 0),
|
||||
NORTH(0, 0, -1),
|
||||
SOUTH(0, 0, 1),
|
||||
WEST(-1, 0, 0),
|
||||
EAST(1, 0, 0);
|
||||
|
||||
final int normalX;
|
||||
final int normalY;
|
||||
final int normalZ;
|
||||
|
||||
Facing(int normalX, int normalY, int normalZ) {
|
||||
this.normalX = normalX;
|
||||
this.normalY = normalY;
|
||||
this.normalZ = normalZ;
|
||||
}
|
||||
|
||||
boolean runsAlongX() {
|
||||
return normalX == 0;
|
||||
}
|
||||
|
||||
// A lane is a row for faces running along X and a column for the others; steps go along the lane.
|
||||
int column(int lane, int step) {
|
||||
return runsAlongX() ? step : lane;
|
||||
}
|
||||
|
||||
int row(int lane, int step) {
|
||||
return runsAlongX() ? lane : step;
|
||||
}
|
||||
}
|
||||
|
||||
// Surface faces show unless their column is in a hole; hole walls only beside one.
|
||||
enum Kind {
|
||||
SURFACE,
|
||||
HOLE_WALL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.awt.Color;
|
||||
|
||||
/**
|
||||
* Colours noise that is already a height in blocks, spreading a palette over the heights it can reach.
|
||||
*/
|
||||
record BlockHeightColorScale(NoisePalette palette, double minimum, double maximum) implements NoiseColorScale {
|
||||
@Override
|
||||
public int colorFinite(double value) {
|
||||
double span = maximum - minimum;
|
||||
return palette.colorNormalized(span <= 0D ? 0.5D : (value - minimum) / span);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int colorInvalid() {
|
||||
return palette.colorInvalid();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Color displayColorNormalized(double normalized) {
|
||||
return palette.displayColorNormalized(normalized);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return "Blocks";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.BlockTextures.BlockTexture;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* A block as the object viewer draws it: the textured faces of its model, in block units, already turned the way the
|
||||
* blockstate faces.
|
||||
*
|
||||
* @param faces every face of every box in the model
|
||||
* @param coveredSides the block's sides that a solid face fills completely, which hide whatever touches them
|
||||
* @param hidesSameBlockSides whether the model fills the whole block, so like glass it hides the sides between two of
|
||||
* the same blocks
|
||||
*/
|
||||
record BlockModel(List<Face> faces, Set<CubeFace> coveredSides, boolean hidesSameBlockSides) {
|
||||
static final BlockModel EMPTY = new BlockModel(List.of(), Set.of(), false);
|
||||
private static final int SIDE_CELLS = 16;
|
||||
private static final double ON_SIDE_TOLERANCE = 1e-4D;
|
||||
|
||||
static BlockModel of(List<Face> faces) {
|
||||
Set<CubeFace> coveredSides = EnumSet.noneOf(CubeFace.class);
|
||||
boolean fillsEverySide = true;
|
||||
for (CubeFace side : CubeFace.values()) {
|
||||
if (sideIsFilled(faces, side, true)) {
|
||||
coveredSides.add(side);
|
||||
}
|
||||
fillsEverySide &= sideIsFilled(faces, side, false);
|
||||
}
|
||||
return new BlockModel(List.copyOf(faces), Set.copyOf(coveredSides), fillsEverySide);
|
||||
}
|
||||
|
||||
static BlockModel solidColor(int color) {
|
||||
BlockTexture texture = BlockTexture.solid(color);
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (CubeFace side : CubeFace.values()) {
|
||||
faces.add(Face.boxSide(Vec3.ZERO, new Vec3(1D, 1D, 1D), side, texture, side));
|
||||
}
|
||||
return of(faces);
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return faces.isEmpty();
|
||||
}
|
||||
|
||||
BlockModel withTextures(UnaryOperator<BlockTexture> change) {
|
||||
Map<BlockTexture, BlockTexture> changed = new IdentityHashMap<>();
|
||||
List<Face> changedFaces = new ArrayList<>(faces.size());
|
||||
for (Face face : faces) {
|
||||
changedFaces.add(face.withTexture(changed.computeIfAbsent(face.texture(), change)));
|
||||
}
|
||||
return new BlockModel(List.copyOf(changedFaces), coveredSides, hidesSameBlockSides);
|
||||
}
|
||||
|
||||
// The side is split into a grid, and every cell has to lie under a face pressed flat against it.
|
||||
private static boolean sideIsFilled(List<Face> faces, CubeFace side, boolean onlySolidFaces) {
|
||||
boolean[] filled = new boolean[SIDE_CELLS * SIDE_CELLS];
|
||||
for (Face face : faces) {
|
||||
if (face.liesOn(side) && (!onlySolidFaces || face.texture().isFullyOpaque())) {
|
||||
face.fillCells(side, filled, SIDE_CELLS);
|
||||
}
|
||||
}
|
||||
for (boolean cell : filled) {
|
||||
if (!cell) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parallelogram from {@code origin} along {@code edgeU} and {@code edgeV}. The texture point at the origin and
|
||||
* how it moves along each edge are given as fractions of the texture.
|
||||
*
|
||||
* @param normal the way the face looks out; it is hidden when seen from behind
|
||||
* @param cullFace the block side whose neighbour hides this face when it covers that side, or null
|
||||
*/
|
||||
record Face(Vec3 origin, Vec3 edgeU, Vec3 edgeV, Vec3 normal, BlockTexture texture, TextureMapping mapping,
|
||||
double shade, CubeFace cullFace) {
|
||||
|
||||
// Laid out as seen from outside: u to the right and v downwards, matching Minecraft's UVs for a whole texture.
|
||||
static Face boxSide(Vec3 from, Vec3 to, CubeFace side, BlockTexture texture, CubeFace cullFace) {
|
||||
Vec3[] corners = BoxCorners.of(from, to, side);
|
||||
return new Face(corners[0], corners[3].minus(corners[0]), corners[1].minus(corners[0]), side.direction(),
|
||||
texture, TextureMapping.WHOLE, side.shade(), cullFace);
|
||||
}
|
||||
|
||||
Face withTexture(BlockTexture changedTexture) {
|
||||
return new Face(origin, edgeU, edgeV, normal, changedTexture, mapping, shade, cullFace);
|
||||
}
|
||||
|
||||
Face transformed(Transform transform) {
|
||||
Vec3 turnedNormal = transform.direction(normal);
|
||||
double length = Math.sqrt(turnedNormal.x() * turnedNormal.x() + turnedNormal.y() * turnedNormal.y() + turnedNormal.z() * turnedNormal.z());
|
||||
return new Face(transform.point(origin), transform.direction(edgeU), transform.direction(edgeV),
|
||||
length == 0D ? turnedNormal : turnedNormal.times(1D / length), texture, mapping, shade,
|
||||
cullFace == null ? null : CubeFace.nearest(transform.direction(cullFace.direction())));
|
||||
}
|
||||
|
||||
Face withCullFace(CubeFace side) {
|
||||
return new Face(origin, edgeU, edgeV, normal, texture, mapping, shade, side);
|
||||
}
|
||||
|
||||
Face withShade(double changedShade) {
|
||||
return new Face(origin, edgeU, edgeV, normal, texture, mapping, changedShade, cullFace);
|
||||
}
|
||||
|
||||
private boolean liesOn(CubeFace side) {
|
||||
int axis = side.axis();
|
||||
double plane = side.pointsPositive() ? 1D : 0D;
|
||||
return CubeFace.nearest(normal) == side
|
||||
&& Math.abs(normal.component(axis)) > 1D - ON_SIDE_TOLERANCE
|
||||
&& Math.abs(origin.component(axis) - plane) < ON_SIDE_TOLERANCE
|
||||
&& Math.abs(edgeU.component(axis)) < ON_SIDE_TOLERANCE
|
||||
&& Math.abs(edgeV.component(axis)) < ON_SIDE_TOLERANCE;
|
||||
}
|
||||
|
||||
private void fillCells(CubeFace side, boolean[] filled, int cells) {
|
||||
int firstAxis = side.axis() == 0 ? 1 : 0;
|
||||
int secondAxis = side.axis() == 2 ? 1 : 2;
|
||||
double[] firstRange = range(firstAxis);
|
||||
double[] secondRange = range(secondAxis);
|
||||
for (int first = 0; first < cells; first++) {
|
||||
double firstCenter = (first + 0.5D) / cells;
|
||||
if (firstCenter < firstRange[0] || firstCenter > firstRange[1]) {
|
||||
continue;
|
||||
}
|
||||
for (int second = 0; second < cells; second++) {
|
||||
double secondCenter = (second + 0.5D) / cells;
|
||||
if (secondCenter >= secondRange[0] && secondCenter <= secondRange[1]) {
|
||||
filled[first * cells + second] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double[] range(int axis) {
|
||||
double start = origin.component(axis);
|
||||
double alongU = edgeU.component(axis);
|
||||
double alongV = edgeV.component(axis);
|
||||
double low = start + Math.min(0D, alongU) + Math.min(0D, alongV);
|
||||
double high = start + Math.max(0D, alongU) + Math.max(0D, alongV);
|
||||
return new double[]{low, high};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a face's corner sits in its texture and how the texture point moves along the face's two edges, all as
|
||||
* fractions of the texture's width and height.
|
||||
*/
|
||||
record TextureMapping(double u, double v, double uAlongU, double vAlongU, double uAlongV, double vAlongV) {
|
||||
static final TextureMapping WHOLE = new TextureMapping(0D, 0D, 1D, 0D, 0D, 1D);
|
||||
}
|
||||
|
||||
/**
|
||||
* A box side's corners in Minecraft's order: top left, bottom left, bottom right and top right, as seen from
|
||||
* outside the box.
|
||||
*/
|
||||
static final class BoxCorners {
|
||||
private BoxCorners() {
|
||||
}
|
||||
|
||||
static Vec3[] of(Vec3 from, Vec3 to, CubeFace side) {
|
||||
double minX = from.x();
|
||||
double minY = from.y();
|
||||
double minZ = from.z();
|
||||
double maxX = to.x();
|
||||
double maxY = to.y();
|
||||
double maxZ = to.z();
|
||||
return switch (side) {
|
||||
case DOWN -> corners(minX, minY, maxZ, minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ);
|
||||
case UP -> corners(minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ);
|
||||
case NORTH -> corners(maxX, maxY, minZ, maxX, minY, minZ, minX, minY, minZ, minX, maxY, minZ);
|
||||
case SOUTH -> corners(minX, maxY, maxZ, minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ);
|
||||
case WEST -> corners(minX, maxY, minZ, minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ);
|
||||
case EAST -> corners(maxX, maxY, maxZ, maxX, minY, maxZ, maxX, minY, minZ, maxX, maxY, minZ);
|
||||
};
|
||||
}
|
||||
|
||||
private static Vec3[] corners(double... coordinates) {
|
||||
Vec3[] corners = new Vec3[4];
|
||||
for (int corner = 0; corner < 4; corner++) {
|
||||
corners[corner] = new Vec3(coordinates[corner * 3], coordinates[corner * 3 + 1], coordinates[corner * 3 + 2]);
|
||||
}
|
||||
return corners;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import art.arcane.iris.studio.map.BlockModel.BoxCorners;
|
||||
import art.arcane.iris.studio.map.BlockModel.Face;
|
||||
import art.arcane.iris.studio.map.BlockModel.TextureMapping;
|
||||
import art.arcane.iris.studio.map.BlockTextures.BlockStateId;
|
||||
import art.arcane.iris.studio.map.BlockTextures.BlockTexture;
|
||||
import art.arcane.iris.studio.map.BlockTextures.ModelDefinition;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reads a block's look the way Minecraft does: every model part the blockstate picks, every box with its own texture
|
||||
* and UVs, tilted boxes, the blockstate's turn with or without locked textures, and which faces a neighbour hides.
|
||||
*/
|
||||
final class BlockModelBuilder {
|
||||
private static final double MODEL_UNITS = 16D;
|
||||
private static final double BLOCK_CENTER = 0.5D;
|
||||
private static final int QUARTER_TURN = 90;
|
||||
|
||||
private final BlockTextures textures;
|
||||
private final EntityBlockShapes entityShapes;
|
||||
|
||||
BlockModelBuilder(BlockTextures textures) {
|
||||
this.textures = textures;
|
||||
this.entityShapes = new EntityBlockShapes(textures);
|
||||
}
|
||||
|
||||
Optional<BlockModel> build(String blockState) {
|
||||
try {
|
||||
BlockStateId state = BlockStateId.parse(blockState);
|
||||
List<JsonObject> placements = placementsOf(state);
|
||||
if (placements == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (JsonObject placement : placements) {
|
||||
faces.addAll(placedFaces(state, placement));
|
||||
}
|
||||
faces.addAll(entityShapes.facesAddedTo(state));
|
||||
return Optional.of(BlockModel.of(faces));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A box with Minecraft's default UVs on each side, for shapes the game draws in code.
|
||||
*/
|
||||
static List<Face> box(Vec3 fromPixels, Vec3 toPixels, BlockTexture texture, boolean culledBySides) {
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (CubeFace side : CubeFace.values()) {
|
||||
FaceUv uv = FaceUv.byDefault(side, fromPixels, toPixels);
|
||||
Face face = boxFace(fromPixels, toPixels, side, texture, uv).withShade(side.shade());
|
||||
faces.add(culledBySides ? face.withCullFace(side) : face);
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
// A variant names one model; a multipart blockstate, like a fence's, adds a part for every condition that holds.
|
||||
private List<JsonObject> placementsOf(BlockStateId state) throws IOException {
|
||||
JsonObject definition = textures.readJson(BlockTextures.assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
|
||||
if (definition == null) {
|
||||
return null;
|
||||
}
|
||||
List<JsonObject> placements = new ArrayList<>();
|
||||
if (definition.has("variants")) {
|
||||
for (Map.Entry<String, JsonElement> variant : definition.getAsJsonObject("variants").entrySet()) {
|
||||
if (BlockTextures.variantMatches(variant.getKey(), state.properties())) {
|
||||
addPlacement(placements, variant.getValue());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (definition.has("multipart")) {
|
||||
for (JsonElement part : definition.getAsJsonArray("multipart")) {
|
||||
JsonObject rule = part.getAsJsonObject();
|
||||
if (!rule.has("when") || BlockTextures.conditionMatches(rule.getAsJsonObject("when"), state.properties())) {
|
||||
addPlacement(placements, rule.get("apply"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return placements;
|
||||
}
|
||||
|
||||
private static void addPlacement(List<JsonObject> placements, JsonElement models) {
|
||||
JsonObject first = BlockTextures.firstModel(models);
|
||||
if (first != null && first.has("model")) {
|
||||
placements.add(first);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Face> placedFaces(BlockStateId state, JsonObject placement) throws IOException {
|
||||
ModelDefinition model = textures.readModel(placement.get("model").getAsString());
|
||||
if (model.elements() == null) {
|
||||
return entityShapes.facesOf(state, model);
|
||||
}
|
||||
BlockRotation rotation = BlockRotation.of(placement);
|
||||
List<Face> faces = new ArrayList<>();
|
||||
for (JsonElement element : model.elements()) {
|
||||
addElementFaces(faces, state, model, element.getAsJsonObject(), rotation);
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
private void addElementFaces(List<Face> faces, BlockStateId state, ModelDefinition model, JsonObject element, BlockRotation rotation) {
|
||||
JsonObject elementFaces = element.getAsJsonObject("faces");
|
||||
if (elementFaces == null) {
|
||||
return;
|
||||
}
|
||||
Vec3 from = vector(element.getAsJsonArray("from"));
|
||||
Vec3 to = vector(element.getAsJsonArray("to"));
|
||||
Transform tilt = tiltOf(element);
|
||||
boolean shaded = !element.has("shade") || element.get("shade").getAsBoolean();
|
||||
CubeFace shadeSide = element.has("shade_direction_override")
|
||||
? CubeFace.named(element.get("shade_direction_override").getAsString()) : null;
|
||||
for (Map.Entry<String, JsonElement> entry : elementFaces.entrySet()) {
|
||||
CubeFace side = CubeFace.named(entry.getKey());
|
||||
JsonObject faceJson = entry.getValue().getAsJsonObject();
|
||||
if (side == null || !faceJson.has("texture")) {
|
||||
continue;
|
||||
}
|
||||
String textureName = BlockTextures.dereference(faceJson.get("texture").getAsString(), model.textures());
|
||||
if (textureName == null) {
|
||||
continue;
|
||||
}
|
||||
Optional<BlockTexture> texture = textures.faceTexture(state, textureName, BlockTextures.tintIndexOf(faceJson));
|
||||
if (texture.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
FaceUv uv = FaceUv.of(faceJson, side, from, to);
|
||||
if (rotation.locksTextures()) {
|
||||
uv = uv.lockedTo(side, rotation);
|
||||
}
|
||||
Face face = boxFace(from, to, side, texture.get(), uv).transformed(tilt);
|
||||
if (faceJson.has("cullface")) {
|
||||
face = face.withCullFace(CubeFace.named(faceJson.get("cullface").getAsString()));
|
||||
}
|
||||
face = face.transformed(rotation.transform());
|
||||
faces.add(face.withShade(shadeOf(face, shaded, shadeSide, rotation)));
|
||||
}
|
||||
}
|
||||
|
||||
private static Face boxFace(Vec3 fromPixels, Vec3 toPixels, CubeFace side, BlockTexture texture, FaceUv uv) {
|
||||
Vec3[] corners = BoxCorners.of(fromPixels.times(1D / MODEL_UNITS), toPixels.times(1D / MODEL_UNITS), side);
|
||||
return new Face(corners[0], corners[3].minus(corners[0]), corners[1].minus(corners[0]), side.direction(),
|
||||
texture, uv.mapping(), side.shade(), null);
|
||||
}
|
||||
|
||||
private static double shadeOf(Face face, boolean shaded, CubeFace shadeSide, BlockRotation rotation) {
|
||||
if (!shaded) {
|
||||
return CubeFace.UP.shade();
|
||||
}
|
||||
if (shadeSide != null) {
|
||||
return CubeFace.nearest(rotation.transform().direction(shadeSide.direction())).shade();
|
||||
}
|
||||
return CubeFace.nearest(face.normal()).shade();
|
||||
}
|
||||
|
||||
// A box can be tilted around a point, on one axis or, since 1.21.?, on all three; rescaling stretches a tilted
|
||||
// plant back to the full block width.
|
||||
private static Transform tiltOf(JsonObject element) {
|
||||
if (!element.has("rotation")) {
|
||||
return Transform.IDENTITY;
|
||||
}
|
||||
JsonObject rotation = element.getAsJsonObject("rotation");
|
||||
Vec3 origin = rotation.has("origin") ? vector(rotation.getAsJsonArray("origin")).times(1D / MODEL_UNITS) : new Vec3(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER);
|
||||
Transform around = Transform.IDENTITY.translate(origin.x(), origin.y(), origin.z());
|
||||
Transform back = Transform.IDENTITY.translate(-origin.x(), -origin.y(), -origin.z());
|
||||
if (!rotation.has("axis")) {
|
||||
return around
|
||||
.rotateX(numberOr(rotation, "x", 0D))
|
||||
.rotateY(numberOr(rotation, "y", 0D))
|
||||
.rotateZ(numberOr(rotation, "z", 0D))
|
||||
.then(back);
|
||||
}
|
||||
String axis = rotation.get("axis").getAsString();
|
||||
double angle = numberOr(rotation, "angle", 0D);
|
||||
boolean rescale = rotation.has("rescale") && rotation.get("rescale").getAsBoolean();
|
||||
double stretch = rescale ? 1D / Math.cos(Math.toRadians(angle)) : 1D;
|
||||
Transform stretched = around.scale(axis.equals("x") ? 1D : stretch, axis.equals("y") ? 1D : stretch, axis.equals("z") ? 1D : stretch);
|
||||
Transform tilted = switch (axis) {
|
||||
case "x" -> stretched.rotateX(angle);
|
||||
case "y" -> stretched.rotateY(angle);
|
||||
default -> stretched.rotateZ(angle);
|
||||
};
|
||||
return tilted.then(back);
|
||||
}
|
||||
|
||||
private static double numberOr(JsonObject object, String key, double fallback) {
|
||||
return object.has(key) ? object.get(key).getAsDouble() : fallback;
|
||||
}
|
||||
|
||||
private static Vec3 vector(JsonArray values) {
|
||||
return new Vec3(values.get(0).getAsDouble(), values.get(1).getAsDouble(), values.get(2).getAsDouble());
|
||||
}
|
||||
|
||||
/**
|
||||
* How a blockstate turns its model: around the x axis first, then the y axis, both in quarter turns against the
|
||||
* right-hand rule. With uvlock the textures stay put while the shape turns.
|
||||
*/
|
||||
private record BlockRotation(int x, int y, boolean uvlock) {
|
||||
static BlockRotation of(JsonObject placement) {
|
||||
int x = placement.has("x") ? Math.floorMod(placement.get("x").getAsInt(), 360) : 0;
|
||||
int y = placement.has("y") ? Math.floorMod(placement.get("y").getAsInt(), 360) : 0;
|
||||
return new BlockRotation(x, y, placement.has("uvlock") && placement.get("uvlock").getAsBoolean());
|
||||
}
|
||||
|
||||
boolean locksTextures() {
|
||||
return uvlock && (x != 0 || y != 0);
|
||||
}
|
||||
|
||||
Transform transform() {
|
||||
return Transform.IDENTITY
|
||||
.translate(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER)
|
||||
.then(turn())
|
||||
.translate(-BLOCK_CENTER, -BLOCK_CENTER, -BLOCK_CENTER);
|
||||
}
|
||||
|
||||
Transform turn() {
|
||||
return Transform.IDENTITY.rotateY(-y).rotateX(-x);
|
||||
}
|
||||
|
||||
Transform inverseTurn() {
|
||||
return Transform.IDENTITY.rotateX(x).rotateY(y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A face's corner UVs in model pixels and the quarter turn of the texture on it.
|
||||
*/
|
||||
private record FaceUv(double u1, double v1, double u2, double v2, int rotation) {
|
||||
static FaceUv of(JsonObject face, CubeFace side, Vec3 from, Vec3 to) {
|
||||
int rotation = face.has("rotation") ? Math.floorMod(face.get("rotation").getAsInt(), 360) : 0;
|
||||
if (!face.has("uv")) {
|
||||
return byDefault(side, from, to).withRotation(rotation);
|
||||
}
|
||||
JsonArray uv = face.getAsJsonArray("uv");
|
||||
return new FaceUv(uv.get(0).getAsDouble(), uv.get(1).getAsDouble(), uv.get(2).getAsDouble(), uv.get(3).getAsDouble(), rotation);
|
||||
}
|
||||
|
||||
// The part of the texture the face would cover if the texture were laid over the whole block side.
|
||||
static FaceUv byDefault(CubeFace side, Vec3 from, Vec3 to) {
|
||||
double full = MODEL_UNITS;
|
||||
return switch (side) {
|
||||
case DOWN -> new FaceUv(from.x(), full - to.z(), to.x(), full - from.z(), 0);
|
||||
case UP -> new FaceUv(from.x(), from.z(), to.x(), to.z(), 0);
|
||||
case NORTH -> new FaceUv(full - to.x(), full - to.y(), full - from.x(), full - from.y(), 0);
|
||||
case SOUTH -> new FaceUv(from.x(), full - to.y(), to.x(), full - from.y(), 0);
|
||||
case WEST -> new FaceUv(from.z(), full - to.y(), to.z(), full - from.y(), 0);
|
||||
case EAST -> new FaceUv(full - to.z(), full - to.y(), full - from.z(), full - from.y(), 0);
|
||||
};
|
||||
}
|
||||
|
||||
FaceUv withRotation(int changedRotation) {
|
||||
return new FaceUv(u1, v1, u2, v2, changedRotation);
|
||||
}
|
||||
|
||||
// Each corner takes the UV a quarter turn further along for every quarter turn of the texture.
|
||||
TextureMapping mapping() {
|
||||
double[] origin = cornerUv(0);
|
||||
double[] alongU = cornerUv(3);
|
||||
double[] alongV = cornerUv(1);
|
||||
return new TextureMapping(origin[0] / MODEL_UNITS, origin[1] / MODEL_UNITS,
|
||||
(alongU[0] - origin[0]) / MODEL_UNITS, (alongU[1] - origin[1]) / MODEL_UNITS,
|
||||
(alongV[0] - origin[0]) / MODEL_UNITS, (alongV[1] - origin[1]) / MODEL_UNITS);
|
||||
}
|
||||
|
||||
private double[] cornerUv(int corner) {
|
||||
return switch ((corner + rotation / QUARTER_TURN) % 4) {
|
||||
case 0 -> new double[]{u1, v1};
|
||||
case 1 -> new double[]{u1, v2};
|
||||
case 2 -> new double[]{u2, v2};
|
||||
default -> new double[]{u2, v1};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minecraft's uvlock: the UVs are carried through the reverse of the blockstate's turn, so the texture keeps
|
||||
* its place in the world while the face moves to another side.
|
||||
*/
|
||||
FaceUv lockedTo(CubeFace side, BlockRotation blockRotation) {
|
||||
CubeFace turnedSide = CubeFace.nearest(blockRotation.turn().direction(side.direction()));
|
||||
Transform uvTurn = Transform.IDENTITY
|
||||
.translate(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER)
|
||||
.then(faceFromWorld(side))
|
||||
.then(blockRotation.inverseTurn())
|
||||
.then(worldFromFace(turnedSide))
|
||||
.translate(-BLOCK_CENTER, -BLOCK_CENTER, -BLOCK_CENTER);
|
||||
Vec3 first = uvTurn.point(new Vec3(u1 / MODEL_UNITS, v1 / MODEL_UNITS, 0D)).times(MODEL_UNITS);
|
||||
Vec3 second = uvTurn.point(new Vec3(u2 / MODEL_UNITS, v2 / MODEL_UNITS, 0D)).times(MODEL_UNITS);
|
||||
boolean keepsUOrder = Math.signum(u2 - u1) == Math.signum(second.x() - first.x());
|
||||
boolean keepsVOrder = Math.signum(v2 - v1) == Math.signum(second.y() - first.y());
|
||||
Vec3 textureRight = uvTurn.direction(new Vec3(Math.cos(Math.toRadians(rotation)), Math.sin(Math.toRadians(rotation)), 0D));
|
||||
long quarterTurns = Math.round(Math.toDegrees(Math.atan2(textureRight.y(), textureRight.x())) / QUARTER_TURN);
|
||||
return new FaceUv(
|
||||
keepsUOrder ? first.x() : second.x(), keepsVOrder ? first.y() : second.y(),
|
||||
keepsUOrder ? second.x() : first.x(), keepsVOrder ? second.y() : first.y(),
|
||||
(int) Math.floorMod(-quarterTurns * QUARTER_TURN, 360L));
|
||||
}
|
||||
|
||||
// Minecraft lays every side's texture out as if it were the south side, turned into place.
|
||||
private static Transform worldFromFace(CubeFace side) {
|
||||
return switch (side) {
|
||||
case SOUTH -> Transform.IDENTITY;
|
||||
case EAST -> Transform.IDENTITY.rotateY(90D);
|
||||
case WEST -> Transform.IDENTITY.rotateY(-90D);
|
||||
case NORTH -> Transform.IDENTITY.rotateY(180D);
|
||||
case UP -> Transform.IDENTITY.rotateX(-90D);
|
||||
case DOWN -> Transform.IDENTITY.rotateX(90D);
|
||||
};
|
||||
}
|
||||
|
||||
private static Transform faceFromWorld(CubeFace side) {
|
||||
return switch (side) {
|
||||
case SOUTH -> Transform.IDENTITY;
|
||||
case EAST -> Transform.IDENTITY.rotateY(-90D);
|
||||
case WEST -> Transform.IDENTITY.rotateY(90D);
|
||||
case NORTH -> Transform.IDENTITY.rotateY(180D);
|
||||
case UP -> Transform.IDENTITY.rotateX(90D);
|
||||
case DOWN -> Transform.IDENTITY.rotateX(-90D);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.generation.decoration.IrisProceduralBlocks;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
|
||||
final class BlockNames {
|
||||
private BlockNames() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the block's type without namespace or properties, like {@code oak_log}
|
||||
*/
|
||||
static String typeOf(NativeBlockState block) {
|
||||
String material = IrisProceduralBlocks.materialKey(block);
|
||||
return material.substring(material.indexOf(':') + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
// Colours for blocks without a texture, such as when the client jar can't be read.
|
||||
final class BlockPreviewColors {
|
||||
private static final double[] MAP_SHADES = {1D, 0.86D, 0.71D, 0.53D};
|
||||
|
||||
private BlockPreviewColors() {
|
||||
}
|
||||
|
||||
static Map<String, Integer> colorsByBlock(List<NativeBlockState> blocks) {
|
||||
Map<String, Integer> colors = new HashMap<>();
|
||||
List<Integer> usedColors = new ArrayList<>();
|
||||
for (NativeBlockState block : blocks) {
|
||||
String key = block.key();
|
||||
if (colors.containsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
int baseColor = colorFromName(BlockNames.typeOf(block));
|
||||
int color = firstUnusedShade(baseColor, usedColors);
|
||||
usedColors.add(color);
|
||||
colors.put(key, color);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
// Blocks that share a map colour, like sand and sandstone, get darker shades the way Minecraft maps shade them.
|
||||
private static int firstUnusedShade(int baseColor, List<Integer> usedColors) {
|
||||
for (double shade : MAP_SHADES) {
|
||||
int shaded = shade(baseColor, shade);
|
||||
if (!usedColors.contains(shaded)) {
|
||||
return shaded;
|
||||
}
|
||||
}
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
private static int colorFromName(String name) {
|
||||
float hue = (name.hashCode() & 0xFFFF) / (float) 0xFFFF;
|
||||
return Color.HSBtoRGB(hue, 0.45F, 0.75F) & 0xFFFFFF;
|
||||
}
|
||||
|
||||
static int shade(int color, double shade) {
|
||||
int red = (int) Math.min(255, ((color >>> 16) & 0xFF) * shade);
|
||||
int green = (int) Math.min(255, ((color >>> 8) & 0xFF) * shade);
|
||||
int blue = (int) Math.min(255, (color & 0xFF) * shade);
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import art.arcane.iris.generation.decoration.IrisProceduralBlocks;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
final class BlockTextures {
|
||||
private static final String DEFAULT_NAMESPACE = "minecraft";
|
||||
private static final int MAX_MODEL_PARENTS = 16;
|
||||
private static final int MAX_TEXTURE_REFERENCES = 16;
|
||||
static final int NO_TINT_INDEX = -1;
|
||||
private static final Object SHARED_LOCK = new Object();
|
||||
private static CompletableFuture<BlockTextures> shared;
|
||||
|
||||
private final ZipFile clientJar;
|
||||
private final MinecraftBiomeColors biomeColors;
|
||||
private final Map<String, Optional<ResolvedTexture>> resolvedTextures = new ConcurrentHashMap<>();
|
||||
private final Map<String, Optional<BlockModel>> resolvedModels = new ConcurrentHashMap<>();
|
||||
private final Map<String, Optional<BlockTexture>> faceTextures = new ConcurrentHashMap<>();
|
||||
private final BlockModelBuilder modelBuilder = new BlockModelBuilder(this);
|
||||
|
||||
private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) {
|
||||
this.clientJar = clientJar;
|
||||
this.biomeColors = biomeColors;
|
||||
}
|
||||
|
||||
static CompletableFuture<BlockTextures> shared() {
|
||||
synchronized (SHARED_LOCK) {
|
||||
if (shared == null || shared.isCompletedExceptionally()) {
|
||||
shared = MinecraftClientJar.open().thenApply(BlockTextures::load);
|
||||
}
|
||||
return shared;
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockTextures load(ZipFile clientJar) {
|
||||
try {
|
||||
return new BlockTextures(clientJar, MinecraftBiomeColors.load(clientJar));
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException("Could not read Minecraft's biome colour maps", error);
|
||||
}
|
||||
}
|
||||
|
||||
MinecraftBiomeColors biomeColors() {
|
||||
return biomeColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the block looks like from above. Plants and other side-on sprites become their average colour, because
|
||||
* their sprite would read as stripes across a stack of ground blocks.
|
||||
*/
|
||||
Optional<BlockTexture> topTexture(NativeBlockState block) {
|
||||
return resolve(block).map(resolved -> resolved.isSideOnSprite() ? resolved.texture().flattened() : resolved.texture());
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #topTexture}, but plants keep their sprite, so single decorations on the ground stay recognisable.
|
||||
* Two block tall plants give their bottom half, the part standing on the ground.
|
||||
*/
|
||||
Optional<BlockTexture> iconTexture(NativeBlockState block) {
|
||||
return resolve(withHalf(block, BlockHalf.LOWER).orElse(block)).map(ResolvedTexture::texture);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sprite of a two block tall plant's top half, or nothing for any other block.
|
||||
*/
|
||||
Optional<BlockTexture> upperHalfIcon(NativeBlockState block) {
|
||||
return withHalf(block, BlockHalf.UPPER).flatMap(this::resolve)
|
||||
.filter(ResolvedTexture::isSideOnSprite).map(ResolvedTexture::texture);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the block looks like from every side, for drawing it in 3D.
|
||||
*/
|
||||
Optional<BlockModel> model(NativeBlockState block) {
|
||||
return resolvedModels.computeIfAbsent(block.key(), modelBuilder::build);
|
||||
}
|
||||
|
||||
private static Optional<NativeBlockState> withHalf(NativeBlockState block, BlockHalf half) {
|
||||
String current = IrisProceduralBlocks.propertyValue(block, BlockHalf.PROPERTY);
|
||||
if (current == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(block.withProperty(BlockHalf.PROPERTY, half.valueLike(current)));
|
||||
}
|
||||
|
||||
// Blockstate files match on every property, so defaults the pack left out have to be spelled out too.
|
||||
private Optional<ResolvedTexture> resolve(NativeBlockState block) {
|
||||
return resolvedTextures.computeIfAbsent(block.key(), this::resolveTopTexture);
|
||||
}
|
||||
|
||||
// Two block tall blocks call their halves lower and upper; stairs and trapdoors call them bottom and top.
|
||||
private enum BlockHalf {
|
||||
LOWER("lower", "bottom"),
|
||||
UPPER("upper", "top");
|
||||
|
||||
static final String PROPERTY = "half";
|
||||
|
||||
private final String tallBlockValue;
|
||||
private final String slabLikeValue;
|
||||
|
||||
BlockHalf(String tallBlockValue, String slabLikeValue) {
|
||||
this.tallBlockValue = tallBlockValue;
|
||||
this.slabLikeValue = slabLikeValue;
|
||||
}
|
||||
|
||||
String valueLike(String currentValue) {
|
||||
boolean isTallBlock = currentValue.equals(LOWER.tallBlockValue) || currentValue.equals(UPPER.tallBlockValue);
|
||||
return isTallBlock ? tallBlockValue : slabLikeValue;
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<ResolvedTexture> resolveTopTexture(String blockState) {
|
||||
try {
|
||||
BlockStateId state = BlockStateId.parse(blockState);
|
||||
JsonObject variant = variantOf(state);
|
||||
if (variant == null || !variant.has("model")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
ModelFace face = readModel(variant.get("model").getAsString()).visibleFace(faceSeenFromAbove(variant));
|
||||
if (face == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return readTexture(state, face).map(texture -> new ResolvedTexture(texture, face.isSideOnSprite()));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject variantOf(BlockStateId state) throws IOException {
|
||||
JsonObject definition = readJson(assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
|
||||
return definition == null ? null : pickVariant(definition, state.properties());
|
||||
}
|
||||
|
||||
private static int rotationOf(JsonObject variant, String axis) {
|
||||
return variant.has(axis) ? Math.floorMod(variant.get(axis).getAsInt(), 360) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* One model face's texture with the block's tint, shared by every block state that uses it.
|
||||
*/
|
||||
Optional<BlockTexture> faceTexture(BlockStateId state, String texture, int tintIndex) {
|
||||
int fixedTint = MinecraftBlockTints.fixedTint(state.name(), state.properties(), tintIndex);
|
||||
BiomeTint biomeTint = MinecraftBlockTints.biomeTint(state.name(), tintIndex).orElse(null);
|
||||
return faceTextures.computeIfAbsent(texture + "|" + fixedTint + "|" + biomeTint, key -> {
|
||||
try {
|
||||
return readTexture(state, new ModelFace(List.of(texture), tintIndex, false));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A texture drawn on a block entity, like a chest, whole and tinted with a fixed colour.
|
||||
*/
|
||||
Optional<BlockTexture> entityTexture(String texture, int tint) {
|
||||
return faceTextures.computeIfAbsent(texture + "|entity|" + tint, key -> {
|
||||
try {
|
||||
BufferedImage image = readImage(assetPath(texture, "textures", ".png"));
|
||||
return image == null ? Optional.empty() : Optional.of(BlockTexture.fromWholeImage(image, tint));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<BlockTexture> readTexture(BlockStateId state, ModelFace face) throws IOException {
|
||||
BufferedImage image = readLayeredImage(face.textures());
|
||||
if (image == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(BlockTexture.fromImage(
|
||||
image,
|
||||
MinecraftBlockTints.fixedTint(state.name(), state.properties(), face.tintIndex()),
|
||||
MinecraftBlockTints.biomeTint(state.name(), face.tintIndex()).orElse(null)
|
||||
));
|
||||
} catch (IllegalArgumentException invisibleTexture) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// A block rotated on its x axis, like a sideways log, shows a side face on top. Blockstates turn the model
|
||||
// against the right-hand rule, so 90 degrees brings the south side up.
|
||||
private static String faceSeenFromAbove(JsonObject variant) {
|
||||
return switch (rotationOf(variant, "x")) {
|
||||
case 90 -> "south";
|
||||
case 270 -> "north";
|
||||
case 180 -> "down";
|
||||
default -> "up";
|
||||
};
|
||||
}
|
||||
|
||||
ModelDefinition readModel(String modelReference) throws IOException {
|
||||
Map<String, String> textures = new HashMap<>();
|
||||
JsonArray elements = null;
|
||||
String current = modelReference;
|
||||
for (int depth = 0; current != null && depth < MAX_MODEL_PARENTS; depth++) {
|
||||
JsonObject model = readJson(assetPath(current, "models", ".json"));
|
||||
if (model == null) {
|
||||
break;
|
||||
}
|
||||
if (model.has("textures")) {
|
||||
for (Map.Entry<String, JsonElement> texture : model.getAsJsonObject("textures").entrySet()) {
|
||||
textures.putIfAbsent(texture.getKey(), spriteName(texture.getValue()));
|
||||
}
|
||||
}
|
||||
if (elements == null && model.has("elements")) {
|
||||
elements = model.getAsJsonArray("elements");
|
||||
}
|
||||
current = model.has("parent") ? model.get("parent").getAsString() : null;
|
||||
}
|
||||
return new ModelDefinition(textures, elements);
|
||||
}
|
||||
|
||||
// Since 1.21.? a texture can also be an object naming its sprite, like glass's {"sprite": ..., "force_translucent": true}.
|
||||
private static String spriteName(JsonElement texture) {
|
||||
return texture.isJsonObject() ? texture.getAsJsonObject().get("sprite").getAsString() : texture.getAsString();
|
||||
}
|
||||
|
||||
// Some plants draw a second sprite over the first, like the glowing fireflies on a firefly bush.
|
||||
private static List<String> spriteLayers(JsonArray elements, Map<String, String> textures) {
|
||||
Set<String> layers = new LinkedHashSet<>();
|
||||
for (JsonElement element : elements) {
|
||||
JsonObject faces = element.getAsJsonObject().getAsJsonObject("faces");
|
||||
if (faces == null) {
|
||||
continue;
|
||||
}
|
||||
for (Map.Entry<String, JsonElement> face : faces.entrySet()) {
|
||||
String texture = dereference(face.getValue().getAsJsonObject().get("texture").getAsString(), textures);
|
||||
if (texture != null) {
|
||||
layers.add(texture);
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(layers);
|
||||
}
|
||||
|
||||
private static int firstTintIndex(JsonArray elements) {
|
||||
for (JsonElement element : elements) {
|
||||
JsonObject faces = element.getAsJsonObject().getAsJsonObject("faces");
|
||||
if (faces == null) {
|
||||
continue;
|
||||
}
|
||||
for (Map.Entry<String, JsonElement> face : faces.entrySet()) {
|
||||
int tintIndex = tintIndexOf(face.getValue().getAsJsonObject());
|
||||
if (tintIndex != NO_TINT_INDEX) {
|
||||
return tintIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
return NO_TINT_INDEX;
|
||||
}
|
||||
|
||||
static int tintIndexOf(JsonObject face) {
|
||||
return face.has("tintindex") ? face.get("tintindex").getAsInt() : NO_TINT_INDEX;
|
||||
}
|
||||
|
||||
private static JsonObject topmostFace(JsonArray elements, String faceName) {
|
||||
JsonObject best = null;
|
||||
double bestHeight = Double.NEGATIVE_INFINITY;
|
||||
double bestArea = -1D;
|
||||
for (JsonElement element : elements) {
|
||||
JsonObject cuboid = element.getAsJsonObject();
|
||||
JsonObject faces = cuboid.getAsJsonObject("faces");
|
||||
if (faces == null || !faces.has(faceName)) {
|
||||
continue;
|
||||
}
|
||||
JsonArray from = cuboid.getAsJsonArray("from");
|
||||
JsonArray to = cuboid.getAsJsonArray("to");
|
||||
double height = to.get(1).getAsDouble();
|
||||
double area = (to.get(0).getAsDouble() - from.get(0).getAsDouble()) * (to.get(2).getAsDouble() - from.get(2).getAsDouble());
|
||||
if (height > bestHeight || (height == bestHeight && area > bestArea)) {
|
||||
best = faces.getAsJsonObject(faceName);
|
||||
bestHeight = height;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
static String dereference(String reference, Map<String, String> textures) {
|
||||
String resolved = reference;
|
||||
for (int hop = 0; resolved != null && resolved.startsWith("#") && hop < MAX_TEXTURE_REFERENCES; hop++) {
|
||||
resolved = textures.get(resolved.substring(1));
|
||||
}
|
||||
return resolved == null || resolved.startsWith("#") ? null : resolved;
|
||||
}
|
||||
|
||||
private static JsonObject pickVariant(JsonObject definition, Map<String, String> properties) {
|
||||
if (definition.has("variants")) {
|
||||
for (Map.Entry<String, JsonElement> variant : definition.getAsJsonObject("variants").entrySet()) {
|
||||
if (variantMatches(variant.getKey(), properties)) {
|
||||
return firstModel(variant.getValue());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (definition.has("multipart")) {
|
||||
for (JsonElement part : definition.getAsJsonArray("multipart")) {
|
||||
JsonObject rule = part.getAsJsonObject();
|
||||
if (!rule.has("when") || conditionMatches(rule.getAsJsonObject("when"), properties)) {
|
||||
return firstModel(rule.get("apply"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static boolean variantMatches(String variantKey, Map<String, String> properties) {
|
||||
if (variantKey.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (String condition : variantKey.split(",")) {
|
||||
String[] pair = condition.split("=", 2);
|
||||
if (pair.length != 2 || !pair[1].equals(properties.get(pair[0]))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static boolean conditionMatches(JsonObject condition, Map<String, String> properties) {
|
||||
if (condition.has("OR")) {
|
||||
for (JsonElement option : condition.getAsJsonArray("OR")) {
|
||||
if (conditionMatches(option.getAsJsonObject(), properties)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (condition.has("AND")) {
|
||||
for (JsonElement requirement : condition.getAsJsonArray("AND")) {
|
||||
if (!conditionMatches(requirement.getAsJsonObject(), properties)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
for (Map.Entry<String, JsonElement> requirement : condition.entrySet()) {
|
||||
String actual = properties.get(requirement.getKey());
|
||||
boolean anyAllowed = false;
|
||||
for (String allowed : requirement.getValue().getAsString().split("\\|")) {
|
||||
anyAllowed |= allowed.equals(actual);
|
||||
}
|
||||
if (!anyAllowed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static JsonObject firstModel(JsonElement models) {
|
||||
if (models.isJsonArray()) {
|
||||
JsonArray choices = models.getAsJsonArray();
|
||||
return choices.isEmpty() ? null : choices.get(0).getAsJsonObject();
|
||||
}
|
||||
return models.getAsJsonObject();
|
||||
}
|
||||
|
||||
static String assetPath(String resource, String folder, String extension) {
|
||||
int separator = resource.indexOf(':');
|
||||
String namespace = separator < 0 ? DEFAULT_NAMESPACE : resource.substring(0, separator);
|
||||
String path = separator < 0 ? resource : resource.substring(separator + 1);
|
||||
return "assets/" + namespace + "/" + folder + "/" + path + extension;
|
||||
}
|
||||
|
||||
JsonObject readJson(String path) throws IOException {
|
||||
return ClientJarFiles.readJson(clientJar, path);
|
||||
}
|
||||
|
||||
// Each layer is drawn over the ones before it, using only its first frame when it is animated.
|
||||
private BufferedImage readLayeredImage(List<String> layers) throws IOException {
|
||||
BufferedImage bottom = readImage(assetPath(layers.get(0), "textures", ".png"));
|
||||
if (bottom == null || layers.size() == 1) {
|
||||
return bottom;
|
||||
}
|
||||
int size = bottom.getWidth();
|
||||
BufferedImage combined = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D canvas = combined.createGraphics();
|
||||
try {
|
||||
for (String layer : layers) {
|
||||
BufferedImage image = readImage(assetPath(layer, "textures", ".png"));
|
||||
if (image != null) {
|
||||
int frameSize = image.getWidth();
|
||||
canvas.drawImage(image, 0, 0, size, size, 0, 0, frameSize, frameSize, null);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
private BufferedImage readImage(String path) throws IOException {
|
||||
return ClientJarFiles.readImage(clientJar, path);
|
||||
}
|
||||
|
||||
// Plants and other blocks without a top face only have a side-on sprite, which would read as stripes from above.
|
||||
private record ModelFace(List<String> textures, int tintIndex, boolean isSideOnSprite) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A block model with its parents' textures merged in. Models drawn in code, like chests, have no elements.
|
||||
*/
|
||||
record ModelDefinition(Map<String, String> textures, JsonArray elements) {
|
||||
ModelFace visibleFace(String faceName) {
|
||||
ModelFace face = sideFace(faceName);
|
||||
if (face != null) {
|
||||
return face;
|
||||
}
|
||||
return elements != null && !spriteLayers(elements, textures).isEmpty() ? spriteFace() : particleFace();
|
||||
}
|
||||
|
||||
// The face's texture on the model's topmost box, the particle texture for models without boxes, or null when
|
||||
// the model leaves that side open.
|
||||
ModelFace sideFace(String faceName) {
|
||||
if (elements == null) {
|
||||
return particleFace();
|
||||
}
|
||||
JsonObject face = topmostFace(elements, faceName);
|
||||
if (face == null) {
|
||||
return null;
|
||||
}
|
||||
String texture = dereference(face.get("texture").getAsString(), textures);
|
||||
return texture == null ? null : new ModelFace(List.of(texture), tintIndexOf(face), false);
|
||||
}
|
||||
|
||||
ModelFace spriteFace() {
|
||||
return new ModelFace(spriteLayers(elements, textures), firstTintIndex(elements), true);
|
||||
}
|
||||
|
||||
private ModelFace particleFace() {
|
||||
String particle = dereference("#particle", textures);
|
||||
return particle == null ? null : new ModelFace(List.of(particle), NO_TINT_INDEX, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private record ResolvedTexture(BlockTexture texture, boolean isSideOnSprite) {
|
||||
}
|
||||
|
||||
record BlockStateId(String namespace, String name, Map<String, String> properties) {
|
||||
static BlockStateId parse(String blockState) {
|
||||
int propertiesStart = blockState.indexOf('[');
|
||||
String id = propertiesStart < 0 ? blockState : blockState.substring(0, propertiesStart);
|
||||
int separator = id.indexOf(':');
|
||||
String namespace = separator < 0 ? DEFAULT_NAMESPACE : id.substring(0, separator);
|
||||
String name = separator < 0 ? id : id.substring(separator + 1);
|
||||
Map<String, String> properties = new HashMap<>();
|
||||
if (propertiesStart >= 0) {
|
||||
String body = blockState.substring(propertiesStart + 1, blockState.length() - 1);
|
||||
for (String pair : body.split(",")) {
|
||||
String[] keyValue = pair.split("=", 2);
|
||||
if (keyValue.length == 2) {
|
||||
properties.put(keyValue[0], keyValue[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new BlockStateId(namespace, name, properties);
|
||||
}
|
||||
}
|
||||
|
||||
// Biome tinted textures keep their grey pixels, because the biome colour changes from column to column.
|
||||
// See-through pixels get a darker shade of the average colour, and the opaque mask remembers which they were.
|
||||
// Textures with partly see-through pixels, like stained glass, ice and water, keep each pixel's alpha too.
|
||||
record BlockTexture(int width, int height, int[] pixels, boolean[] opaque, int[] alphas, int averageColor,
|
||||
BiomeTint biomeTint) {
|
||||
private static final double GAP_SHADE = 0.55D;
|
||||
private static final int FULL_ALPHA = 255;
|
||||
|
||||
// Animated textures stack their frames vertically, so only the first square frame is used.
|
||||
static BlockTexture fromImage(BufferedImage image, int fixedTint, BiomeTint biomeTint) {
|
||||
return fromPixels(image, image.getWidth(), Math.min(image.getWidth(), image.getHeight()), fixedTint, biomeTint);
|
||||
}
|
||||
|
||||
// Entity textures, like a chest's, are laid out for a model and can be wider than they are tall.
|
||||
static BlockTexture fromWholeImage(BufferedImage image, int fixedTint) {
|
||||
return fromPixels(image, image.getWidth(), image.getHeight(), fixedTint, null);
|
||||
}
|
||||
|
||||
static BlockTexture solid(int color) {
|
||||
return new BlockTexture(1, 1, new int[]{color}, new boolean[]{true}, null, color, null);
|
||||
}
|
||||
|
||||
private static BlockTexture fromPixels(BufferedImage image, int width, int height, int fixedTint, BiomeTint biomeTint) {
|
||||
int[] pixels = new int[width * height];
|
||||
boolean[] opaque = new boolean[pixels.length];
|
||||
int[] alphas = new int[pixels.length];
|
||||
boolean translucent = false;
|
||||
long red = 0;
|
||||
long green = 0;
|
||||
long blue = 0;
|
||||
int opaqueCount = 0;
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int argb = image.getRGB(x, y);
|
||||
int index = y * width + x;
|
||||
int alpha = argb >>> 24;
|
||||
alphas[index] = alpha;
|
||||
if (alpha == 0) {
|
||||
continue;
|
||||
}
|
||||
translucent |= alpha < FULL_ALPHA;
|
||||
int color = multiply(argb & 0xFFFFFF, fixedTint);
|
||||
pixels[index] = color;
|
||||
opaque[index] = true;
|
||||
red += (color >>> 16) & 0xFF;
|
||||
green += (color >>> 8) & 0xFF;
|
||||
blue += color & 0xFF;
|
||||
opaqueCount++;
|
||||
}
|
||||
}
|
||||
if (opaqueCount == 0) {
|
||||
throw new IllegalArgumentException("Texture has no visible pixels");
|
||||
}
|
||||
int average = (int) (red / opaqueCount) << 16 | (int) (green / opaqueCount) << 8 | (int) (blue / opaqueCount);
|
||||
int gapColor = BlockPreviewColors.shade(average, GAP_SHADE);
|
||||
for (int index = 0; index < pixels.length; index++) {
|
||||
if (!opaque[index]) {
|
||||
pixels[index] = gapColor;
|
||||
}
|
||||
}
|
||||
return new BlockTexture(width, height, pixels, opaque, translucent ? alphas : null, average, biomeTint);
|
||||
}
|
||||
|
||||
boolean isTranslucent() {
|
||||
return alphas != null;
|
||||
}
|
||||
|
||||
boolean isFullyOpaque() {
|
||||
if (isTranslucent()) {
|
||||
return false;
|
||||
}
|
||||
for (boolean pixelOpaque : opaque) {
|
||||
if (!pixelOpaque) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
BlockTexture flattened() {
|
||||
int[] flat = new int[pixels.length];
|
||||
Arrays.fill(flat, averageColor);
|
||||
boolean[] allOpaque = new boolean[pixels.length];
|
||||
Arrays.fill(allOpaque, true);
|
||||
return new BlockTexture(width, height, flat, allOpaque, null, averageColor, biomeTint);
|
||||
}
|
||||
|
||||
BlockTexture tinted(int tint) {
|
||||
int[] tintedPixels = new int[pixels.length];
|
||||
for (int index = 0; index < pixels.length; index++) {
|
||||
tintedPixels[index] = multiply(pixels[index], tint);
|
||||
}
|
||||
return new BlockTexture(width, height, tintedPixels, opaque, alphas, multiply(averageColor, tint), null);
|
||||
}
|
||||
|
||||
int texelAt(double worldX, double worldZ) {
|
||||
return pixels[texelIndex(worldX, worldZ)];
|
||||
}
|
||||
|
||||
boolean isOpaqueAt(double worldX, double worldZ) {
|
||||
return opaque[texelIndex(worldX, worldZ)];
|
||||
}
|
||||
|
||||
private int texelIndex(double worldX, double worldZ) {
|
||||
int x = Math.min(width - 1, (int) ((worldX - Math.floor(worldX)) * width));
|
||||
int z = Math.min(height - 1, (int) ((worldZ - Math.floor(worldZ)) * height));
|
||||
return z * width + x;
|
||||
}
|
||||
|
||||
BufferedImage toImage() {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
image.setRGB(0, 0, width, height, pixels, 0, width);
|
||||
return image;
|
||||
}
|
||||
|
||||
// The GPU multiplies the texture by the tint in floating point and rounds back to 8 bits per channel.
|
||||
static int multiply(int color, int tint) {
|
||||
return multiplyChannel(color, tint, 16) << 16 | multiplyChannel(color, tint, 8) << 8 | multiplyChannel(color, tint, 0);
|
||||
}
|
||||
|
||||
private static int multiplyChannel(int color, int tint, int shift) {
|
||||
return (((color >>> shift) & 0xFF) * ((tint >>> shift) & 0xFF) + 127) / 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.color.ColorSpace;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.Raster;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
final class ClientJarFiles {
|
||||
private ClientJarFiles() {
|
||||
}
|
||||
|
||||
static JsonObject readJson(ZipFile clientJar, String path) throws IOException {
|
||||
ZipEntry entry = clientJar.getEntry(path);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
try (InputStreamReader reader = new InputStreamReader(clientJar.getInputStream(entry), StandardCharsets.UTF_8)) {
|
||||
return JsonParser.parseReader(reader).getAsJsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
static BufferedImage readImage(ZipFile clientJar, String path) throws IOException {
|
||||
ZipEntry entry = clientJar.getEntry(path);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
try (InputStream input = clientJar.getInputStream(entry)) {
|
||||
BufferedImage image = ImageIO.read(input);
|
||||
return image != null && isGrey(image) ? withGreyKept(image) : image;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isGrey(BufferedImage image) {
|
||||
return image.getColorModel().getColorSpace().getType() == ColorSpace.TYPE_GRAY;
|
||||
}
|
||||
|
||||
// Java treats grey PNGs, like stone's, as linear light and brightens them when converting to RGB; Minecraft reads
|
||||
// the stored values as they are.
|
||||
private static BufferedImage withGreyKept(BufferedImage image) {
|
||||
Raster raster = image.getRaster();
|
||||
boolean hasAlpha = raster.getNumBands() > 1;
|
||||
int greyMax = (1 << raster.getSampleModel().getSampleSize(0)) - 1;
|
||||
int alphaMax = hasAlpha ? (1 << raster.getSampleModel().getSampleSize(1)) - 1 : 1;
|
||||
BufferedImage converted = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB);
|
||||
for (int y = 0; y < image.getHeight(); y++) {
|
||||
for (int x = 0; x < image.getWidth(); x++) {
|
||||
int grey = raster.getSample(x, y, 0) * 255 / greyMax;
|
||||
int alpha = hasAlpha ? raster.getSample(x, y, 1) * 255 / alphaMax : 255;
|
||||
converted.setRGB(x, y, alpha << 24 | grey << 16 | grey << 8 | grey);
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
/**
|
||||
* A rectangle of a terrain's columns and rows, from the first up to but not including the last.
|
||||
*/
|
||||
record ColumnArea(int fromColumn, int fromRow, int toColumn, int toRow) {
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
/**
|
||||
* The six sides of a block, named as in Minecraft's model files, with the light Minecraft gives each side.
|
||||
*/
|
||||
enum CubeFace {
|
||||
UP("up", 0, 1, 0, 1.0D),
|
||||
DOWN("down", 0, -1, 0, 0.5D),
|
||||
NORTH("north", 0, 0, -1, 0.8D),
|
||||
SOUTH("south", 0, 0, 1, 0.8D),
|
||||
EAST("east", 1, 0, 0, 0.6D),
|
||||
WEST("west", -1, 0, 0, 0.6D);
|
||||
|
||||
private final String modelName;
|
||||
private final int stepX;
|
||||
private final int stepY;
|
||||
private final int stepZ;
|
||||
private final double shade;
|
||||
|
||||
CubeFace(String modelName, int stepX, int stepY, int stepZ, double shade) {
|
||||
this.modelName = modelName;
|
||||
this.stepX = stepX;
|
||||
this.stepY = stepY;
|
||||
this.stepZ = stepZ;
|
||||
this.shade = shade;
|
||||
}
|
||||
|
||||
static CubeFace named(String modelName) {
|
||||
for (CubeFace face : values()) {
|
||||
if (face.modelName.equals(modelName)) {
|
||||
return face;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The side a direction points at most, like Minecraft picks for a tilted face.
|
||||
*/
|
||||
static CubeFace nearest(Vec3 direction) {
|
||||
CubeFace nearest = UP;
|
||||
double best = Double.NEGATIVE_INFINITY;
|
||||
for (CubeFace face : values()) {
|
||||
double alignment = face.stepX * direction.x() + face.stepY * direction.y() + face.stepZ * direction.z();
|
||||
if (alignment > best) {
|
||||
best = alignment;
|
||||
nearest = face;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
String modelName() {
|
||||
return modelName;
|
||||
}
|
||||
|
||||
int stepX() {
|
||||
return stepX;
|
||||
}
|
||||
|
||||
int stepY() {
|
||||
return stepY;
|
||||
}
|
||||
|
||||
int stepZ() {
|
||||
return stepZ;
|
||||
}
|
||||
|
||||
double shade() {
|
||||
return shade;
|
||||
}
|
||||
|
||||
Vec3 direction() {
|
||||
return new Vec3(stepX, stepY, stepZ);
|
||||
}
|
||||
|
||||
CubeFace opposite() {
|
||||
return switch (this) {
|
||||
case UP -> DOWN;
|
||||
case DOWN -> UP;
|
||||
case NORTH -> SOUTH;
|
||||
case SOUTH -> NORTH;
|
||||
case EAST -> WEST;
|
||||
case WEST -> EAST;
|
||||
};
|
||||
}
|
||||
|
||||
int axis() {
|
||||
return stepX != 0 ? 0 : stepY != 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
boolean pointsPositive() {
|
||||
return stepX + stepY + stepZ > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.Icon;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
|
||||
/**
|
||||
* What an empty view says, drawn in the middle of it the way IntelliJ fills an empty tool window: a large icon, what
|
||||
* is missing, and where to add it.
|
||||
*
|
||||
* @param location the pack folder or file the hint points to, drawn as code
|
||||
*/
|
||||
record EmptyState(Icon icon, String title, String hint, String location, String footer) {
|
||||
private static final int ICON_GAP = 18;
|
||||
private static final int TITLE_GAP = 8;
|
||||
private static final int LOCATION_GAP = 10;
|
||||
private static final int FOOTER_GAP = 14;
|
||||
private static final int CHIP_PADDING_X = 8;
|
||||
private static final int CHIP_PADDING_Y = 4;
|
||||
private static final int CHIP_ARC = 8;
|
||||
|
||||
/**
|
||||
* Draws the empty state centred on the given point.
|
||||
*/
|
||||
void paint(Graphics2D graphics, Component owner, int centerX, int centerY) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
int top = centerY - height(canvas) / 2;
|
||||
icon.paintIcon(owner, canvas, centerX - icon.getIconWidth() / 2, top);
|
||||
top += icon.getIconHeight() + StudioTheme.scale(ICON_GAP);
|
||||
top = drawCentered(canvas, title, StudioTheme.titleFont(), StudioTheme.TEXT, centerX, top);
|
||||
top = drawCentered(canvas, hint, StudioTheme.font(), StudioTheme.SECONDARY_TEXT, centerX,
|
||||
top + StudioTheme.scale(TITLE_GAP));
|
||||
top = drawLocationChip(canvas, centerX, top + StudioTheme.scale(LOCATION_GAP));
|
||||
drawCentered(canvas, footer, StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, centerX, top + StudioTheme.scale(FOOTER_GAP));
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private int height(Graphics2D canvas) {
|
||||
return icon.getIconHeight() + StudioTheme.scale(ICON_GAP)
|
||||
+ canvas.getFontMetrics(StudioTheme.titleFont()).getHeight()
|
||||
+ StudioTheme.scale(TITLE_GAP) + canvas.getFontMetrics(StudioTheme.font()).getHeight()
|
||||
+ StudioTheme.scale(LOCATION_GAP) + chipHeight(canvas)
|
||||
+ StudioTheme.scale(FOOTER_GAP) + canvas.getFontMetrics(StudioTheme.smallFont()).getHeight();
|
||||
}
|
||||
|
||||
private int chipHeight(Graphics2D canvas) {
|
||||
return canvas.getFontMetrics(StudioTheme.monospacedFont()).getHeight() + StudioTheme.scale(CHIP_PADDING_Y) * 2;
|
||||
}
|
||||
|
||||
private int drawLocationChip(Graphics2D canvas, int centerX, int top) {
|
||||
canvas.setFont(StudioTheme.monospacedFont());
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
int width = metrics.stringWidth(location) + StudioTheme.scale(CHIP_PADDING_X) * 2;
|
||||
int height = chipHeight(canvas);
|
||||
int left = centerX - width / 2;
|
||||
RoundRectangle2D chip = new RoundRectangle2D.Double(left + 0.5D, top + 0.5D, width - 1D, height - 1D,
|
||||
StudioTheme.scale(CHIP_ARC), StudioTheme.scale(CHIP_ARC));
|
||||
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
|
||||
canvas.fill(chip);
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.draw(chip);
|
||||
canvas.setColor(StudioTheme.TEXT);
|
||||
canvas.drawString(location, left + StudioTheme.scale(CHIP_PADDING_X), top + StudioTheme.scale(CHIP_PADDING_Y) + metrics.getAscent());
|
||||
return top + height;
|
||||
}
|
||||
|
||||
private static int drawCentered(Graphics2D canvas, String text, Font font, Color color, int centerX, int top) {
|
||||
canvas.setFont(font);
|
||||
canvas.setColor(color);
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
canvas.drawString(text, centerX - metrics.stringWidth(text) / 2, top + metrics.getAscent());
|
||||
return top + metrics.getHeight();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.BlockModel.Face;
|
||||
import art.arcane.iris.studio.map.BlockModel.TextureMapping;
|
||||
import art.arcane.iris.studio.map.BlockTextures.BlockStateId;
|
||||
import art.arcane.iris.studio.map.BlockTextures.BlockTexture;
|
||||
import art.arcane.iris.studio.map.BlockTextures.ModelDefinition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Shapes for the blocks Minecraft draws in code instead of from a model file, like chests, heads, banners and
|
||||
* fluids, which fill their block. Chests, heads, banners and the bell use their entity textures laid out the way Minecraft's model parts are;
|
||||
* rarer ones are a simple box in their particle texture.
|
||||
*/
|
||||
final class EntityBlockShapes {
|
||||
private static final Set<String> INVISIBLE = Set.of("air", "cave_air", "void_air", "barrier", "light",
|
||||
"structure_void", "moving_piston", "pitcher_crop");
|
||||
private static final int END_PORTAL_COLOR = 0x0E1219;
|
||||
private static final double SKULL_WALL_OFFSET = 0.25D;
|
||||
private static final double ROTATION_SEGMENT_DEGREES = 22.5D;
|
||||
private static final double BANNER_SCALE = 2D / 3D;
|
||||
private static final double MODEL_UNITS = 16D;
|
||||
private static final Map<String, Integer> DYE_COLORS = Map.ofEntries(
|
||||
Map.entry("white", 0xF9FFFE), Map.entry("orange", 0xF9801D), Map.entry("magenta", 0xC74EBD),
|
||||
Map.entry("light_blue", 0x3AB3DA), Map.entry("yellow", 0xFED83D), Map.entry("lime", 0x80C71F),
|
||||
Map.entry("pink", 0xF38BAA), Map.entry("gray", 0x474F52), Map.entry("light_gray", 0x9D9D97),
|
||||
Map.entry("cyan", 0x169C9C), Map.entry("purple", 0x8932B8), Map.entry("blue", 0x3C44AA),
|
||||
Map.entry("brown", 0x835432), Map.entry("green", 0x5E7C16), Map.entry("red", 0xB02E26),
|
||||
Map.entry("black", 0x1D1D21));
|
||||
|
||||
private final BlockTextures textures;
|
||||
|
||||
EntityBlockShapes(BlockTextures textures) {
|
||||
this.textures = textures;
|
||||
}
|
||||
|
||||
/**
|
||||
* The faces of a block whose model has no boxes of its own.
|
||||
*/
|
||||
List<Face> facesOf(BlockStateId state, ModelDefinition model) {
|
||||
String name = state.name();
|
||||
if (INVISIBLE.contains(name)) {
|
||||
return List.of();
|
||||
}
|
||||
if (name.endsWith("chest")) {
|
||||
return chest(state);
|
||||
}
|
||||
if (name.endsWith("_skull") || name.endsWith("_head")) {
|
||||
return head(state, model);
|
||||
}
|
||||
if (name.endsWith("banner")) {
|
||||
return banner(state);
|
||||
}
|
||||
return switch (name) {
|
||||
case "conduit" -> particleBox(state, model, new Vec3(5, 5, 5), new Vec3(11, 11, 11), false);
|
||||
case "decorated_pot" -> decoratedPot(state, model);
|
||||
case "end_portal" -> BlockModelBuilder.box(new Vec3(0, 0, 0), new Vec3(16, 12, 16), BlockTexture.solid(END_PORTAL_COLOR), true);
|
||||
case "end_gateway" -> BlockModelBuilder.box(new Vec3(0, 0, 0), new Vec3(16, 16, 16), BlockTexture.solid(END_PORTAL_COLOR), true);
|
||||
default -> name.endsWith("copper_golem_statue")
|
||||
? particleBox(state, model, new Vec3(4, 0, 4), new Vec3(12, 14, 12), false)
|
||||
: particleBox(state, model, new Vec3(0, 0, 0), new Vec3(16, 16, 16), true);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts a block draws in code on top of its model, like the bell hanging in its frame.
|
||||
*/
|
||||
List<Face> facesAddedTo(BlockStateId state) {
|
||||
if (!state.name().equals("bell")) {
|
||||
return List.of();
|
||||
}
|
||||
Optional<BlockTexture> texture = textures.entityTexture("entity/bell/bell_body", MinecraftBlockTints.NO_TINT);
|
||||
if (texture.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Face> faces = new ArrayList<>();
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(5, 6, 5), new Vec3(6, 7, 6), Transform.IDENTITY));
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 13, new Vec3(4, 4, 4), new Vec3(8, 2, 8), Transform.IDENTITY));
|
||||
return faces;
|
||||
}
|
||||
|
||||
private List<Face> particleBox(BlockStateId state, ModelDefinition model, Vec3 from, Vec3 to, boolean culledBySides) {
|
||||
String particle = BlockTextures.dereference("#particle", model.textures());
|
||||
if (particle == null) {
|
||||
return List.of();
|
||||
}
|
||||
return textures.faceTexture(state, particle, BlockTextures.NO_TINT_INDEX)
|
||||
.map(texture -> BlockModelBuilder.box(from, to, texture, culledBySides))
|
||||
.orElse(List.of());
|
||||
}
|
||||
|
||||
private List<Face> decoratedPot(BlockStateId state, ModelDefinition model) {
|
||||
List<Face> faces = new ArrayList<>(particleBox(state, model, new Vec3(1, 0, 1), new Vec3(15, 16, 15), false));
|
||||
faces.addAll(particleBox(state, model, new Vec3(4, 16, 4), new Vec3(12, 20, 12), false));
|
||||
return faces;
|
||||
}
|
||||
|
||||
// Single chests are 14 pixels wide; each half of a double chest is 15, reaching over to its partner.
|
||||
private List<Face> chest(BlockStateId state) {
|
||||
String type = state.properties().getOrDefault("type", "single");
|
||||
String suffix = type.equals("single") ? "" : "_" + type;
|
||||
Optional<BlockTexture> texture = textures.entityTexture("entity/chest/" + chestTextureName(state.name()) + suffix, MinecraftBlockTints.NO_TINT);
|
||||
if (texture.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Transform place = Transform.IDENTITY
|
||||
.translate(0.5D, 0.5D, 0.5D)
|
||||
.rotateY(-yRotationOf(state.properties().getOrDefault("facing", "north")))
|
||||
.translate(-0.5D, -0.5D, -0.5D);
|
||||
double left = type.equals("left") ? 0D : 1D;
|
||||
double width = type.equals("single") ? 14D : 15D;
|
||||
double lockX = switch (type) {
|
||||
case "left" -> 0D;
|
||||
case "right" -> 15D;
|
||||
default -> 7D;
|
||||
};
|
||||
double lockWidth = type.equals("single") ? 2D : 1D;
|
||||
List<Face> faces = new ArrayList<>();
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 19, new Vec3(left, 0, 1), new Vec3(width, 10, 14), place));
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(left, 9, 1), new Vec3(width, 5, 14), place));
|
||||
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(lockX, 7, 15), new Vec3(lockWidth, 4, 1), place));
|
||||
return faces;
|
||||
}
|
||||
|
||||
private static String chestTextureName(String blockName) {
|
||||
String name = blockName.startsWith("waxed_") ? blockName.substring("waxed_".length()) : blockName;
|
||||
return switch (name) {
|
||||
case "trapped_chest" -> "trapped";
|
||||
case "ender_chest" -> "ender";
|
||||
case "copper_chest" -> "copper";
|
||||
case "exposed_copper_chest" -> "copper_exposed";
|
||||
case "weathered_copper_chest" -> "copper_weathered";
|
||||
case "oxidized_copper_chest" -> "copper_oxidized";
|
||||
default -> "normal";
|
||||
};
|
||||
}
|
||||
|
||||
// Heads are drawn upside down and mirrored in their model, and turn in sixteenths like signs.
|
||||
private List<Face> head(BlockStateId state, ModelDefinition model) {
|
||||
boolean onWall = state.name().contains("_wall_");
|
||||
String kind = state.name().replace("_wall_", "_");
|
||||
Optional<HeadLook> look = HeadLook.of(kind);
|
||||
Optional<BlockTexture> texture = look.flatMap(found -> textures.entityTexture(found.texture(), MinecraftBlockTints.NO_TINT));
|
||||
if (texture.isEmpty()) {
|
||||
return onWall
|
||||
? particleBox(state, model, new Vec3(4, 4, 8), new Vec3(12, 12, 16), false)
|
||||
: particleBox(state, model, new Vec3(4, 0, 4), new Vec3(12, 8, 12), false);
|
||||
}
|
||||
Transform place;
|
||||
if (onWall) {
|
||||
CubeFace facing = CubeFace.named(state.properties().getOrDefault("facing", "north"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D - facing.stepX() * SKULL_WALL_OFFSET, SKULL_WALL_OFFSET, 0.5D - facing.stepZ() * SKULL_WALL_OFFSET)
|
||||
.scale(-1D, -1D, 1D)
|
||||
.rotateY(yRotationOf(facing.opposite().modelName()));
|
||||
} else {
|
||||
int rotation = Integer.parseInt(state.properties().getOrDefault("rotation", "0"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D, 0D, 0.5D)
|
||||
.scale(-1D, -1D, 1D)
|
||||
.rotateY(rotation * ROTATION_SEGMENT_DEGREES);
|
||||
}
|
||||
HeadLook found = look.get();
|
||||
return modelPartBox(texture.get(), 0, 0, new Vec3(-found.width() / 2D, -8, -4), new Vec3(found.width(), 8, 8), place);
|
||||
}
|
||||
|
||||
private List<Face> banner(BlockStateId state) {
|
||||
boolean onWall = state.name().endsWith("_wall_banner");
|
||||
String color = state.name().substring(0, state.name().length() - (onWall ? "_wall_banner" : "_banner").length());
|
||||
Optional<BlockTexture> frame = textures.entityTexture("entity/banner/banner_base", MinecraftBlockTints.NO_TINT);
|
||||
Optional<BlockTexture> cloth = textures.entityTexture("entity/banner/base", DYE_COLORS.getOrDefault(color, MinecraftBlockTints.NO_TINT));
|
||||
if (frame.isEmpty() || cloth.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Transform place;
|
||||
if (onWall) {
|
||||
CubeFace facing = CubeFace.named(state.properties().getOrDefault("facing", "north"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D, -1D / 6D, 0.5D)
|
||||
.rotateY(-yRotationOf(facing.modelName()))
|
||||
.translate(0D, -0.3125D, -0.4375D)
|
||||
.scale(BANNER_SCALE, -BANNER_SCALE, -BANNER_SCALE);
|
||||
} else {
|
||||
int rotation = Integer.parseInt(state.properties().getOrDefault("rotation", "0"));
|
||||
place = Transform.IDENTITY
|
||||
.translate(0.5D, 0.5D, 0.5D)
|
||||
.rotateY(-rotation * ROTATION_SEGMENT_DEGREES)
|
||||
.scale(BANNER_SCALE, -BANNER_SCALE, -BANNER_SCALE);
|
||||
}
|
||||
List<Face> faces = new ArrayList<>();
|
||||
if (!onWall) {
|
||||
faces.addAll(modelPartBox(frame.get(), 44, 0, new Vec3(-1, -30, -1), new Vec3(2, 42, 2), place));
|
||||
}
|
||||
faces.addAll(modelPartBox(frame.get(), 0, 42, new Vec3(-10, -32, -1), new Vec3(20, 2, 2), place));
|
||||
faces.addAll(modelPartBox(cloth.get(), 0, 0, new Vec3(-10, -32, -2), new Vec3(20, 40, 1), place));
|
||||
return faces;
|
||||
}
|
||||
|
||||
// Minecraft's facing angle: south is 0 and each quarter turn clockwise seen from above adds 90 degrees.
|
||||
private static double yRotationOf(String facing) {
|
||||
return switch (facing) {
|
||||
case "west" -> 90D;
|
||||
case "north" -> 180D;
|
||||
case "east" -> 270D;
|
||||
default -> 0D;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A box of a block entity's model, in model pixels, with its sides laid out on the texture the way Minecraft's
|
||||
* model parts are: the top and bottom in a row above the four sides.
|
||||
*/
|
||||
static List<Face> modelPartBox(BlockTexture texture, int textureU, int textureV, Vec3 from, Vec3 size, Transform place) {
|
||||
double minX = from.x() / MODEL_UNITS;
|
||||
double minY = from.y() / MODEL_UNITS;
|
||||
double minZ = from.z() / MODEL_UNITS;
|
||||
double maxX = (from.x() + size.x()) / MODEL_UNITS;
|
||||
double maxY = (from.y() + size.y()) / MODEL_UNITS;
|
||||
double maxZ = (from.z() + size.z()) / MODEL_UNITS;
|
||||
Vec3 v0 = new Vec3(minX, minY, minZ);
|
||||
Vec3 v1 = new Vec3(maxX, minY, minZ);
|
||||
Vec3 v2 = new Vec3(maxX, maxY, minZ);
|
||||
Vec3 v3 = new Vec3(minX, maxY, minZ);
|
||||
Vec3 v4 = new Vec3(minX, minY, maxZ);
|
||||
Vec3 v5 = new Vec3(maxX, minY, maxZ);
|
||||
Vec3 v6 = new Vec3(maxX, maxY, maxZ);
|
||||
Vec3 v7 = new Vec3(minX, maxY, maxZ);
|
||||
double u = textureU;
|
||||
double v = textureV;
|
||||
double width = size.x();
|
||||
double height = size.y();
|
||||
double depth = size.z();
|
||||
List<Face> faces = new ArrayList<>(6);
|
||||
faces.add(polygon(texture, place, CubeFace.DOWN, v5, v4, v0, u + depth, v, u + depth + width, v + depth));
|
||||
faces.add(polygon(texture, place, CubeFace.UP, v2, v3, v7, u + depth + width, v + depth, u + depth + width + width, v));
|
||||
faces.add(polygon(texture, place, CubeFace.WEST, v0, v4, v7, u, v + depth, u + depth, v + depth + height));
|
||||
faces.add(polygon(texture, place, CubeFace.NORTH, v1, v0, v3, u + depth, v + depth, u + depth + width, v + depth + height));
|
||||
faces.add(polygon(texture, place, CubeFace.EAST, v5, v1, v2, u + depth + width, v + depth, u + depth + width + depth, v + depth + height));
|
||||
faces.add(polygon(texture, place, CubeFace.SOUTH, v4, v5, v6, u + depth + width + depth, v + depth, u + depth + width + depth + width, v + depth + height));
|
||||
return faces;
|
||||
}
|
||||
|
||||
// A model part's polygon gives its first corner (u2, v1), its second (u1, v1) and its third (u1, v2).
|
||||
private static Face polygon(BlockTexture texture, Transform place, CubeFace side, Vec3 first, Vec3 second, Vec3 third,
|
||||
double u1, double v1, double u2, double v2) {
|
||||
double textureWidth = texture.width();
|
||||
double textureHeight = texture.height();
|
||||
TextureMapping mapping = new TextureMapping(u1 / textureWidth, v1 / textureHeight,
|
||||
(u2 - u1) / textureWidth, 0D, 0D, (v2 - v1) / textureHeight);
|
||||
Face face = new Face(second, first.minus(second), third.minus(second), side.direction(), texture, mapping, side.shade(), null)
|
||||
.transformed(place);
|
||||
return face.withShade(CubeFace.nearest(face.normal()).shade());
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity texture of a mob head and how wide its head is.
|
||||
*/
|
||||
private record HeadLook(String texture, double width) {
|
||||
private static final double HEAD_WIDTH = 8D;
|
||||
private static final double PIGLIN_HEAD_WIDTH = 10D;
|
||||
|
||||
static Optional<HeadLook> of(String kind) {
|
||||
return Optional.ofNullable(switch (kind) {
|
||||
case "skeleton_skull" -> new HeadLook("entity/skeleton/skeleton", HEAD_WIDTH);
|
||||
case "wither_skeleton_skull" -> new HeadLook("entity/skeleton/wither_skeleton", HEAD_WIDTH);
|
||||
case "zombie_head" -> new HeadLook("entity/zombie/zombie", HEAD_WIDTH);
|
||||
case "creeper_head" -> new HeadLook("entity/creeper/creeper", HEAD_WIDTH);
|
||||
case "player_head" -> new HeadLook("entity/player/wide/steve", HEAD_WIDTH);
|
||||
case "piglin_head" -> new HeadLook("entity/piglin/piglin", PIGLIN_HEAD_WIDTH);
|
||||
default -> null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.Component;
|
||||
import java.awt.Container;
|
||||
import java.awt.Rectangle;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Finds the Swing panels floating over a native window, the way AWT does when it cuts them out of that window. Only
|
||||
* for the Swing thread.
|
||||
*/
|
||||
final class FloatingPanels {
|
||||
private FloatingPanels() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A floating panel that hides what lies behind most of it, and only lets it show through some parts.
|
||||
*/
|
||||
interface PartlySeeThrough {
|
||||
/**
|
||||
* @return where what lies behind shows through, in the panel's own coordinates
|
||||
*/
|
||||
List<Rectangle> seeThroughParts();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return where what the window shows has to show through the panels over it, in the window's coordinates
|
||||
*/
|
||||
static List<Rectangle> seeThroughOver(Component window) {
|
||||
List<Rectangle> parts = new ArrayList<>();
|
||||
Rectangle windowBounds = new Rectangle(0, 0, window.getWidth(), window.getHeight());
|
||||
Component child = window;
|
||||
Container parent = child.getParent();
|
||||
while (parent != null) {
|
||||
for (int index = parent.getComponentZOrder(child) - 1; index >= 0; index--) {
|
||||
Component above = parent.getComponent(index);
|
||||
if (above.isLightweight() && above.isShowing()) {
|
||||
addSeeThroughParts(above, window, windowBounds, parts);
|
||||
}
|
||||
}
|
||||
if (!parent.isLightweight()) {
|
||||
break;
|
||||
}
|
||||
child = parent;
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static void addSeeThroughParts(Component panel, Component window, Rectangle windowBounds, List<Rectangle> parts) {
|
||||
List<Rectangle> panelParts = panel instanceof PartlySeeThrough partly
|
||||
? partly.seeThroughParts()
|
||||
: List.of(new Rectangle(0, 0, panel.getWidth(), panel.getHeight()));
|
||||
for (Rectangle part : panelParts) {
|
||||
Rectangle inWindow = SwingUtilities.convertRectangle(panel, part, window).intersection(windowBounds);
|
||||
if (!inWindow.isEmpty()) {
|
||||
parts.add(inWindow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JSlider;
|
||||
import javax.swing.SwingConstants;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
/**
|
||||
* A slim vertical slider floating over the right edge of a 3D view, halfway down, with a short label above it. It
|
||||
* stays faint so it hides little of the view, until the mouse comes near or it is told to stand out.
|
||||
*/
|
||||
final class FloatingSlider extends JPanel {
|
||||
private static final long serialVersionUID = 4473820211915526120L;
|
||||
private static final int PANEL_WIDTH = 30;
|
||||
private static final int MAX_HEIGHT = 320;
|
||||
private static final double HEIGHT_SHARE_OF_VIEW = 0.5D;
|
||||
private static final int EDGE_MARGIN = 8;
|
||||
private static final float FAINT_OPACITY = 0.28F;
|
||||
private static final String BLANK_LABEL = " ";
|
||||
|
||||
private final JSlider slider = new JSlider(SwingConstants.VERTICAL, 0, 1, 1);
|
||||
private final JLabel label = new JLabel(BLANK_LABEL, SwingConstants.CENTER);
|
||||
private boolean movingQuietly;
|
||||
private boolean hovered;
|
||||
private boolean standingOut;
|
||||
|
||||
/**
|
||||
* @param onMoved told the slider's value whenever the user moves it
|
||||
*/
|
||||
FloatingSlider(String tooltip, IntConsumer onMoved) {
|
||||
super(new BorderLayout(0, StudioTheme.scale(4)));
|
||||
setOpaque(false);
|
||||
setToolTipText(tooltip);
|
||||
label.setFont(StudioTheme.smallFont());
|
||||
label.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
slider.setOpaque(false);
|
||||
slider.setFocusable(false);
|
||||
slider.setToolTipText(tooltip);
|
||||
slider.addChangeListener(event -> {
|
||||
if (!movingQuietly) {
|
||||
onMoved.accept(slider.getValue());
|
||||
}
|
||||
});
|
||||
MouseAdapter hover = new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent event) {
|
||||
setHovered(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent event) {
|
||||
setHovered(slider.getValueIsAdjusting());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent event) {
|
||||
setHovered(slider.contains(event.getPoint()));
|
||||
}
|
||||
};
|
||||
slider.addMouseListener(hover);
|
||||
addMouseListener(hover);
|
||||
add(label, BorderLayout.NORTH);
|
||||
add(slider, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* The view with this slider floating over its right edge, halfway down.
|
||||
*/
|
||||
JComponent floatingOver(JComponent view) {
|
||||
FloatingSlider floating = this;
|
||||
JPanel stack = new JPanel(null) {
|
||||
private static final long serialVersionUID = -1950381405658102347L;
|
||||
|
||||
// The slider overlaps the view, so the view may not repaint without the slider on top of it.
|
||||
@Override
|
||||
public boolean isOptimizedDrawingEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doLayout() {
|
||||
view.setBounds(0, 0, getWidth(), getHeight());
|
||||
int width = StudioTheme.scale(PANEL_WIDTH);
|
||||
int height = Math.min(StudioTheme.scale(MAX_HEIGHT), (int) (getHeight() * HEIGHT_SHARE_OF_VIEW));
|
||||
floating.setBounds(getWidth() - width - StudioTheme.scale(EDGE_MARGIN), (getHeight() - height) / 2, width, height);
|
||||
}
|
||||
};
|
||||
stack.add(floating);
|
||||
stack.add(view);
|
||||
return stack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a range and value the user didn't choose, without telling anyone the slider moved.
|
||||
*/
|
||||
void showQuietly(int minimum, int maximum, int value) {
|
||||
movingQuietly = true;
|
||||
try {
|
||||
slider.getModel().setRangeProperties(value, 0, minimum, maximum, false);
|
||||
} finally {
|
||||
movingQuietly = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Moves the slider as if the user had.
|
||||
void moveBy(int steps) {
|
||||
slider.setValue(slider.getValue() + steps);
|
||||
}
|
||||
|
||||
int value() {
|
||||
return slider.getValue();
|
||||
}
|
||||
|
||||
int maximum() {
|
||||
return slider.getMaximum();
|
||||
}
|
||||
|
||||
void showLabel(String text) {
|
||||
label.setText(text.isEmpty() ? BLANK_LABEL : text);
|
||||
repaint();
|
||||
}
|
||||
|
||||
void setStandingOut(boolean nowStandingOut) {
|
||||
if (standingOut != nowStandingOut) {
|
||||
standingOut = nowStandingOut;
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintChildren(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
if (!hovered && !standingOut) {
|
||||
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, FAINT_OPACITY));
|
||||
}
|
||||
super.paintChildren(canvas);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void setHovered(boolean nowHovered) {
|
||||
if (hovered != nowHovered) {
|
||||
hovered = nowHovered;
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
|
||||
/**
|
||||
* How many frames a second a 3D view keeps up, right-aligned, floating over the view.
|
||||
*/
|
||||
final class FrameRateLabel extends JComponent {
|
||||
private static final long serialVersionUID = -6402187316475095871L;
|
||||
private static final String WIDEST_TEXT = "8888 fps";
|
||||
|
||||
private String text = "";
|
||||
|
||||
FrameRateLabel() {
|
||||
setOpaque(false);
|
||||
}
|
||||
|
||||
void showRate(double framesPerSecond) {
|
||||
String next = Math.round(framesPerSecond) + " fps";
|
||||
if (!next.equals(text)) {
|
||||
text = next;
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
FontMetrics metrics = getFontMetrics(StudioTheme.smallFont());
|
||||
return new Dimension(metrics.stringWidth(WIDEST_TEXT), metrics.getHeight());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics;
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
canvas.drawString(text, getWidth() - metrics.stringWidth(text), metrics.getAscent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import org.lwjgl.PointerBuffer;
|
||||
import org.lwjgl.opengl.GL;
|
||||
import org.lwjgl.opengl.GLXCapabilities;
|
||||
import org.lwjgl.system.Callback;
|
||||
import org.lwjgl.system.CallbackI;
|
||||
import org.lwjgl.system.Configuration;
|
||||
import org.lwjgl.system.JNI;
|
||||
import org.lwjgl.system.MemoryStack;
|
||||
import org.lwjgl.system.linux.DynamicLinkLoader;
|
||||
import org.lwjgl.system.linux.X11;
|
||||
import org.lwjgl.system.linux.XErrorEvent;
|
||||
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.awt.Toolkit;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.lwjgl.opengl.GL11.GL_RENDERER;
|
||||
import static org.lwjgl.opengl.GL11.glGetString;
|
||||
import static org.lwjgl.opengl.GLX.glXDestroyContext;
|
||||
import static org.lwjgl.opengl.GLX.glXMakeCurrent;
|
||||
import static org.lwjgl.opengl.GLX.glXSwapBuffers;
|
||||
import static org.lwjgl.opengl.GLX13.*;
|
||||
import static org.lwjgl.opengl.GLXARBCreateContext.*;
|
||||
import static org.lwjgl.opengl.GLXARBCreateContextProfile.GLX_CONTEXT_CORE_PROFILE_BIT_ARB;
|
||||
import static org.lwjgl.opengl.GLXARBCreateContextProfile.GLX_CONTEXT_PROFILE_MASK_ARB;
|
||||
import static org.lwjgl.opengl.GLXEXTSwapControl.glXSwapIntervalEXT;
|
||||
import static org.lwjgl.system.APIUtil.apiClosureRet;
|
||||
import static org.lwjgl.system.APIUtil.apiCreateCIF;
|
||||
import static org.lwjgl.system.MemoryUtil.NULL;
|
||||
import static org.lwjgl.system.MemoryUtil.memAddress;
|
||||
import static org.lwjgl.system.MemoryUtil.memGetAddress;
|
||||
import static org.lwjgl.system.libffi.LibFFI.ffi_type_pointer;
|
||||
import static org.lwjgl.system.libffi.LibFFI.ffi_type_sint32;
|
||||
|
||||
/**
|
||||
* A connection of our own to the X server Swing's windows are on, with one OpenGL context that draws straight into
|
||||
* those windows, so a frame never has to be copied back and painted by Swing. Only for the GPU thread.
|
||||
*/
|
||||
final class GlxDisplay {
|
||||
private static final String X11_TOOLKIT = "sun.awt.X11.XToolkit";
|
||||
private static final String NATIVE_CONTEXT_API = "native";
|
||||
private static final String NVIDIA_DRIVER_VERSION_FILE = "/proc/driver/nvidia/version";
|
||||
private static final Set<String> SOFTWARE_RENDERERS = Set.of("llvmpipe", "softpipe", "software rasterizer");
|
||||
private static final int OPENGL_MAJOR_VERSION = 3;
|
||||
private static final int OPENGL_MINOR_VERSION = 3;
|
||||
private static final int COLOR_CHANNEL_BITS = 8;
|
||||
private static final int DEPTH_BITS = 24;
|
||||
// Frames are only drawn when something moves, and the compositor keeps them from tearing.
|
||||
private static final int SWAP_EVERY_FRAME = 0;
|
||||
|
||||
private final long display;
|
||||
private final long context;
|
||||
private final long visualId;
|
||||
private final boolean canSetSwapInterval;
|
||||
private final Set<Long> windowsWithSwapIntervalSet = new HashSet<>();
|
||||
private final String renderer;
|
||||
private long currentWindow;
|
||||
|
||||
private GlxDisplay(long display, long context, long visualId, boolean canSetSwapInterval, String renderer) {
|
||||
this.display = display;
|
||||
this.context = context;
|
||||
this.visualId = visualId;
|
||||
this.canSetSwapInterval = canSetSwapInterval;
|
||||
this.renderer = renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the connection and makes its context current on this thread, without any window yet.
|
||||
*
|
||||
* @throws IllegalStateException when Swing doesn't draw on X or no card there draws OpenGL 3.3 in hardware
|
||||
*/
|
||||
static GlxDisplay open() {
|
||||
if (!isSwingOnX()) {
|
||||
throw new IllegalStateException("Swing doesn't draw on X");
|
||||
}
|
||||
preferNvidiaCard();
|
||||
// LWJGL would take EGL in a Wayland session, even though Swing's windows are on X.
|
||||
Configuration.OPENGL_CONTEXT_API.set(NATIVE_CONTEXT_API);
|
||||
GL.create();
|
||||
long display = X11.nXOpenDisplay(NULL);
|
||||
if (display == NULL) {
|
||||
GL.destroy();
|
||||
throw new IllegalStateException("Can't connect to the X server");
|
||||
}
|
||||
XErrors.watch(display);
|
||||
try {
|
||||
return openOn(display);
|
||||
} catch (RuntimeException failure) {
|
||||
XErrors.forget(display);
|
||||
X11.XCloseDisplay(display);
|
||||
GL.destroy();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isSwingOnX() {
|
||||
return !GraphicsEnvironment.isHeadless() && X11_TOOLKIT.equals(Toolkit.getDefaultToolkit().getClass().getName());
|
||||
}
|
||||
|
||||
private static GlxDisplay openOn(long display) {
|
||||
int screen = X11.XDefaultScreen(display);
|
||||
GLXCapabilities glx = GL.createCapabilitiesGLX(display, screen);
|
||||
if (!glx.GLX13 || !glx.GLX_ARB_create_context || !glx.GLX_ARB_create_context_profile) {
|
||||
throw new IllegalStateException("GLX can't make an OpenGL " + OPENGL_MAJOR_VERSION + "." + OPENGL_MINOR_VERSION + " context");
|
||||
}
|
||||
long visualId = Xlib.defaultVisualId(display, screen);
|
||||
long config = configFor(display, screen, visualId);
|
||||
long context = glXCreateContextAttribsARB(display, config, NULL, true, new int[]{
|
||||
GLX_CONTEXT_MAJOR_VERSION_ARB, OPENGL_MAJOR_VERSION,
|
||||
GLX_CONTEXT_MINOR_VERSION_ARB, OPENGL_MINOR_VERSION,
|
||||
GLX_CONTEXT_PROFILE_MASK_ARB, GLX_CONTEXT_CORE_PROFILE_BIT_ARB,
|
||||
0});
|
||||
Xlib.sync(display);
|
||||
if (context == NULL || XErrors.takeFailed(display)) {
|
||||
throw new IllegalStateException("GLX can't make an OpenGL " + OPENGL_MAJOR_VERSION + "." + OPENGL_MINOR_VERSION + " context");
|
||||
}
|
||||
if (!glXMakeContextCurrent(display, NULL, NULL, context)) {
|
||||
glXDestroyContext(display, context);
|
||||
throw new IllegalStateException("GLX can't use a context without a window");
|
||||
}
|
||||
GL.createCapabilities();
|
||||
String renderer = glGetString(GL_RENDERER);
|
||||
if (isDrawnInSoftware(renderer)) {
|
||||
glXMakeContextCurrent(display, NULL, NULL, NULL);
|
||||
glXDestroyContext(display, context);
|
||||
throw new IllegalStateException("GLX only draws in software, on " + renderer);
|
||||
}
|
||||
return new GlxDisplay(display, context, visualId, glx.GLX_EXT_swap_control, renderer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Laptops show X on the built-in card and only lend the faster one out when asked to. Asked the way NVIDIA's own
|
||||
* prime-run does, unless whoever started the server asked for something else already.
|
||||
*/
|
||||
private static void preferNvidiaCard() {
|
||||
if (!Files.exists(Path.of(NVIDIA_DRIVER_VERSION_FILE))) {
|
||||
return;
|
||||
}
|
||||
LibC.setenvUnlessSet("__NV_PRIME_RENDER_OFFLOAD", "1");
|
||||
LibC.setenvUnlessSet("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
|
||||
}
|
||||
|
||||
// Windows are only drawn into with the visual they were made with; Swing makes them with the screen's default one.
|
||||
private static long configFor(long display, int screen, long visualId) {
|
||||
PointerBuffer configs = glXChooseFBConfig(display, screen, new int[]{
|
||||
GLX_X_RENDERABLE, 1,
|
||||
GLX_DRAWABLE_TYPE, GLX_WINDOW_BIT,
|
||||
GLX_RENDER_TYPE, GLX_RGBA_BIT,
|
||||
GLX_DOUBLEBUFFER, 1,
|
||||
GLX_RED_SIZE, COLOR_CHANNEL_BITS,
|
||||
GLX_GREEN_SIZE, COLOR_CHANNEL_BITS,
|
||||
GLX_BLUE_SIZE, COLOR_CHANNEL_BITS,
|
||||
GLX_DEPTH_SIZE, DEPTH_BITS,
|
||||
0});
|
||||
if (configs == null) {
|
||||
throw new IllegalStateException("GLX has no double-buffered config with a depth buffer");
|
||||
}
|
||||
try {
|
||||
int[] configVisual = new int[1];
|
||||
for (int index = 0; index < configs.remaining(); index++) {
|
||||
long config = configs.get(index);
|
||||
glXGetFBConfigAttrib(display, config, GLX_VISUAL_ID, configVisual);
|
||||
if (configVisual[0] == visualId) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
X11.XFree(configs);
|
||||
}
|
||||
throw new IllegalStateException("GLX can't draw into windows of the default visual 0x" + Long.toHexString(visualId));
|
||||
}
|
||||
|
||||
private static boolean isDrawnInSoftware(String renderer) {
|
||||
String lowerCase = renderer == null ? "" : renderer.toLowerCase(Locale.ROOT);
|
||||
return SOFTWARE_RENDERERS.stream().anyMatch(lowerCase::contains);
|
||||
}
|
||||
|
||||
String renderer() {
|
||||
return renderer;
|
||||
}
|
||||
|
||||
boolean canDrawInto(long windowVisualId) {
|
||||
return windowVisualId == visualId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the window what the default framebuffer draws into.
|
||||
*
|
||||
* @return false when the window can't be drawn into
|
||||
*/
|
||||
boolean drawInto(long window) {
|
||||
if (window == currentWindow) {
|
||||
return true;
|
||||
}
|
||||
boolean madeCurrent = glXMakeCurrent(display, window, context);
|
||||
Xlib.sync(display);
|
||||
if (!madeCurrent || XErrors.takeFailed(display)) {
|
||||
currentWindow = NULL;
|
||||
return false;
|
||||
}
|
||||
currentWindow = window;
|
||||
if (canSetSwapInterval && windowsWithSwapIntervalSet.add(window)) {
|
||||
glXSwapIntervalEXT(display, window, SWAP_EVERY_FRAME);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts what was drawn into the window on screen.
|
||||
*
|
||||
* @return false when the window couldn't take it, or something drawn into it before failed
|
||||
*/
|
||||
boolean show(long window) {
|
||||
glXSwapBuffers(display, window);
|
||||
return !XErrors.takeFailed(display);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops drawing into a window before it goes away, keeping the context for the rest.
|
||||
*/
|
||||
void forget(long window) {
|
||||
windowsWithSwapIntervalSet.remove(window);
|
||||
if (window != currentWindow) {
|
||||
return;
|
||||
}
|
||||
glXMakeContextCurrent(display, NULL, NULL, context);
|
||||
currentWindow = NULL;
|
||||
Xlib.sync(display);
|
||||
XErrors.takeFailed(display);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the context and the connection. Nothing is drawn through this display afterwards.
|
||||
*/
|
||||
void close() {
|
||||
glXMakeContextCurrent(display, NULL, NULL, NULL);
|
||||
glXDestroyContext(display, context);
|
||||
currentWindow = NULL;
|
||||
windowsWithSwapIntervalSet.clear();
|
||||
Xlib.sync(display);
|
||||
XErrors.forget(display);
|
||||
X11.XCloseDisplay(display);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes X errors on our own connection instead of letting Xlib end the process over them, which is what it does
|
||||
* for any connection but Swing's. Errors on other connections go wherever they went before.
|
||||
*/
|
||||
private static final class XErrors {
|
||||
private static final Set<Long> watchedDisplays = ConcurrentHashMap.newKeySet();
|
||||
private static final Set<Long> failedDisplays = ConcurrentHashMap.newKeySet();
|
||||
private static long previousHandler;
|
||||
private static ErrorHandler handler;
|
||||
|
||||
static synchronized void watch(long display) {
|
||||
if (handler == null) {
|
||||
handler = XErrors::handle;
|
||||
previousHandler = Xlib.setErrorHandler(handler.address());
|
||||
}
|
||||
watchedDisplays.add(display);
|
||||
}
|
||||
|
||||
static void forget(long display) {
|
||||
watchedDisplays.remove(display);
|
||||
failedDisplays.remove(display);
|
||||
}
|
||||
|
||||
static boolean takeFailed(long display) {
|
||||
return failedDisplays.remove(display);
|
||||
}
|
||||
|
||||
private static int handle(long display, long errorEvent) {
|
||||
if (watchedDisplays.contains(display)) {
|
||||
failedDisplays.add(display);
|
||||
IrisLogging.debug("X error " + Byte.toUnsignedInt(XErrorEvent.create(errorEvent).error_code()) + " drawing a 3D view");
|
||||
return 0;
|
||||
}
|
||||
return previousHandler == NULL ? 0 : JNI.callPPI(display, errorEvent, previousHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ErrorHandler extends CallbackI {
|
||||
Callback.Descriptor DESCRIPTOR = new Callback.Descriptor(ErrorHandler.class, MethodHandles.lookup(),
|
||||
apiCreateCIF(ffi_type_sint32, ffi_type_pointer, ffi_type_pointer));
|
||||
|
||||
@Override
|
||||
default Callback.Descriptor getDescriptor() {
|
||||
return DESCRIPTOR;
|
||||
}
|
||||
|
||||
@Override
|
||||
default void callback(long ret, long args) {
|
||||
apiClosureRet(ret, invoke(memGetAddress(memGetAddress(args)), memGetAddress(memGetAddress(args + Long.BYTES))));
|
||||
}
|
||||
|
||||
int invoke(long display, long errorEvent);
|
||||
}
|
||||
|
||||
// The few Xlib calls LWJGL has no binding for.
|
||||
private static final class Xlib {
|
||||
static long defaultVisualId(long display, int screen) {
|
||||
long visual = JNI.callPP(display, screen, function("XDefaultVisual"));
|
||||
return JNI.callPP(visual, function("XVisualIDFromVisual"));
|
||||
}
|
||||
|
||||
static void sync(long display) {
|
||||
JNI.callPI(display, 0, function("XSync"));
|
||||
}
|
||||
|
||||
static long setErrorHandler(long handler) {
|
||||
return JNI.callPP(handler, function("XSetErrorHandler"));
|
||||
}
|
||||
|
||||
private static long function(String name) {
|
||||
long address = X11.getLibrary().getFunctionAddress(name);
|
||||
if (address == NULL) {
|
||||
throw new IllegalStateException("Xlib has no " + name);
|
||||
}
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LibC {
|
||||
private static final int KEEP_EXISTING = 0;
|
||||
private static final long EVERYTHING_LOADED = NULL;
|
||||
|
||||
// Looked up among everything the process loaded, which LWJGL's own dlsym won't do.
|
||||
static void setenvUnlessSet(String name, String value) {
|
||||
try (MemoryStack stack = MemoryStack.stackPush()) {
|
||||
long setenv = DynamicLinkLoader.ndlsym(EVERYTHING_LOADED, memAddress(stack.ASCII("setenv")));
|
||||
if (setenv == NULL) {
|
||||
return;
|
||||
}
|
||||
JNI.callPPI(memAddress(stack.ASCII(name)), memAddress(stack.ASCII(value)), KEEP_EXISTING, setenv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.lang.ref.Reference;
|
||||
import java.lang.ref.ReferenceQueue;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What was put on the graphics card, by what it was made from. The sources are held weakly: once nothing else keeps
|
||||
* one, its upload can never be asked for again and is deleted. Only for the GPU thread.
|
||||
*/
|
||||
final class GpuCache<U extends GpuCache.Upload> {
|
||||
private final LinkedHashMap<Source, U> leastRecentlyUsedFirst = new LinkedHashMap<>(16, 0.75F, true);
|
||||
private final ReferenceQueue<Object> collectedSources = new ReferenceQueue<>();
|
||||
|
||||
U get(Object source) {
|
||||
return leastRecentlyUsedFirst.get(Source.lookingFor(source));
|
||||
}
|
||||
|
||||
void put(Object source, U upload) {
|
||||
leastRecentlyUsedFirst.put(new Source(source, collectedSources), upload);
|
||||
}
|
||||
|
||||
void forgetCollected() {
|
||||
for (Reference<?> collected = collectedSources.poll(); collected != null; collected = collectedSources.poll()) {
|
||||
U upload = leastRecentlyUsedFirst.remove(collected);
|
||||
if (upload != null) {
|
||||
upload.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the least recently used uploads until the cache fits, sparing those made from what is kept.
|
||||
*/
|
||||
void forgetBeyond(long mostBytes, Set<Object> kept) {
|
||||
long bytes = 0L;
|
||||
for (U upload : leastRecentlyUsedFirst.values()) {
|
||||
bytes += upload.bytes();
|
||||
}
|
||||
Iterator<Map.Entry<Source, U>> oldestFirst = leastRecentlyUsedFirst.entrySet().iterator();
|
||||
while (bytes > mostBytes && oldestFirst.hasNext()) {
|
||||
Map.Entry<Source, U> entry = oldestFirst.next();
|
||||
if (!kept.contains(entry.getKey().get())) {
|
||||
bytes -= entry.getValue().bytes();
|
||||
entry.getValue().delete();
|
||||
oldestFirst.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Upload {
|
||||
long bytes();
|
||||
|
||||
void delete();
|
||||
}
|
||||
|
||||
// Equal only to a source of the same object, so a collected one is equal to nothing but itself.
|
||||
private static final class Source extends WeakReference<Object> {
|
||||
private final int identityHash;
|
||||
|
||||
private Source(Object source, ReferenceQueue<Object> collected) {
|
||||
super(source, collected);
|
||||
identityHash = System.identityHashCode(source);
|
||||
}
|
||||
|
||||
static Source lookingFor(Object source) {
|
||||
return new Source(source, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return identityHash;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
Object source = get();
|
||||
return source != null && other instanceof Source otherSource && source == otherSource.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import org.lwjgl.system.MemoryStack;
|
||||
import org.lwjgl.system.jawt.JAWT;
|
||||
import org.lwjgl.system.jawt.JAWTDrawingSurface;
|
||||
import org.lwjgl.system.jawt.JAWTDrawingSurfaceInfo;
|
||||
import org.lwjgl.system.jawt.JAWTX11DrawingSurfaceInfo;
|
||||
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.Canvas;
|
||||
import java.awt.Component;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.event.MouseWheelEvent;
|
||||
|
||||
import static org.lwjgl.system.jawt.JAWTFunctions.*;
|
||||
|
||||
/**
|
||||
* A native window inside a 3D view that the graphics card draws frames straight into. Swing can't paint over it, so
|
||||
* panels floating over the view are cut out of it, and the view paints what lies behind them itself. The mouse belongs
|
||||
* to the view behind it, as if this window weren't there.
|
||||
*/
|
||||
final class GpuCanvas extends Canvas {
|
||||
private static final long serialVersionUID = 2874650379461920137L;
|
||||
|
||||
private final transient Component view;
|
||||
private final transient Runnable onExposed;
|
||||
private volatile long window;
|
||||
private volatile long visualId;
|
||||
|
||||
/**
|
||||
* @param onExposed runs on the Swing thread when what the window showed was lost and has to be drawn again
|
||||
*/
|
||||
GpuCanvas(Component view, Runnable onExposed) {
|
||||
this.view = view;
|
||||
this.onExposed = onExposed;
|
||||
setFocusable(false);
|
||||
setBackground(StudioTheme.EDITOR_BACKGROUND);
|
||||
handMouseToView();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the X window frames go into, or 0 while there is none
|
||||
*/
|
||||
long window() {
|
||||
return window;
|
||||
}
|
||||
|
||||
long visualId() {
|
||||
return visualId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addNotify() {
|
||||
super.addNotify();
|
||||
readNativeWindow();
|
||||
}
|
||||
|
||||
// A frame on its way into the window would otherwise draw into one that no longer exists.
|
||||
@Override
|
||||
public void removeNotify() {
|
||||
long goingWindow = window;
|
||||
window = 0L;
|
||||
if (goingWindow != 0L) {
|
||||
GpuContext.forgetWindow(goingWindow);
|
||||
}
|
||||
super.removeNotify();
|
||||
}
|
||||
|
||||
// Erasing to the background first would flash it between frames.
|
||||
@Override
|
||||
public void update(Graphics graphics) {
|
||||
onExposed.run();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void paint(Graphics graphics) {
|
||||
onExposed.run();
|
||||
}
|
||||
|
||||
private void readNativeWindow() {
|
||||
try (MemoryStack stack = MemoryStack.stackPush()) {
|
||||
JAWT awt = JAWT.calloc(stack).version(JAWT_VERSION_1_4);
|
||||
if (!JAWT_GetAWT(awt)) {
|
||||
return;
|
||||
}
|
||||
JAWTDrawingSurface surface = JAWT_GetDrawingSurface(this, awt.GetDrawingSurface());
|
||||
if (surface == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
readLocked(surface);
|
||||
} finally {
|
||||
JAWT_FreeDrawingSurface(surface, awt.FreeDrawingSurface());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void readLocked(JAWTDrawingSurface surface) {
|
||||
if ((JAWT_DrawingSurface_Lock(surface, surface.Lock()) & JAWT_LOCK_ERROR) != 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JAWTDrawingSurfaceInfo info = JAWT_DrawingSurface_GetDrawingSurfaceInfo(surface, surface.GetDrawingSurfaceInfo());
|
||||
if (info == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JAWTX11DrawingSurfaceInfo x11 = JAWTX11DrawingSurfaceInfo.create(info.platformInfo());
|
||||
visualId = x11.visualID();
|
||||
window = x11.drawable();
|
||||
} finally {
|
||||
JAWT_DrawingSurface_FreeDrawingSurfaceInfo(info, surface.FreeDrawingSurfaceInfo());
|
||||
}
|
||||
} finally {
|
||||
JAWT_DrawingSurface_Unlock(surface, surface.Unlock());
|
||||
}
|
||||
}
|
||||
|
||||
private void handMouseToView() {
|
||||
MouseAdapter handOver = new MouseAdapter() {
|
||||
@Override
|
||||
public void mousePressed(MouseEvent event) {
|
||||
handOver(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent event) {
|
||||
handOver(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent event) {
|
||||
handOver(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent event) {
|
||||
handOver(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent event) {
|
||||
handOver(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseWheelMoved(MouseWheelEvent event) {
|
||||
handOver(event);
|
||||
}
|
||||
};
|
||||
addMouseListener(handOver);
|
||||
addMouseMotionListener(handOver);
|
||||
addMouseWheelListener(handOver);
|
||||
}
|
||||
|
||||
private void handOver(MouseEvent event) {
|
||||
view.dispatchEvent(SwingUtilities.convertMouseEvent(this, event, view));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import static org.lwjgl.opengl.GL12.*;
|
||||
|
||||
/**
|
||||
* Terrain's column colours on the graphics card, one texel per column, so faces running over many columns still colour
|
||||
* each one. Only for the GPU thread.
|
||||
*/
|
||||
final class GpuColors {
|
||||
private static final int BYTES_PER_COLUMN = 4;
|
||||
|
||||
private final int texture = glGenTextures();
|
||||
private final long bytes;
|
||||
|
||||
private GpuColors(NoiseTerrain terrain) {
|
||||
this.bytes = (long) terrain.columns() * terrain.rows() * BYTES_PER_COLUMN;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param largestSide the card's GL_MAX_TEXTURE_SIZE
|
||||
* @return the colours, or null when the terrain has more columns or rows than the card's textures can hold
|
||||
*/
|
||||
static GpuColors upload(NoiseTerrain terrain, int largestSide) {
|
||||
if (terrain.columns() > largestSide || terrain.rows() > largestSide) {
|
||||
return null;
|
||||
}
|
||||
GpuColors uploaded = new GpuColors(terrain);
|
||||
glBindTexture(GL_TEXTURE_2D, uploaded.texture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, terrain.columns(), terrain.rows(), 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, terrain.colors());
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
long bytes() {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
void bind() {
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
}
|
||||
|
||||
void delete() {
|
||||
glDeleteTextures(texture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.studio.view.GuiHost;
|
||||
import org.lwjgl.PointerBuffer;
|
||||
import org.lwjgl.egl.EGL;
|
||||
import org.lwjgl.opengl.GL;
|
||||
import org.lwjgl.system.Configuration;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.lwjgl.egl.EGL15.*;
|
||||
import static org.lwjgl.egl.EXTDeviceEnumeration.eglQueryDevicesEXT;
|
||||
import static org.lwjgl.egl.EXTDeviceQuery.eglQueryDeviceStringEXT;
|
||||
import static org.lwjgl.egl.EXTPlatformBase.eglGetPlatformDisplayEXT;
|
||||
import static org.lwjgl.egl.EXTPlatformDevice.EGL_PLATFORM_DEVICE_EXT;
|
||||
import static org.lwjgl.opengl.GL11.GL_RENDERER;
|
||||
import static org.lwjgl.opengl.GL11.glGetString;
|
||||
|
||||
/**
|
||||
* An OpenGL context on a graphics card, and the one thread allowed to use it. Where Swing draws on X, the context draws
|
||||
* straight into Swing's windows through GLX. Anywhere else, or when GLX only draws in software, it is made through
|
||||
* EGL without any window: EGL lists the cards and the first one that isn't drawn in software is taken.
|
||||
*/
|
||||
final class GpuContext {
|
||||
private static final String THREAD_NAME = "Iris GPU";
|
||||
private static final long RELEASE_WAIT_SECONDS = 5L;
|
||||
private static final int OPENGL_MAJOR_VERSION = 3;
|
||||
private static final int OPENGL_MINOR_VERSION = 3;
|
||||
private static final String SOFTWARE_DEVICE_EXTENSION = "EGL_MESA_device_software";
|
||||
private static final Set<String> REQUIRED_CLIENT_EXTENSIONS = Set.of("EGL_EXT_device_enumeration", "EGL_EXT_device_query",
|
||||
"EGL_EXT_platform_base", "EGL_EXT_platform_device");
|
||||
|
||||
private static volatile GpuContext shared;
|
||||
private static boolean unavailable;
|
||||
|
||||
private final ExecutorService thread = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread gpuThread = new Thread(runnable, THREAD_NAME);
|
||||
gpuThread.setDaemon(true);
|
||||
return gpuThread;
|
||||
});
|
||||
private String renderer;
|
||||
private GlxDisplay windows;
|
||||
private volatile boolean drawsIntoWindows;
|
||||
private long eglDisplay = EGL_NO_DISPLAY;
|
||||
private long eglContext = EGL_NO_CONTEXT;
|
||||
|
||||
private GpuContext() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether frames can go straight into windows, as far as is known without opening the context.
|
||||
*/
|
||||
static boolean drawsIntoWindows() {
|
||||
GpuContext context = shared;
|
||||
return context != null && context.drawsIntoWindows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only for the context's thread.
|
||||
*
|
||||
* @return the connection drawing into windows, or nothing when frames are only drawn off screen
|
||||
*/
|
||||
Optional<GlxDisplay> windows() {
|
||||
return drawsIntoWindows ? Optional.of(windows) : Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops drawing into a window about to be destroyed, waiting for a frame going into it to finish first.
|
||||
*/
|
||||
static void forgetWindow(long window) {
|
||||
GpuContext context = shared;
|
||||
if (context == null || context.windows == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.call(() -> {
|
||||
context.windows.forget(window);
|
||||
return null;
|
||||
});
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (RuntimeException failure) {
|
||||
IrisLogging.reportError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
// Off-screen frames still work in the same context, read back like on a machine without GLX.
|
||||
void stopDrawingIntoWindows(RuntimeException failure) {
|
||||
if (!drawsIntoWindows) {
|
||||
return;
|
||||
}
|
||||
drawsIntoWindows = false;
|
||||
IrisLogging.reportError(failure);
|
||||
IrisLogging.warn("3D views stop drawing straight into windows: %s", failure.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the context every view shares, opened on first use, or nothing when this machine has no usable card
|
||||
*/
|
||||
static synchronized Optional<GpuContext> shared() {
|
||||
if (shared == null && !unavailable) {
|
||||
shared = open();
|
||||
unavailable = shared == null;
|
||||
}
|
||||
return Optional.ofNullable(shared);
|
||||
}
|
||||
|
||||
private static GpuContext open() {
|
||||
GpuContext context = new GpuContext();
|
||||
try {
|
||||
context.thread.submit(context::openOnCard).get();
|
||||
IrisLogging.info(context.drawsIntoWindows ? "3D views draw on %s, straight into their windows" : "3D views draw on %s",
|
||||
context.renderer);
|
||||
releaseOnShutdown();
|
||||
return context;
|
||||
} catch (ExecutionException failure) {
|
||||
IrisLogging.warn("3D views draw without a graphics card: %s", failure.getCause());
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
context.thread.shutdownNow();
|
||||
return null;
|
||||
}
|
||||
|
||||
// NVIDIA's driver crashes the JVM on its way out when a context is still current on some thread.
|
||||
private static void releaseOnShutdown() {
|
||||
GuiHost.runAtShutdown(GpuContext::releaseShared);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets go of the card once frames already asked for are drawn. Frames asked for later are drawn without it.
|
||||
*/
|
||||
static synchronized void releaseShared() {
|
||||
GpuContext context = shared;
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
context.thread.execute(context::release);
|
||||
context.thread.shutdown();
|
||||
try {
|
||||
if (!context.thread.awaitTermination(RELEASE_WAIT_SECONDS, TimeUnit.SECONDS)) {
|
||||
IrisLogging.warn("3D views didn't let go of the graphics card in time");
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private void release() {
|
||||
drawsIntoWindows = false;
|
||||
if (windows != null) {
|
||||
windows.close();
|
||||
return;
|
||||
}
|
||||
eglMakeCurrent(eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||
eglDestroyContext(eglDisplay, eglContext);
|
||||
eglTerminate(eglDisplay);
|
||||
eglReleaseThread();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs {@code work} on the context's thread and waits for it.
|
||||
*/
|
||||
<T> T call(Callable<T> work) throws InterruptedException {
|
||||
try {
|
||||
return thread.submit(work).get();
|
||||
} catch (ExecutionException failure) {
|
||||
throw new IllegalStateException("Drawing on the graphics card failed", failure.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
// GL would otherwise look for a GLX library as soon as it loads, which a context from EGL doesn't use.
|
||||
private Void openOnCard() {
|
||||
Configuration.OPENGL_EXPLICIT_INIT.set(true);
|
||||
try {
|
||||
windows = GlxDisplay.open();
|
||||
renderer = windows.renderer();
|
||||
drawsIntoWindows = true;
|
||||
return null;
|
||||
} catch (RuntimeException | LinkageError noWindows) {
|
||||
IrisLogging.info("3D views can't draw straight into windows: %s", noWindows);
|
||||
}
|
||||
openWithoutWindows();
|
||||
return null;
|
||||
}
|
||||
|
||||
private void openWithoutWindows() {
|
||||
if (!canListDevices()) {
|
||||
throw new IllegalStateException("EGL can't list graphics cards");
|
||||
}
|
||||
GL.create(EGL.getFunctionProvider());
|
||||
for (long device : devices()) {
|
||||
if (!isDrawnInSoftware(device) && makeCurrentOn(device)) {
|
||||
GL.createCapabilities();
|
||||
renderer = glGetString(GL_RENDERER);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("No graphics card supports OpenGL " + OPENGL_MAJOR_VERSION + "." + OPENGL_MINOR_VERSION);
|
||||
}
|
||||
|
||||
// LWJGL's own capability flags read false for these even where the library has them, so the list is asked for.
|
||||
private static boolean canListDevices() {
|
||||
String extensions = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS);
|
||||
return extensions != null && Arrays.asList(extensions.split(" ")).containsAll(REQUIRED_CLIENT_EXTENSIONS);
|
||||
}
|
||||
|
||||
private static long[] devices() {
|
||||
int[] count = new int[1];
|
||||
eglQueryDevicesEXT(null, count);
|
||||
PointerBuffer found = PointerBuffer.allocateDirect(count[0]);
|
||||
eglQueryDevicesEXT(found, count);
|
||||
long[] devices = new long[count[0]];
|
||||
for (int device = 0; device < devices.length; device++) {
|
||||
devices[device] = found.get(device);
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
private static boolean isDrawnInSoftware(long device) {
|
||||
String extensions = eglQueryDeviceStringEXT(device, EGL_EXTENSIONS);
|
||||
return extensions != null && extensions.contains(SOFTWARE_DEVICE_EXTENSION);
|
||||
}
|
||||
|
||||
// Nothing is drawn to a surface, only to framebuffers of the context's own, so the context needs none. Configs
|
||||
// default to window surfaces, which a card without a display has none of.
|
||||
private boolean makeCurrentOn(long device) {
|
||||
long display = eglGetPlatformDisplayEXT(EGL_PLATFORM_DEVICE_EXT, device, (int[]) null);
|
||||
if (display == EGL_NO_DISPLAY || !eglInitialize(display, new int[1], new int[1])) {
|
||||
return false;
|
||||
}
|
||||
PointerBuffer config = PointerBuffer.allocateDirect(1);
|
||||
int[] configCount = new int[1];
|
||||
int[] wanted = {EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, EGL_NONE};
|
||||
if (!eglChooseConfig(display, wanted, config, configCount) || configCount[0] == 0
|
||||
|| !eglBindAPI(EGL_OPENGL_API)) {
|
||||
eglTerminate(display);
|
||||
return false;
|
||||
}
|
||||
long context = eglCreateContext(display, config.get(0), EGL_NO_CONTEXT, new int[]{
|
||||
EGL_CONTEXT_MAJOR_VERSION, OPENGL_MAJOR_VERSION,
|
||||
EGL_CONTEXT_MINOR_VERSION, OPENGL_MINOR_VERSION,
|
||||
EGL_CONTEXT_OPENGL_PROFILE_MASK, EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT,
|
||||
EGL_NONE});
|
||||
if (context == EGL_NO_CONTEXT || !eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context)) {
|
||||
eglTerminate(display);
|
||||
return false;
|
||||
}
|
||||
eglDisplay = display;
|
||||
eglContext = context;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import org.lwjgl.system.MemoryUtil;
|
||||
|
||||
import java.nio.IntBuffer;
|
||||
|
||||
import static org.lwjgl.opengl.GL33.*;
|
||||
|
||||
/**
|
||||
* A layer's block faces on the graphics card, four ints each: first column, first row, bottom, and run with height.
|
||||
* The vertex shader reads them from a buffer texture and works out each face's two triangles itself, which cards draw
|
||||
* far faster than millions of tiny instances. Only for the GPU thread.
|
||||
*/
|
||||
final class GpuFaces {
|
||||
static final int CORNERS_PER_FACE = 6;
|
||||
private static final int INTS_PER_FACE = 4;
|
||||
private static final int BYTES_PER_FACE = INTS_PER_FACE * Integer.BYTES;
|
||||
private static final int FACES_PER_UPLOAD = 1 << 20;
|
||||
private static final int LARGEST_PACKED = 0xFFFF;
|
||||
private static final BlockFaces.Facing[] FACINGS = BlockFaces.Facing.values();
|
||||
private static final BlockFaces.Kind[] KINDS = BlockFaces.Kind.values();
|
||||
|
||||
// Copied rather than kept, so the faces left in memory can be dropped once nothing else uses them.
|
||||
private final int[][] firstOf = new int[KINDS.length][FACINGS.length];
|
||||
private final int[][] countOf = new int[KINDS.length][FACINGS.length];
|
||||
private final long bytes;
|
||||
private final int buffer = glGenBuffers();
|
||||
private final int texture = glGenTextures();
|
||||
|
||||
private GpuFaces(BlockFaces faces) {
|
||||
for (BlockFaces.Kind kind : KINDS) {
|
||||
for (BlockFaces.Facing facing : FACINGS) {
|
||||
firstOf[kind.ordinal()][facing.ordinal()] = faces.first(kind, facing);
|
||||
countOf[kind.ordinal()][facing.ordinal()] = faces.count(kind, facing);
|
||||
}
|
||||
}
|
||||
bytes = (long) faces.count() * BYTES_PER_FACE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException when there are more faces than the card's buffer textures hold, or a face runs
|
||||
* or reaches further than four ints can hold
|
||||
* @param mostFaces the card's GL_MAX_TEXTURE_BUFFER_SIZE
|
||||
*/
|
||||
static GpuFaces upload(BlockFaces faces, int mostFaces) {
|
||||
if (faces.count() > mostFaces || (long) faces.count() * CORNERS_PER_FACE > Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException(faces.count() + " faces are more than the graphics card can hold");
|
||||
}
|
||||
GpuFaces uploaded = new GpuFaces(faces);
|
||||
try {
|
||||
uploaded.fill(faces);
|
||||
} catch (RuntimeException failure) {
|
||||
uploaded.delete();
|
||||
throw failure;
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
// Packed a slice at a time, so large terrain doesn't need all of it twice in memory.
|
||||
private void fill(BlockFaces faces) {
|
||||
glBindBuffer(GL_TEXTURE_BUFFER, buffer);
|
||||
glBufferData(GL_TEXTURE_BUFFER, (long) Math.max(1, faces.count()) * BYTES_PER_FACE, GL_STATIC_DRAW);
|
||||
IntBuffer packed = MemoryUtil.memAllocInt(Math.max(1, Math.min(faces.count(), FACES_PER_UPLOAD)) * INTS_PER_FACE);
|
||||
try {
|
||||
for (int from = 0; from < faces.count(); from += FACES_PER_UPLOAD) {
|
||||
int to = Math.min(faces.count(), from + FACES_PER_UPLOAD);
|
||||
pack(faces, packed, from, to);
|
||||
glBufferSubData(GL_TEXTURE_BUFFER, (long) from * BYTES_PER_FACE, packed);
|
||||
}
|
||||
} finally {
|
||||
MemoryUtil.memFree(packed);
|
||||
}
|
||||
glBindTexture(GL_TEXTURE_BUFFER, texture);
|
||||
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32I, buffer);
|
||||
}
|
||||
|
||||
private static void pack(BlockFaces faces, IntBuffer packed, int fromFace, int toFace) {
|
||||
packed.clear();
|
||||
for (int face = fromFace; face < toFace; face++) {
|
||||
int run = faces.columns(face);
|
||||
int reach = faces.height(face);
|
||||
if (run > LARGEST_PACKED || reach > LARGEST_PACKED) {
|
||||
throw new IllegalArgumentException("A face of " + run + " by " + reach + " is too large to draw on the graphics card");
|
||||
}
|
||||
packed.put(faces.firstColumn(face)).put(faces.firstRow(face)).put(faces.bottomY(face)).put(run | reach << 16);
|
||||
}
|
||||
packed.flip();
|
||||
}
|
||||
|
||||
void bind() {
|
||||
glBindTexture(GL_TEXTURE_BUFFER, texture);
|
||||
}
|
||||
|
||||
int first(BlockFaces.Kind kind, BlockFaces.Facing facing) {
|
||||
return firstOf[kind.ordinal()][facing.ordinal()];
|
||||
}
|
||||
|
||||
int count(BlockFaces.Kind kind, BlockFaces.Facing facing) {
|
||||
return countOf[kind.ordinal()][facing.ordinal()];
|
||||
}
|
||||
|
||||
long bytes() {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
void delete() {
|
||||
glDeleteTextures(texture);
|
||||
glDeleteBuffers(buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
import static org.lwjgl.opengl.GL11.GL_DEPTH_COMPONENT;
|
||||
import static org.lwjgl.opengl.GL11.GL_FLOAT;
|
||||
import static org.lwjgl.opengl.GL11.glReadPixels;
|
||||
|
||||
/**
|
||||
* Where the graphics card draws a frame: a picture read back for Swing, or straight into a window. Only for the GPU
|
||||
* thread.
|
||||
*/
|
||||
interface GpuFrameTarget {
|
||||
double FARTHEST_DEPTH = 1D;
|
||||
|
||||
/**
|
||||
* Makes the target the one drawn into, at the given size.
|
||||
*/
|
||||
void bindSized(int width, int height);
|
||||
|
||||
/**
|
||||
* Ends the frame drawn since {@link #bindSized}.
|
||||
*
|
||||
* @param depthRange the range the clip matrix was made for
|
||||
*/
|
||||
OrbitPicture finish(OrbitProjection projection, double depthRange);
|
||||
|
||||
/**
|
||||
* Reads the one depth a picture keeps, the middle's, from what is bound.
|
||||
*/
|
||||
static OptionalDouble depthInMiddle(OrbitProjection projection, double depthRange, int width, int height) {
|
||||
int middleX = (int) projection.screenCenterX();
|
||||
int middleY = (int) projection.screenCenterY();
|
||||
if (middleX < 0 || middleY < 0 || middleX >= width || middleY >= height) {
|
||||
return OptionalDouble.empty();
|
||||
}
|
||||
float[] middleDepth = new float[1];
|
||||
glReadPixels(middleX, height - 1 - middleY, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, middleDepth);
|
||||
return OrbitPicture.finite(blockDepth(middleDepth[0], depthRange));
|
||||
}
|
||||
|
||||
// The depth buffer holds 0 at the near plane and 1 at the far one, nearer being smaller.
|
||||
private static double blockDepth(float bufferDepth, double depthRange) {
|
||||
if (bufferDepth >= FARTHEST_DEPTH) {
|
||||
return Double.NEGATIVE_INFINITY;
|
||||
}
|
||||
return -(2D * bufferDepth - 1D) * depthRange;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
|
||||
import static org.lwjgl.opengl.GL30.*;
|
||||
|
||||
/**
|
||||
* A picture the graphics card draws into instead of a window, read back as an {@link OrbitPicture} so Swing shows it
|
||||
* like any other. Only for the GPU thread.
|
||||
*/
|
||||
final class GpuFramebuffer implements GpuFrameTarget {
|
||||
private final int framebuffer = glGenFramebuffers();
|
||||
private final int colorBuffer = glGenRenderbuffers();
|
||||
private final int depthBuffer = glGenRenderbuffers();
|
||||
private int width;
|
||||
private int height;
|
||||
private int[] colorRows = new int[0];
|
||||
|
||||
@Override
|
||||
public void bindSized(int nextWidth, int nextHeight) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
|
||||
if (nextWidth != width || nextHeight != height) {
|
||||
resizeTo(nextWidth, nextHeight);
|
||||
}
|
||||
glViewport(0, 0, width, height);
|
||||
}
|
||||
|
||||
private void resizeTo(int nextWidth, int nextHeight) {
|
||||
width = nextWidth;
|
||||
height = nextHeight;
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, colorBuffer);
|
||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, width, height);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorBuffer);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, depthBuffer);
|
||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, width, height);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthBuffer);
|
||||
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
|
||||
throw new IllegalStateException("The graphics card can't draw a " + width + "x" + height + " picture");
|
||||
}
|
||||
colorRows = new int[width * height];
|
||||
}
|
||||
|
||||
// Reads what was drawn. Only the middle's depth is read back, the one depth a picture keeps.
|
||||
@Override
|
||||
public OrbitPicture finish(OrbitProjection projection, double depthRange) {
|
||||
glReadPixels(0, 0, width, height, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, colorRows);
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||
for (int row = 0; row < height; row++) {
|
||||
System.arraycopy(colorRows, (height - 1 - row) * width, pixels, row * width, width);
|
||||
}
|
||||
return OrbitPicture.whole(image, GpuFrameTarget.depthInMiddle(projection, depthRange, width, height));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.lwjgl.opengl.GL33.*;
|
||||
|
||||
/**
|
||||
* Runs a 3D view's frame on the graphics card, straight into the view's window where frames can go there, or else into
|
||||
* a picture read back for Swing. A card that fails once isn't used again, as it would most likely fail every frame,
|
||||
* each time only after costing the frame its time.
|
||||
*/
|
||||
final class GpuFrames {
|
||||
private static final double COLOR_CHANNEL_MAX = 255D;
|
||||
|
||||
private static GpuFramebuffer offScreen;
|
||||
private static volatile boolean failed;
|
||||
|
||||
private GpuFrames() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame drawn on the card's thread.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
interface Drawing {
|
||||
/**
|
||||
* @return the frame, or nothing when the card can't draw this scene and it should be drawn without one
|
||||
*/
|
||||
Optional<OrbitPicture> drawInto(GpuFrameTarget target);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the picture, or nothing when the caller should draw it without a card
|
||||
*/
|
||||
static Optional<OrbitPicture> drawPicture(Drawing drawing) {
|
||||
return onCard(gpu -> drawing.drawInto(offScreenFramebuffer()));
|
||||
}
|
||||
|
||||
static boolean canDrawOnScreen() {
|
||||
return !failed && GpuContext.drawsIntoWindows();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the frame, or nothing when it can't go straight into the window and the caller should draw a picture
|
||||
*/
|
||||
static Optional<OrbitPicture> drawOnScreen(ScreenTarget screen, Drawing drawing) {
|
||||
return onCard(gpu -> {
|
||||
Optional<GlxDisplay> windows = gpu.windows();
|
||||
long window = screen.canvas().window();
|
||||
if (windows.isEmpty() || window == 0L) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
if (!windows.get().canDrawInto(screen.canvas().visualId())) {
|
||||
throw new IllegalStateException("The view's window has another visual than the one GLX draws with");
|
||||
}
|
||||
if (!windows.get().drawInto(window)) {
|
||||
throw new IllegalStateException("GLX can't draw into the view's window");
|
||||
}
|
||||
return drawing.drawInto(new GpuWindowFrame(windows.get(), window, screen.seeThrough()));
|
||||
} catch (RuntimeException failure) {
|
||||
gpu.stopDrawingIntoWindows(failure);
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void failOnError() {
|
||||
int error = glGetError();
|
||||
if (error != GL_NO_ERROR) {
|
||||
throw new IllegalStateException("OpenGL error 0x" + Integer.toHexString(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears to the views' background, and sets up for opaque drawing with depth.
|
||||
*/
|
||||
static void clearToBackground() {
|
||||
int background = StudioTheme.EDITOR_BACKGROUND.getRGB();
|
||||
glClearColor(channel(background, 16), channel(background, 8), channel(background, 0), 1F);
|
||||
glClearDepth(1D);
|
||||
glDepthMask(true);
|
||||
glEnable(GL_DEPTH_TEST);
|
||||
glDepthFunc(GL_LESS);
|
||||
glDisable(GL_BLEND);
|
||||
glDisable(GL_CULL_FACE);
|
||||
glDisable(GL_POLYGON_OFFSET_FILL);
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
}
|
||||
|
||||
static float channel(int color, int shift) {
|
||||
return (float) (((color >>> shift) & 0xFF) / COLOR_CHANNEL_MAX);
|
||||
}
|
||||
|
||||
private static GpuFramebuffer offScreenFramebuffer() {
|
||||
if (offScreen == null) {
|
||||
offScreen = new GpuFramebuffer();
|
||||
}
|
||||
return offScreen;
|
||||
}
|
||||
|
||||
private static Optional<OrbitPicture> onCard(CardWork work) {
|
||||
if (failed) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Optional<GpuContext> gpu = GpuContext.shared();
|
||||
if (gpu.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return gpu.get().call(() -> work.drawOn(gpu.get()));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return Optional.empty();
|
||||
} catch (RuntimeException failure) {
|
||||
stopUsingCard(failure);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static void stopUsingCard(RuntimeException failure) {
|
||||
failed = true;
|
||||
IrisLogging.reportError(failure);
|
||||
IrisLogging.warn("3D views draw without a graphics card from now on: %s", failure.getCause() == null ? failure : failure.getCause());
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface CardWork {
|
||||
Optional<OrbitPicture> drawOn(GpuContext gpu);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Whether the graphics card library the 3D views draw with is on the classpath. Mod loaders get it from the game,
|
||||
* which ships only part of it, so the views check here before touching any class that links against it.
|
||||
*/
|
||||
final class GpuLibrary {
|
||||
private static final List<String> REQUIRED_CLASSES = List.of(
|
||||
"org.lwjgl.system.MemoryStack",
|
||||
"org.lwjgl.opengl.GL",
|
||||
"org.lwjgl.egl.EGL",
|
||||
"org.lwjgl.system.jawt.JAWT"
|
||||
);
|
||||
private static final boolean LOADED = findRequiredClasses();
|
||||
|
||||
private GpuLibrary() {
|
||||
}
|
||||
|
||||
static boolean isLoaded() {
|
||||
return LOADED;
|
||||
}
|
||||
|
||||
private static boolean findRequiredClasses() {
|
||||
ClassLoader loader = GpuLibrary.class.getClassLoader();
|
||||
for (String className : REQUIRED_CLASSES) {
|
||||
try {
|
||||
Class.forName(className, false, loader);
|
||||
} catch (ClassNotFoundException | LinkageError missing) {
|
||||
IrisLogging.info("3D views draw without a graphics card: %s isn't on the classpath", className);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.BlockModel.Face;
|
||||
import art.arcane.iris.studio.map.BlockModel.TextureMapping;
|
||||
import art.arcane.iris.studio.map.BlockTextures.BlockTexture;
|
||||
import org.lwjgl.system.MemoryUtil;
|
||||
|
||||
import java.nio.FloatBuffer;
|
||||
import java.nio.IntBuffer;
|
||||
import java.util.Arrays;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.lwjgl.opengl.GL33.*;
|
||||
|
||||
/**
|
||||
* An object mesh's quads and the texels of their textures on the graphics card, each in a buffer texture the shaders
|
||||
* read from. Quads come solid first, then the cut-away ghost, then the see-through ones, whose drawing order is sent
|
||||
* again every frame, farthest first. Only for the GPU thread.
|
||||
*/
|
||||
final class GpuObjectMesh implements GpuCache.Upload {
|
||||
static final int TEXELS_PER_QUAD = 7;
|
||||
private static final int FLOATS_PER_QUAD = TEXELS_PER_QUAD * 4;
|
||||
private static final int OPAQUE_ALPHA = 0xFF;
|
||||
|
||||
private final Vec3 anchor;
|
||||
private final int solidCount;
|
||||
private final int ghostCount;
|
||||
private final int seeThroughCount;
|
||||
private final float[] seeThroughCenters;
|
||||
private final long bytes;
|
||||
private final int quadBuffer = glGenBuffers();
|
||||
private final int quadTexture = glGenTextures();
|
||||
private final int texelBuffer = glGenBuffers();
|
||||
private final int texelTexture = glGenTextures();
|
||||
private final int orderBuffer = glGenBuffers();
|
||||
private final int orderTexture = glGenTextures();
|
||||
|
||||
private GpuObjectMesh(ObjectMesh mesh, int texelCount) {
|
||||
ObjectMesh.Bounds bounds = mesh.bounds();
|
||||
anchor = new Vec3(bounds.minX(), bounds.minY(), bounds.minZ());
|
||||
solidCount = mesh.solidQuads().size();
|
||||
ghostCount = mesh.cutAwayQuads().size();
|
||||
seeThroughCount = mesh.translucentQuads().size();
|
||||
seeThroughCenters = centersOf(mesh.translucentQuads());
|
||||
bytes = (long) quadCount() * FLOATS_PER_QUAD * Float.BYTES + (long) texelCount * Integer.BYTES + (long) seeThroughCount * Integer.BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mostTexels the card's GL_MAX_TEXTURE_BUFFER_SIZE
|
||||
* @return the uploaded mesh, or null when it is more than the card's buffer textures hold
|
||||
*/
|
||||
static GpuObjectMesh upload(ObjectMesh mesh, int mostTexels) {
|
||||
Map<BlockTexture, Integer> textureOffsets = new IdentityHashMap<>();
|
||||
int texelCount = placeTextures(mesh, textureOffsets);
|
||||
long quadTexels = (long) (mesh.solidQuads().size() + mesh.cutAwayQuads().size() + mesh.translucentQuads().size()) * TEXELS_PER_QUAD;
|
||||
if (quadTexels > mostTexels || texelCount > mostTexels || quadTexels * 6L > Integer.MAX_VALUE) {
|
||||
return null;
|
||||
}
|
||||
GpuObjectMesh uploaded = new GpuObjectMesh(mesh, texelCount);
|
||||
try {
|
||||
uploaded.fillQuads(mesh, textureOffsets);
|
||||
uploaded.fillTexels(textureOffsets, texelCount);
|
||||
uploaded.prepareOrder();
|
||||
} catch (RuntimeException failure) {
|
||||
uploaded.delete();
|
||||
throw failure;
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
private static int placeTextures(ObjectMesh mesh, Map<BlockTexture, Integer> offsets) {
|
||||
int texelCount = 0;
|
||||
for (List<ObjectMesh.Quad> quads : List.of(mesh.solidQuads(), mesh.cutAwayQuads(), mesh.translucentQuads())) {
|
||||
for (ObjectMesh.Quad quad : quads) {
|
||||
BlockTexture texture = quad.face().texture();
|
||||
if (!offsets.containsKey(texture)) {
|
||||
offsets.put(texture, texelCount);
|
||||
texelCount += texture.width() * texture.height();
|
||||
}
|
||||
}
|
||||
}
|
||||
return texelCount;
|
||||
}
|
||||
|
||||
private float[] centersOf(List<ObjectMesh.Quad> quads) {
|
||||
float[] centers = new float[quads.size() * 3];
|
||||
for (int index = 0; index < quads.size(); index++) {
|
||||
ObjectMesh.Quad quad = quads.get(index);
|
||||
Face face = quad.face();
|
||||
centers[index * 3] = (float) (quad.originX() - anchor.x() + (face.edgeU().x() + face.edgeV().x()) / 2D);
|
||||
centers[index * 3 + 1] = (float) (quad.originY() - anchor.y() + (face.edgeU().y() + face.edgeV().y()) / 2D);
|
||||
centers[index * 3 + 2] = (float) (quad.originZ() - anchor.z() + (face.edgeU().z() + face.edgeV().z()) / 2D);
|
||||
}
|
||||
return centers;
|
||||
}
|
||||
|
||||
private void fillQuads(ObjectMesh mesh, Map<BlockTexture, Integer> textureOffsets) {
|
||||
FloatBuffer packed = MemoryUtil.memAllocFloat(Math.max(1, quadCount()) * FLOATS_PER_QUAD);
|
||||
try {
|
||||
for (List<ObjectMesh.Quad> quads : List.of(mesh.solidQuads(), mesh.cutAwayQuads(), mesh.translucentQuads())) {
|
||||
for (ObjectMesh.Quad quad : quads) {
|
||||
pack(quad, textureOffsets.get(quad.face().texture()), packed);
|
||||
}
|
||||
}
|
||||
packed.flip();
|
||||
glBindBuffer(GL_TEXTURE_BUFFER, quadBuffer);
|
||||
glBufferData(GL_TEXTURE_BUFFER, packed, GL_STATIC_DRAW);
|
||||
} finally {
|
||||
MemoryUtil.memFree(packed);
|
||||
}
|
||||
glBindTexture(GL_TEXTURE_BUFFER, quadTexture);
|
||||
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32F, quadBuffer);
|
||||
}
|
||||
|
||||
// Whole numbers ride along as float bits, read back exactly by the shader.
|
||||
private void pack(ObjectMesh.Quad quad, int textureOffset, FloatBuffer packed) {
|
||||
Face face = quad.face();
|
||||
BlockTexture texture = face.texture();
|
||||
TextureMapping mapping = face.mapping();
|
||||
ObjectRenderer.TexelRange columns = ObjectRenderer.TexelRange.of(mapping.u(), mapping.uAlongU(), mapping.uAlongV(), texture.width());
|
||||
ObjectRenderer.TexelRange rows = ObjectRenderer.TexelRange.of(mapping.v(), mapping.vAlongU(), mapping.vAlongV(), texture.height());
|
||||
put(packed, quad.originX() - anchor.x(), quad.originY() - anchor.y(), quad.originZ() - anchor.z()).put((float) face.shade());
|
||||
put(packed, face.edgeU()).put((float) quad.opacity());
|
||||
put(packed, face.edgeV()).put(Float.intBitsToFloat(textureOffset));
|
||||
put(packed, face.normal()).put(Float.intBitsToFloat(texture.width()));
|
||||
put(packed, mapping.u(), mapping.uAlongU(), mapping.uAlongV()).put(Float.intBitsToFloat(texture.height()));
|
||||
put(packed, mapping.v(), mapping.vAlongU(), mapping.vAlongV()).put(0F);
|
||||
packed.put(Float.intBitsToFloat(columns.first())).put(Float.intBitsToFloat(columns.last()))
|
||||
.put(Float.intBitsToFloat(rows.first())).put(Float.intBitsToFloat(rows.last()));
|
||||
}
|
||||
|
||||
private static FloatBuffer put(FloatBuffer packed, Vec3 vector) {
|
||||
return put(packed, vector.x(), vector.y(), vector.z());
|
||||
}
|
||||
|
||||
private static FloatBuffer put(FloatBuffer packed, double x, double y, double z) {
|
||||
return packed.put((float) x).put((float) y).put((float) z);
|
||||
}
|
||||
|
||||
// Texels the game doesn't draw are fully transparent, so the shader skips them.
|
||||
private void fillTexels(Map<BlockTexture, Integer> textureOffsets, int texelCount) {
|
||||
IntBuffer packed = MemoryUtil.memAllocInt(Math.max(1, texelCount));
|
||||
try {
|
||||
textureOffsets.forEach((texture, offset) -> {
|
||||
int[] alphas = texture.alphas();
|
||||
for (int texel = 0; texel < texture.width() * texture.height(); texel++) {
|
||||
int alpha = !texture.opaque()[texel] ? 0 : alphas == null ? OPAQUE_ALPHA : alphas[texel];
|
||||
packed.put(offset + texel, rgba(texture.pixels()[texel], alpha));
|
||||
}
|
||||
});
|
||||
glBindBuffer(GL_TEXTURE_BUFFER, texelBuffer);
|
||||
glBufferData(GL_TEXTURE_BUFFER, packed, GL_STATIC_DRAW);
|
||||
} finally {
|
||||
MemoryUtil.memFree(packed);
|
||||
}
|
||||
glBindTexture(GL_TEXTURE_BUFFER, texelTexture);
|
||||
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA8, texelBuffer);
|
||||
}
|
||||
|
||||
// GL_RGBA8 takes red from the lowest byte, which is the first in memory on the little-endian machines LWJGL runs on.
|
||||
private static int rgba(int rgb, int alpha) {
|
||||
return ((rgb >>> 16) & 0xFF) | (rgb & 0xFF00) | ((rgb & 0xFF) << 16) | (alpha << 24);
|
||||
}
|
||||
|
||||
private void prepareOrder() {
|
||||
glBindBuffer(GL_TEXTURE_BUFFER, orderBuffer);
|
||||
glBufferData(GL_TEXTURE_BUFFER, (long) Math.max(1, seeThroughCount) * Integer.BYTES, GL_STREAM_DRAW);
|
||||
glBindTexture(GL_TEXTURE_BUFFER, orderTexture);
|
||||
glTexBuffer(GL_TEXTURE_BUFFER, GL_R32I, orderBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the see-through quads' drawing order, farthest from the camera first, keeping the mesh's order between
|
||||
* quads at the same depth.
|
||||
*
|
||||
* @param towardCamera how far each block along x, y and z brings a point toward the camera
|
||||
*/
|
||||
void sortSeeThroughFarthestFirst(Vec3 towardCamera) {
|
||||
if (seeThroughCount == 0) {
|
||||
return;
|
||||
}
|
||||
long[] depthThenIndex = new long[seeThroughCount];
|
||||
for (int index = 0; index < seeThroughCount; index++) {
|
||||
float depth = (float) (seeThroughCenters[index * 3] * towardCamera.x() + seeThroughCenters[index * 3 + 1] * towardCamera.y()
|
||||
+ seeThroughCenters[index * 3 + 2] * towardCamera.z());
|
||||
depthThenIndex[index] = (long) sortableBits(depth) << 32 | index;
|
||||
}
|
||||
Arrays.sort(depthThenIndex);
|
||||
IntBuffer order = MemoryUtil.memAllocInt(seeThroughCount);
|
||||
try {
|
||||
int firstSeeThrough = firstSeeThrough();
|
||||
for (long entry : depthThenIndex) {
|
||||
order.put(firstSeeThrough + (int) entry);
|
||||
}
|
||||
order.flip();
|
||||
glBindBuffer(GL_TEXTURE_BUFFER, orderBuffer);
|
||||
glBufferSubData(GL_TEXTURE_BUFFER, 0L, order);
|
||||
} finally {
|
||||
MemoryUtil.memFree(order);
|
||||
}
|
||||
}
|
||||
|
||||
// Ordered as signed ints the way the floats are ordered.
|
||||
private static int sortableBits(float value) {
|
||||
int bits = Float.floatToIntBits(value);
|
||||
return bits < 0 ? bits ^ Integer.MAX_VALUE : bits;
|
||||
}
|
||||
|
||||
void bindQuads() {
|
||||
glBindTexture(GL_TEXTURE_BUFFER, quadTexture);
|
||||
}
|
||||
|
||||
void bindTexels() {
|
||||
glBindTexture(GL_TEXTURE_BUFFER, texelTexture);
|
||||
}
|
||||
|
||||
void bindOrder() {
|
||||
glBindTexture(GL_TEXTURE_BUFFER, orderTexture);
|
||||
}
|
||||
|
||||
Vec3 anchor() {
|
||||
return anchor;
|
||||
}
|
||||
|
||||
int solidCount() {
|
||||
return solidCount;
|
||||
}
|
||||
|
||||
int firstGhost() {
|
||||
return solidCount;
|
||||
}
|
||||
|
||||
int ghostCount() {
|
||||
return ghostCount;
|
||||
}
|
||||
|
||||
int firstSeeThrough() {
|
||||
return solidCount + ghostCount;
|
||||
}
|
||||
|
||||
int seeThroughCount() {
|
||||
return seeThroughCount;
|
||||
}
|
||||
|
||||
private int quadCount() {
|
||||
return solidCount + ghostCount + seeThroughCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long bytes() {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete() {
|
||||
glDeleteTextures(new int[]{quadTexture, texelTexture, orderTexture});
|
||||
glDeleteBuffers(new int[]{quadBuffer, texelBuffer, orderBuffer});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.lwjgl.opengl.GL33.*;
|
||||
|
||||
/**
|
||||
* Draws an object mesh on the graphics card, looking the way {@link ObjectRenderer} draws it. That one still draws on
|
||||
* machines without a card, and the thumbnails. The mesh stays on the card while it is shown, so a frame only sends the
|
||||
* camera and the see-through quads' order.
|
||||
*/
|
||||
final class GpuObjectRenderer {
|
||||
private static final double DEPTH_MARGIN_BLOCKS = 4D;
|
||||
// Several large objects, well within what any card able to draw them has.
|
||||
private static final long MOST_UPLOADED_BYTES = 256L << 20;
|
||||
private static final int CORNERS_PER_QUAD = 6;
|
||||
private static final int ENDS_PER_LINE = 2;
|
||||
private static final int GRID_DIRECTIONS = 2;
|
||||
|
||||
private static final String QUAD_VERTEX_SHADER = """
|
||||
#version 330 core
|
||||
const int[6] CORNERS = int[6](0, 1, 2, 2, 1, 3);
|
||||
const int TEXELS_PER_QUAD = %d;
|
||||
uniform samplerBuffer quads;
|
||||
uniform isamplerBuffer order;
|
||||
uniform bool inOrder;
|
||||
uniform int firstQuad;
|
||||
uniform mat4 toClip;
|
||||
uniform vec3 anchorFromCenter;
|
||||
uniform vec3 towardCamera;
|
||||
out vec2 texturePosition;
|
||||
flat out int firstTexel;
|
||||
flat out ivec2 textureSize;
|
||||
flat out ivec4 texelRange;
|
||||
flat out float shade;
|
||||
flat out float opacity;
|
||||
|
||||
// Faces looking away from the camera are hidden behind the ones looking toward it, so every corner of
|
||||
// theirs is put in the same spot outside the view, which draws nothing.
|
||||
void main() {
|
||||
int index = gl_VertexID / 6;
|
||||
int quad = inOrder ? texelFetch(order, index).r : firstQuad + index;
|
||||
int base = quad * TEXELS_PER_QUAD;
|
||||
vec4 origin = texelFetch(quads, base);
|
||||
vec4 edgeU = texelFetch(quads, base + 1);
|
||||
vec4 edgeV = texelFetch(quads, base + 2);
|
||||
vec4 normal = texelFetch(quads, base + 3);
|
||||
if (dot(normal.xyz, towardCamera) <= 0.0) {
|
||||
gl_Position = vec4(2.0, 2.0, 2.0, 1.0);
|
||||
return;
|
||||
}
|
||||
vec4 mappingU = texelFetch(quads, base + 4);
|
||||
vec4 mappingV = texelFetch(quads, base + 5);
|
||||
int corner = CORNERS[gl_VertexID %% 6];
|
||||
float alongU = float(corner & 1);
|
||||
float alongV = float(corner >> 1);
|
||||
gl_Position = toClip * vec4(anchorFromCenter + origin.xyz + alongU * edgeU.xyz + alongV * edgeV.xyz, 1.0);
|
||||
texturePosition = vec2(mappingU.x + alongU * mappingU.y + alongV * mappingU.z,
|
||||
mappingV.x + alongU * mappingV.y + alongV * mappingV.z);
|
||||
firstTexel = floatBitsToInt(edgeV.w);
|
||||
textureSize = ivec2(floatBitsToInt(normal.w), floatBitsToInt(mappingU.w));
|
||||
texelRange = floatBitsToInt(texelFetch(quads, base + 6));
|
||||
shade = origin.w;
|
||||
opacity = edgeU.w;
|
||||
}
|
||||
""".formatted(GpuObjectMesh.TEXELS_PER_QUAD);
|
||||
|
||||
private static final String QUAD_FRAGMENT_SHADER = """
|
||||
#version 330 core
|
||||
uniform samplerBuffer texels;
|
||||
uniform bool blended;
|
||||
in vec2 texturePosition;
|
||||
flat in int firstTexel;
|
||||
flat in ivec2 textureSize;
|
||||
flat in ivec4 texelRange;
|
||||
flat in float shade;
|
||||
flat in float opacity;
|
||||
out vec4 fragment;
|
||||
|
||||
void main() {
|
||||
int column = clamp(int(floor(texturePosition.x * float(textureSize.x))), texelRange.x, texelRange.y);
|
||||
int row = clamp(int(floor(texturePosition.y * float(textureSize.y))), texelRange.z, texelRange.w);
|
||||
vec4 texel = texelFetch(texels, firstTexel + row * textureSize.x + column);
|
||||
if (texel.a == 0.0) {
|
||||
discard;
|
||||
}
|
||||
fragment = vec4(min(texel.rgb * shade, vec3(1.0)), blended ? texel.a * opacity : 1.0);
|
||||
}
|
||||
""";
|
||||
|
||||
private static final String GRID_VERTEX_SHADER = """
|
||||
#version 330 core
|
||||
uniform mat4 toClip;
|
||||
uniform vec3 gridCenter;
|
||||
uniform int reach;
|
||||
uniform int step;
|
||||
uniform int linesPerDirection;
|
||||
|
||||
void main() {
|
||||
int line = gl_VertexID / 2;
|
||||
float across = float(-reach + (line % linesPerDirection) * step);
|
||||
float along = float((gl_VertexID % 2) * 2 - 1) * float(reach);
|
||||
vec3 offset = line < linesPerDirection ? vec3(across, 0.0, along) : vec3(along, 0.0, across);
|
||||
gl_Position = toClip * vec4(gridCenter + offset, 1.0);
|
||||
}
|
||||
""";
|
||||
|
||||
private static final String FLAT_FRAGMENT_SHADER = """
|
||||
#version 330 core
|
||||
uniform vec4 color;
|
||||
out vec4 fragment;
|
||||
|
||||
void main() {
|
||||
fragment = color;
|
||||
}
|
||||
""";
|
||||
|
||||
private static GpuObjectRenderer onCard;
|
||||
|
||||
private final GpuProgram quadProgram = GpuProgram.of(QUAD_VERTEX_SHADER, QUAD_FRAGMENT_SHADER);
|
||||
private final GpuProgram gridProgram = GpuProgram.of(GRID_VERTEX_SHADER, FLAT_FRAGMENT_SHADER);
|
||||
private final int attributelessVertexArray = glGenVertexArrays();
|
||||
// Asked once: asking the card waits for everything sent to it so far.
|
||||
private final int mostBufferTexels = glGetInteger(GL_MAX_TEXTURE_BUFFER_SIZE);
|
||||
private final GpuCache<GpuObjectMesh> uploadedMeshes = new GpuCache<>();
|
||||
|
||||
/**
|
||||
* @return the picture, or nothing when the card can't draw this mesh and the caller should draw it without one
|
||||
*/
|
||||
static Optional<OrbitPicture> render(ObjectMesh mesh, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
return GpuFrames.drawPicture(target -> onCard().draw(mesh, camera, width, height, coveredLeftWidth, target));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the frame, or nothing when it can't go straight into the window and the caller should draw a picture
|
||||
*/
|
||||
static Optional<OrbitPicture> renderOnScreen(ObjectMesh mesh, ScreenTarget screen, OrbitCamera camera, int width, int height,
|
||||
int coveredLeftWidth) {
|
||||
return GpuFrames.drawOnScreen(screen, target -> onCard().draw(mesh, camera, width, height, coveredLeftWidth, target));
|
||||
}
|
||||
|
||||
private static GpuObjectRenderer onCard() {
|
||||
if (onCard == null) {
|
||||
onCard = new GpuObjectRenderer();
|
||||
}
|
||||
return onCard;
|
||||
}
|
||||
|
||||
private Optional<OrbitPicture> draw(ObjectMesh mesh, OrbitCamera camera, int width, int height, int coveredLeftWidth,
|
||||
GpuFrameTarget target) {
|
||||
GpuObjectMesh uploaded = uploaded(mesh);
|
||||
if (uploaded == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
OrbitProjection projection = ObjectRenderer.projection(mesh, camera, width, height, coveredLeftWidth);
|
||||
ObjectRenderer.FloorGrid grid = ObjectRenderer.FloorGrid.under(mesh.bounds());
|
||||
double depthRange = farthestFrom(projection.center(), mesh.bounds(), grid) + DEPTH_MARGIN_BLOCKS;
|
||||
float[] toClip = projection.clipMatrix(width, height, depthRange);
|
||||
target.bindSized(width, height);
|
||||
GpuFrames.clearToBackground();
|
||||
glDepthFunc(GL_LEQUAL);
|
||||
glBindVertexArray(attributelessVertexArray);
|
||||
drawGrid(grid, projection, toClip);
|
||||
drawMesh(uploaded, projection, toClip);
|
||||
GpuFrames.failOnError();
|
||||
return Optional.of(target.finish(projection, depthRange));
|
||||
}
|
||||
|
||||
private GpuObjectMesh uploaded(ObjectMesh mesh) {
|
||||
uploadedMeshes.forgetCollected();
|
||||
GpuObjectMesh uploaded = uploadedMeshes.get(mesh);
|
||||
if (uploaded == null) {
|
||||
uploaded = GpuObjectMesh.upload(mesh, mostBufferTexels);
|
||||
if (uploaded == null) {
|
||||
return null;
|
||||
}
|
||||
uploadedMeshes.put(mesh, uploaded);
|
||||
}
|
||||
uploadedMeshes.forgetBeyond(MOST_UPLOADED_BYTES, Set.of(mesh));
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
// No point of the scene is further from the projection's centre than the farthest corner of a box around it all.
|
||||
private static double farthestFrom(Vec3 center, ObjectMesh.Bounds bounds, ObjectRenderer.FloorGrid grid) {
|
||||
double reachX = Math.max(Math.abs(bounds.minX() - center.x()), Math.abs(bounds.maxX() - center.x()));
|
||||
double reachZ = Math.max(Math.abs(bounds.minZ() - center.z()), Math.abs(bounds.maxZ() - center.z()));
|
||||
double gridReachX = Math.abs(grid.centerX() - center.x()) + grid.reach();
|
||||
double gridReachZ = Math.abs(grid.centerZ() - center.z()) + grid.reach();
|
||||
double reachY = Math.max(Math.abs(Math.min(bounds.minY(), grid.floorY()) - center.y()), Math.abs(bounds.maxY() - center.y()));
|
||||
return Math.sqrt(square(Math.max(reachX, gridReachX)) + square(reachY) + square(Math.max(reachZ, gridReachZ)));
|
||||
}
|
||||
|
||||
private static double square(double value) {
|
||||
return value * value;
|
||||
}
|
||||
|
||||
private void drawGrid(ObjectRenderer.FloorGrid grid, OrbitProjection projection, float[] toClip) {
|
||||
Vec3 center = projection.center();
|
||||
int color = grid.color();
|
||||
gridProgram.use();
|
||||
gridProgram.setMatrix("toClip", toClip);
|
||||
gridProgram.set("gridCenter", new Vec3(grid.centerX() - center.x(), grid.floorY() - center.y(), grid.centerZ() - center.z()));
|
||||
gridProgram.set("reach", grid.reach());
|
||||
gridProgram.set("step", grid.step());
|
||||
gridProgram.set("linesPerDirection", grid.linesPerDirection());
|
||||
gridProgram.set("color", GpuFrames.channel(color, 16), GpuFrames.channel(color, 8), GpuFrames.channel(color, 0), 1D);
|
||||
glDrawArrays(GL_LINES, 0, grid.linesPerDirection() * GRID_DIRECTIONS * ENDS_PER_LINE);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cut-away ghost is drawn first, hiding nothing, so the solid quads drawn over it replace it wherever the object
|
||||
* shows. Later quads win ties, so an overlay on the same box, like grass on a grass block's side, shows on top.
|
||||
*/
|
||||
private void drawMesh(GpuObjectMesh mesh, OrbitProjection projection, float[] toClip) {
|
||||
Vec3 towardCamera = new Vec3(projection.depthStep(1D, 0D, 0D), projection.depthStep(0D, 1D, 0D), projection.depthStep(0D, 0D, 1D));
|
||||
Vec3 anchor = mesh.anchor();
|
||||
Vec3 center = projection.center();
|
||||
quadProgram.use();
|
||||
quadProgram.setMatrix("toClip", toClip);
|
||||
quadProgram.set("anchorFromCenter", new Vec3(anchor.x() - center.x(), anchor.y() - center.y(), anchor.z() - center.z()));
|
||||
quadProgram.set("towardCamera", towardCamera);
|
||||
quadProgram.set("quads", 0);
|
||||
quadProgram.set("texels", 1);
|
||||
quadProgram.set("order", 2);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
mesh.bindQuads();
|
||||
glActiveTexture(GL_TEXTURE1);
|
||||
mesh.bindTexels();
|
||||
glActiveTexture(GL_TEXTURE2);
|
||||
mesh.bindOrder();
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
|
||||
startBlending();
|
||||
drawQuads(mesh.firstGhost(), mesh.ghostCount(), true);
|
||||
glDisable(GL_BLEND);
|
||||
glDepthMask(true);
|
||||
drawQuads(0, mesh.solidCount(), false);
|
||||
startBlending();
|
||||
mesh.sortSeeThroughFarthestFirst(towardCamera);
|
||||
quadProgram.set("inOrder", 1);
|
||||
drawQuads(0, mesh.seeThroughCount(), true);
|
||||
quadProgram.set("inOrder", 0);
|
||||
}
|
||||
|
||||
// Blended quads hide nothing behind them. The window's own alpha is left whole, so nothing behind it shows through.
|
||||
private static void startBlending() {
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ZERO, GL_ONE);
|
||||
glDepthMask(false);
|
||||
}
|
||||
|
||||
private void drawQuads(int firstQuad, int count, boolean blended) {
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
quadProgram.set("firstQuad", firstQuad);
|
||||
quadProgram.set("blended", blended ? 1 : 0);
|
||||
glDrawArrays(GL_TRIANGLES, 0, count * CORNERS_PER_QUAD);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.lwjgl.opengl.GL20.*;
|
||||
|
||||
/**
|
||||
* A vertex and a fragment shader linked into one program, with its uniforms set by name. Only for the GPU thread.
|
||||
*/
|
||||
final class GpuProgram {
|
||||
private final int id;
|
||||
private final Map<String, Integer> uniformLocations = new HashMap<>();
|
||||
|
||||
private GpuProgram(int id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
static GpuProgram of(String vertexSource, String fragmentSource) {
|
||||
int vertexShader = compile(GL_VERTEX_SHADER, vertexSource);
|
||||
int fragmentShader = compile(GL_FRAGMENT_SHADER, fragmentSource);
|
||||
int program = glCreateProgram();
|
||||
glAttachShader(program, vertexShader);
|
||||
glAttachShader(program, fragmentShader);
|
||||
glLinkProgram(program);
|
||||
glDeleteShader(vertexShader);
|
||||
glDeleteShader(fragmentShader);
|
||||
if (glGetProgrami(program, GL_LINK_STATUS) == GL_FALSE) {
|
||||
String log = glGetProgramInfoLog(program);
|
||||
glDeleteProgram(program);
|
||||
throw new IllegalStateException("Linking a shader program failed: " + log);
|
||||
}
|
||||
return new GpuProgram(program);
|
||||
}
|
||||
|
||||
private static int compile(int type, String source) {
|
||||
int shader = glCreateShader(type);
|
||||
glShaderSource(shader, source);
|
||||
glCompileShader(shader);
|
||||
if (glGetShaderi(shader, GL_COMPILE_STATUS) == GL_FALSE) {
|
||||
String log = glGetShaderInfoLog(shader);
|
||||
glDeleteShader(shader);
|
||||
throw new IllegalStateException("Compiling a shader failed: " + log);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
void use() {
|
||||
glUseProgram(id);
|
||||
}
|
||||
|
||||
void set(String name, int value) {
|
||||
glUniform1i(location(name), value);
|
||||
}
|
||||
|
||||
void set(String name, int x, int y) {
|
||||
glUniform2i(location(name), x, y);
|
||||
}
|
||||
|
||||
void set(String name, int x, int y, int z) {
|
||||
glUniform3i(location(name), x, y, z);
|
||||
}
|
||||
|
||||
void set(String name, int[] values) {
|
||||
glUniform1iv(location(name), values);
|
||||
}
|
||||
|
||||
void set(String name, double value) {
|
||||
glUniform1f(location(name), (float) value);
|
||||
}
|
||||
|
||||
void set(String name, Vec3 value) {
|
||||
glUniform3f(location(name), (float) value.x(), (float) value.y(), (float) value.z());
|
||||
}
|
||||
|
||||
void set(String name, double red, double green, double blue, double alpha) {
|
||||
glUniform4f(location(name), (float) red, (float) green, (float) blue, (float) alpha);
|
||||
}
|
||||
|
||||
void setMatrix(String name, float[] columnMajor) {
|
||||
glUniformMatrix4fv(location(name), false, columnMajor);
|
||||
}
|
||||
|
||||
private int location(String name) {
|
||||
return uniformLocations.computeIfAbsent(name, uniform -> glGetUniformLocation(id, uniform));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.lwjgl.opengl.GL33.*;
|
||||
|
||||
/**
|
||||
* Draws noise terrain as block columns on the graphics card, looking the way {@link NoiseTerrainRenderer} draws them.
|
||||
* That one still draws on machines without a card. Faces and colours stay on the card while the scene shows them, so
|
||||
* a frame only sends the camera.
|
||||
*/
|
||||
final class GpuTerrainRenderer {
|
||||
private static final double DEPTH_MARGIN_BLOCKS = 16D;
|
||||
// A few full views' worth, well within what any card able to draw them has.
|
||||
private static final long MOST_UPLOADED_BYTES = 768L << 20;
|
||||
// Half a frame at 60 frames a second.
|
||||
private static final long UPLOAD_NANOS_PER_FRAME = 8_000_000L;
|
||||
private static final double FACE_OVERLAP_PIXELS = 0.05D;
|
||||
|
||||
private static final String FACE_VERTEX_SHADER = """
|
||||
#version 330 core
|
||||
const int[6] CORNERS = int[6](0, 1, 2, 2, 1, 3);
|
||||
uniform isamplerBuffer faces;
|
||||
uniform int firstFace;
|
||||
uniform mat4 toClip;
|
||||
uniform ivec3 layerOrigin;
|
||||
uniform vec3 centerFraction;
|
||||
uniform int step;
|
||||
uniform ivec3 normal;
|
||||
uniform float overlap;
|
||||
uniform int hiddenCells[%1$d];
|
||||
uniform bool holeWall;
|
||||
out vec2 units;
|
||||
flat out ivec2 firstCell;
|
||||
flat out int run;
|
||||
|
||||
bool hides(ivec2 column) {
|
||||
if (any(lessThan(column, ivec2(0))) || any(greaterThanEqual(column, ivec2(%2$d)))) {
|
||||
return false;
|
||||
}
|
||||
int cell = column.y / %3$d * %4$d + column.x / %3$d;
|
||||
return (hiddenCells[cell / 32] & (1 << (cell %% 32))) != 0;
|
||||
}
|
||||
|
||||
// The same as TerrainHole.shows: no face of a hidden column, and beside the hole its wall instead of the
|
||||
// usual side. No face crosses a cell's edge, so its first column decides for all of it.
|
||||
bool showsWithHole(ivec2 column) {
|
||||
if (hides(column)) {
|
||||
return false;
|
||||
}
|
||||
return normal.y == 1 || holeWall == hides(column + normal.xz);
|
||||
}
|
||||
|
||||
// Corners are worked out in whole blocks relative to the view centre, so far from the world's origin
|
||||
// nothing is lost to float precision. Faces meeting the middle of a longer face's edge leave hairline
|
||||
// cracks along it, so every face reaches a little past its edges. Faces the hole leaves out land outside
|
||||
// the view, where they're clipped away before any pixel is drawn.
|
||||
void main() {
|
||||
ivec4 face = texelFetch(faces, firstFace + gl_VertexID / 6);
|
||||
if (!showsWithHole(face.xy)) {
|
||||
gl_Position = vec4(2.0, 2.0, 2.0, 1.0);
|
||||
units = vec2(0.0);
|
||||
firstCell = ivec2(0);
|
||||
run = 1;
|
||||
return;
|
||||
}
|
||||
int corner = CORNERS[gl_VertexID %% 6];
|
||||
int alongRun = corner & 1;
|
||||
int alongReach = corner >> 1;
|
||||
run = face.w & 0xFFFF;
|
||||
int reach = (face.w >> 16) & 0xFFFF;
|
||||
bool runsAlongX = normal.x == 0;
|
||||
bool isTop = normal.y == 1;
|
||||
ivec3 origin = ivec3((face.x + max(normal.x, 0)) * step, face.z, (face.y + max(normal.z, 0)) * step);
|
||||
ivec3 runEdge = runsAlongX ? ivec3(run * step, 0, 0) : ivec3(0, 0, run * step);
|
||||
ivec3 reachEdge = isTop ? ivec3(0, 0, reach * step) : ivec3(0, reach, 0);
|
||||
ivec3 position = layerOrigin + origin + alongRun * runEdge + alongReach * reachEdge;
|
||||
vec3 runDirection = runsAlongX ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 0.0, 1.0);
|
||||
vec3 reachDirection = isTop ? vec3(0.0, 0.0, 1.0) : vec3(0.0, 1.0, 0.0);
|
||||
vec3 outward = (float(alongRun) * 2.0 - 1.0) * runDirection + (float(alongReach) * 2.0 - 1.0) * reachDirection;
|
||||
gl_Position = toClip * vec4(vec3(position) - centerFraction + outward * overlap, 1.0);
|
||||
units = vec2(alongRun * run, alongReach * reach);
|
||||
firstCell = face.xy;
|
||||
}
|
||||
""".formatted(TerrainHole.WORDS, TerrainTile.COLUMNS, TerrainHole.CELL_COLUMNS, TerrainHole.CELLS_ACROSS);
|
||||
|
||||
private static final String FACE_FRAGMENT_SHADER = """
|
||||
#version 330 core
|
||||
uniform sampler2D colors;
|
||||
uniform ivec2 runDirection;
|
||||
uniform float shade;
|
||||
in vec2 units;
|
||||
flat in ivec2 firstCell;
|
||||
flat in int run;
|
||||
out vec4 fragment;
|
||||
|
||||
float outlineStrength(float pixelsPerUnit) {
|
||||
return clamp((pixelsPerUnit - %1$s) / (%2$s - %1$s), 0.0, 1.0);
|
||||
}
|
||||
|
||||
float lineStrength(float along, float unitsPerPixel) {
|
||||
float pixelsPerUnit = 1.0 / max(unitsPerPixel, 1e-6);
|
||||
if (pixelsPerUnit < %1$s || abs(along - round(along)) * pixelsPerUnit >= %3$s) {
|
||||
return 0.0;
|
||||
}
|
||||
return outlineStrength(pixelsPerUnit);
|
||||
}
|
||||
|
||||
void main() {
|
||||
int along = clamp(int(floor(units.x)), 0, run - 1);
|
||||
vec3 color = min(texelFetch(colors, firstCell + runDirection * along, 0).rgb * shade, vec3(1.0));
|
||||
vec2 unitsPerPixel = fwidth(units);
|
||||
float strength = lineStrength(units.x, unitsPerPixel.x);
|
||||
if (strength == 0.0) {
|
||||
strength = lineStrength(units.y, unitsPerPixel.y);
|
||||
}
|
||||
float luminance = dot(color, vec3(0.3, 0.59, 0.11)) * 255.0;
|
||||
vec3 outline = luminance < %4$s ? mix(color, vec3(1.0), %5$s) : color * %6$s;
|
||||
fragment = vec4(mix(color, outline, strength), 1.0);
|
||||
}
|
||||
""".formatted(NoiseTerrainRenderer.LEAST_OUTLINE_SPACING_PIXELS, NoiseTerrainRenderer.FULL_OUTLINE_SPACING_PIXELS,
|
||||
NoiseTerrainRenderer.OUTLINE_HALF_WIDTH_PIXELS, NoiseTerrainRenderer.DARK_LUMINANCE,
|
||||
NoiseTerrainRenderer.OUTLINE_LIGHTENING, NoiseTerrainRenderer.OUTLINE_SHADE);
|
||||
|
||||
private static final String FLAT_VERTEX_SHADER = """
|
||||
#version 330 core
|
||||
uniform mat4 toClip;
|
||||
uniform vec3 corner;
|
||||
uniform vec3 edgeU;
|
||||
uniform vec3 edgeV;
|
||||
|
||||
void main() {
|
||||
float alongU = float(gl_VertexID & 1);
|
||||
float alongV = float(gl_VertexID >> 1);
|
||||
gl_Position = toClip * vec4(corner + alongU * edgeU + alongV * edgeV, 1.0);
|
||||
}
|
||||
""";
|
||||
|
||||
private static final String FLAT_FRAGMENT_SHADER = """
|
||||
#version 330 core
|
||||
uniform vec4 color;
|
||||
out vec4 fragment;
|
||||
|
||||
void main() {
|
||||
fragment = color;
|
||||
}
|
||||
""";
|
||||
|
||||
private static GpuTerrainRenderer onCard;
|
||||
|
||||
private final GpuProgram facesProgram = GpuProgram.of(FACE_VERTEX_SHADER, FACE_FRAGMENT_SHADER);
|
||||
private final GpuProgram flatProgram = GpuProgram.of(FLAT_VERTEX_SHADER, FLAT_FRAGMENT_SHADER);
|
||||
private final int attributelessVertexArray = glGenVertexArrays();
|
||||
// Asked once: asking the card waits for everything sent to it so far.
|
||||
private final int mostFacesPerLayer = glGetInteger(GL_MAX_TEXTURE_BUFFER_SIZE);
|
||||
private final int largestColorsSide = glGetInteger(GL_MAX_TEXTURE_SIZE);
|
||||
private final GpuTileCache uploadedTiles = new GpuTileCache();
|
||||
|
||||
/**
|
||||
* @return the picture, or nothing when the card can't draw this scene and the caller should draw it without one
|
||||
*/
|
||||
static Optional<OrbitPicture> render(NoiseTerrainScene scene, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
return GpuFrames.drawPicture(target -> onCard().draw(scene, camera, width, height, coveredLeftWidth, target));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the frame, or nothing when it can't go straight into the window and the caller should draw a picture
|
||||
*/
|
||||
static Optional<OrbitPicture> renderOnScreen(NoiseTerrainScene scene, ScreenTarget screen, OrbitCamera camera, int width, int height,
|
||||
int coveredLeftWidth) {
|
||||
return GpuFrames.drawOnScreen(screen, target -> onCard().draw(scene, camera, width, height, coveredLeftWidth, target));
|
||||
}
|
||||
|
||||
private static GpuTerrainRenderer onCard() {
|
||||
if (onCard == null) {
|
||||
onCard = new GpuTerrainRenderer();
|
||||
}
|
||||
return onCard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws what of the scene is on the card. What isn't yet is put there over the next frames, as the picture asks to
|
||||
* be drawn again, so turning the camera never waits on new ground being put on the card.
|
||||
*/
|
||||
private Optional<OrbitPicture> draw(NoiseTerrainScene scene, OrbitCamera camera, int width, int height, int coveredLeftWidth,
|
||||
GpuFrameTarget target) {
|
||||
Uploads uploads = uploadWithinBudget(scene);
|
||||
if (uploads == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
OrbitPicture picture = drawLayers(scene, uploads.layers(), camera, width, height, coveredLeftWidth, target);
|
||||
return Optional.of(uploads.complete() ? picture : picture.stillCatchingUp());
|
||||
}
|
||||
|
||||
private OrbitPicture drawLayers(NoiseTerrainScene scene, List<UploadedLayer> layers, OrbitCamera camera, int width, int height,
|
||||
int coveredLeftWidth, GpuFrameTarget target) {
|
||||
OrbitProjection projection = scene.projection(camera, width, height, coveredLeftWidth);
|
||||
NoiseTerrainScene.Extent extent = scene.extent();
|
||||
double depthRange = extent.farthestFrom(projection.center()) + DEPTH_MARGIN_BLOCKS;
|
||||
float[] toClip = projection.clipMatrix(width, height, depthRange);
|
||||
target.bindSized(width, height);
|
||||
GpuFrames.clearToBackground();
|
||||
for (UploadedLayer layer : layers) {
|
||||
drawFaces(layer, projection, toClip);
|
||||
}
|
||||
if (scene.hasWater()) {
|
||||
drawWater(extent, BlockFaces.blockTop(scene.waterLevel()), projection, toClip);
|
||||
}
|
||||
GpuFrames.failOnError();
|
||||
return target.finish(projection, depthRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts as many of the scene's layers on the card as fit in one frame's time, reusing what is already there, and
|
||||
* says how to draw what is there. A place whose newest ground isn't on the card yet is drawn with what is, such as
|
||||
* the ground from before the heights changed. Tiles the scene doesn't show stay on the card while they fit and are
|
||||
* still in memory, as turning back to them is likely.
|
||||
*
|
||||
* @return the layers on the card, or null when one is too large for it
|
||||
*/
|
||||
private Uploads uploadWithinBudget(NoiseTerrainScene scene) {
|
||||
UploadProgress progress = uploadCoarsestFirst(scene.layers());
|
||||
if (progress == UploadProgress.TOO_LARGE) {
|
||||
return null;
|
||||
}
|
||||
List<UploadedLayer> layers = layersOnCard(scene.layers());
|
||||
Set<GpuTileCache.Place> shown = new HashSet<>();
|
||||
for (HoledLayer layer : scene.layers()) {
|
||||
shown.add(GpuTileCache.Place.of(layer.terrain()));
|
||||
}
|
||||
uploadedTiles.forgetBeyond(MOST_UPLOADED_BYTES, shown);
|
||||
return new Uploads(layers, progress == UploadProgress.COMPLETE);
|
||||
}
|
||||
|
||||
// Coarse tiles cover the most ground for their upload, so they go first; finer ones then only cut their holes.
|
||||
private UploadProgress uploadCoarsestFirst(List<HoledLayer> layers) {
|
||||
long deadline = System.nanoTime() + UPLOAD_NANOS_PER_FRAME;
|
||||
List<HoledLayer> coarsestFirst = new ArrayList<>(layers);
|
||||
coarsestFirst.sort(Comparator.comparingInt((HoledLayer layer) -> layer.terrain().step()).reversed());
|
||||
UploadProgress progress = UploadProgress.COMPLETE;
|
||||
for (HoledLayer layer : coarsestFirst) {
|
||||
GpuTileCache.Place place = GpuTileCache.Place.of(layer.terrain());
|
||||
GpuTile tile = uploadedTiles.get(place);
|
||||
if (tile != null && tile.shows(layer.layer())) {
|
||||
continue;
|
||||
}
|
||||
if (System.nanoTime() > deadline) {
|
||||
progress = UploadProgress.CATCHING_UP;
|
||||
continue;
|
||||
}
|
||||
if (upload(layer.layer(), place, tile) == null) {
|
||||
return UploadProgress.TOO_LARGE;
|
||||
}
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
|
||||
// A hole is only cut where what fills it is on the card, so no ground is ever left out with nothing in its place.
|
||||
// Tiles whose fillers are all on the card show nothing, so they aren't drawn at all.
|
||||
private List<UploadedLayer> layersOnCard(List<HoledLayer> layers) {
|
||||
List<UploadedLayer> onCard = new ArrayList<>(layers.size());
|
||||
for (HoledLayer layer : layers) {
|
||||
GpuTile tile = uploadedTiles.get(GpuTileCache.Place.of(layer.terrain()));
|
||||
if (tile == null) {
|
||||
continue;
|
||||
}
|
||||
TerrainHole hole = layer.holeAmong(filler -> uploadedTiles.get(GpuTileCache.Place.of(filler)) != null);
|
||||
if (!hole.hidesAll()) {
|
||||
onCard.add(new UploadedLayer(layer.terrain(), tile.faces(), tile.colors(), hole));
|
||||
}
|
||||
}
|
||||
return onCard;
|
||||
}
|
||||
|
||||
private GpuTile upload(TerrainLayer layer, GpuTileCache.Place place, GpuTile previous) {
|
||||
if (previous != null) {
|
||||
return previous.showInstead(layer, largestColorsSide, mostFacesPerLayer) ? previous : null;
|
||||
}
|
||||
GpuTile tile = GpuTile.upload(layer, largestColorsSide, mostFacesPerLayer);
|
||||
if (tile != null) {
|
||||
uploadedTiles.put(place, tile);
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
|
||||
// Faces looking away from the camera are hidden behind the ones looking toward it, so they're never drawn.
|
||||
private void drawFaces(UploadedLayer layer, OrbitProjection projection, float[] toClip) {
|
||||
Vec3 center = projection.center();
|
||||
int wholeX = (int) Math.floor(center.x());
|
||||
int wholeY = (int) Math.floor(center.y());
|
||||
int wholeZ = (int) Math.floor(center.z());
|
||||
facesProgram.use();
|
||||
facesProgram.setMatrix("toClip", toClip);
|
||||
facesProgram.set("colors", 0);
|
||||
facesProgram.set("faces", 1);
|
||||
facesProgram.set("layerOrigin", layer.terrain().originX() - wholeX, -wholeY, layer.terrain().originZ() - wholeZ);
|
||||
facesProgram.set("centerFraction", new Vec3(center.x() - wholeX, center.y() - wholeY, center.z() - wholeZ));
|
||||
facesProgram.set("step", layer.terrain().step());
|
||||
facesProgram.set("overlap", FACE_OVERLAP_PIXELS / projection.pixelsPerBlock());
|
||||
facesProgram.set("hiddenCells", layer.hole().hiddenCells());
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
layer.colors().bind();
|
||||
glActiveTexture(GL_TEXTURE1);
|
||||
layer.faces().bind();
|
||||
glBindVertexArray(attributelessVertexArray);
|
||||
for (BlockFaces.Kind kind : BlockFaces.Kind.values()) {
|
||||
facesProgram.set("holeWall", kind == BlockFaces.Kind.HOLE_WALL ? 1 : 0);
|
||||
for (BlockFaces.Facing facing : BlockFaces.Facing.values()) {
|
||||
drawFacing(layer.faces(), kind, facing, projection);
|
||||
}
|
||||
}
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
}
|
||||
|
||||
private void drawFacing(GpuFaces faces, BlockFaces.Kind kind, BlockFaces.Facing facing, OrbitProjection projection) {
|
||||
int count = faces.count(kind, facing);
|
||||
if (count == 0 || !projection.facesCamera(facing.normalX, facing.normalY, facing.normalZ)) {
|
||||
return;
|
||||
}
|
||||
facesProgram.set("firstFace", faces.first(kind, facing));
|
||||
facesProgram.set("normal", facing.normalX, facing.normalY, facing.normalZ);
|
||||
facesProgram.set("runDirection", facing.runsAlongX() ? 1 : 0, facing.runsAlongX() ? 0 : 1);
|
||||
facesProgram.set("shade", NoiseTerrainRenderer.faceShadeAt(facing, projection.pixelsPerBlock()));
|
||||
glDrawArrays(GL_TRIANGLES, 0, count * GpuFaces.CORNERS_PER_FACE);
|
||||
}
|
||||
|
||||
// Water is blended over what lies below its surface, and hides nothing behind it. Ground level with the water's
|
||||
// surface stays dry rather than flickering between the two.
|
||||
private void drawWater(NoiseTerrainScene.Extent extent, int level, OrbitProjection projection, float[] toClip) {
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
glDepthMask(false);
|
||||
glEnable(GL_POLYGON_OFFSET_FILL);
|
||||
glPolygonOffset(1F, 1F);
|
||||
Vec3 center = projection.center();
|
||||
flatProgram.use();
|
||||
flatProgram.setMatrix("toClip", toClip);
|
||||
flatProgram.set("corner", new Vec3(extent.minX() - center.x(), level - center.y(), extent.minZ() - center.z()));
|
||||
flatProgram.set("edgeU", new Vec3(extent.sizeX(), 0D, 0D));
|
||||
flatProgram.set("edgeV", new Vec3(0D, 0D, extent.sizeZ()));
|
||||
int color = NoiseTerrainRenderer.WATER_COLOR;
|
||||
flatProgram.set("color", GpuFrames.channel(color, 16), GpuFrames.channel(color, 8), GpuFrames.channel(color, 0),
|
||||
NoiseTerrainRenderer.WATER_OPACITY);
|
||||
glBindVertexArray(attributelessVertexArray);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
}
|
||||
|
||||
private record Uploads(List<UploadedLayer> layers, boolean complete) {
|
||||
}
|
||||
|
||||
private enum UploadProgress {
|
||||
COMPLETE,
|
||||
CATCHING_UP,
|
||||
TOO_LARGE
|
||||
}
|
||||
|
||||
private record UploadedLayer(NoiseTerrain terrain, GpuFaces faces, GpuColors colors, TerrainHole hole) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
/**
|
||||
* A place on the ground on the graphics card: the colours and faces of the terrain last sampled there. They're put on
|
||||
* the card once, whole, and stay as finer tiles inside come and go, since the face shader leaves out whatever hole they
|
||||
* cut. When the noise or its heights change, the place is sampled anew; until the newest terrain is on the card, the
|
||||
* older one still covers the place. Only for the GPU thread.
|
||||
*/
|
||||
final class GpuTile implements GpuCache.Upload {
|
||||
private GpuColors colors;
|
||||
private GpuFaces faces;
|
||||
// Held weakly, so ground no longer drawn can be dropped from memory while its upload still stands in for it.
|
||||
private WeakReference<TerrainLayer> source;
|
||||
|
||||
private GpuTile(GpuColors colors, GpuFaces faces, TerrainLayer source) {
|
||||
this.colors = colors;
|
||||
this.faces = faces;
|
||||
this.source = new WeakReference<>(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param largestSide the card's GL_MAX_TEXTURE_SIZE
|
||||
* @param mostFaces the card's GL_MAX_TEXTURE_BUFFER_SIZE
|
||||
* @return the tile, or null when its terrain has more columns or rows than the card's textures can hold
|
||||
*/
|
||||
static GpuTile upload(TerrainLayer layer, int largestSide, int mostFaces) {
|
||||
GpuColors colors = GpuColors.upload(layer.terrain(), largestSide);
|
||||
if (colors == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new GpuTile(colors, GpuFaces.upload(layer.faces(), mostFaces), layer);
|
||||
} catch (RuntimeException failure) {
|
||||
colors.delete();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
boolean shows(TerrainLayer wanted) {
|
||||
return source.get() == wanted;
|
||||
}
|
||||
|
||||
// Ground whose terrain is gone from memory is drawn only while nothing newer for its place is on the card.
|
||||
boolean showsForgottenTerrain() {
|
||||
return source.get() == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false when the layer's terrain has more columns or rows than the card's textures can hold
|
||||
*/
|
||||
boolean showInstead(TerrainLayer wanted, int largestSide, int mostFaces) {
|
||||
GpuTile next = upload(wanted, largestSide, mostFaces);
|
||||
if (next == null) {
|
||||
return false;
|
||||
}
|
||||
delete();
|
||||
colors = next.colors;
|
||||
faces = next.faces;
|
||||
source = next.source;
|
||||
return true;
|
||||
}
|
||||
|
||||
GpuColors colors() {
|
||||
return colors;
|
||||
}
|
||||
|
||||
GpuFaces faces() {
|
||||
return faces;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long bytes() {
|
||||
return colors.bytes() + faces.bytes();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete() {
|
||||
colors.delete();
|
||||
faces.delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The ground on the graphics card by where it lies, so ground sampled anew for a place replaces what was there, which
|
||||
* covers the place until then. Only for the GPU thread.
|
||||
*/
|
||||
final class GpuTileCache {
|
||||
private final LinkedHashMap<Place, GpuTile> leastRecentlyUsedFirst = new LinkedHashMap<>(16, 0.75F, true);
|
||||
|
||||
GpuTile get(Place place) {
|
||||
return leastRecentlyUsedFirst.get(place);
|
||||
}
|
||||
|
||||
void put(Place place, GpuTile tile) {
|
||||
leastRecentlyUsedFirst.put(place, tile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes ground not shown whose terrain is gone from memory, as it can never be drawn again, then the least
|
||||
* recently used until the cache fits, sparing what is shown.
|
||||
*/
|
||||
void forgetBeyond(long mostBytes, Set<Place> shown) {
|
||||
long bytes = 0L;
|
||||
Iterator<Map.Entry<Place, GpuTile>> oldestFirst = leastRecentlyUsedFirst.entrySet().iterator();
|
||||
while (oldestFirst.hasNext()) {
|
||||
Map.Entry<Place, GpuTile> entry = oldestFirst.next();
|
||||
if (!shown.contains(entry.getKey()) && entry.getValue().showsForgottenTerrain()) {
|
||||
entry.getValue().delete();
|
||||
oldestFirst.remove();
|
||||
} else {
|
||||
bytes += entry.getValue().bytes();
|
||||
}
|
||||
}
|
||||
oldestFirst = leastRecentlyUsedFirst.entrySet().iterator();
|
||||
while (bytes > mostBytes && oldestFirst.hasNext()) {
|
||||
Map.Entry<Place, GpuTile> entry = oldestFirst.next();
|
||||
if (!shown.contains(entry.getKey())) {
|
||||
bytes -= entry.getValue().bytes();
|
||||
entry.getValue().delete();
|
||||
oldestFirst.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a tile's ground lies, and how coarse it is.
|
||||
*/
|
||||
record Place(int originX, int originZ, int step) {
|
||||
static Place of(NoiseTerrain terrain) {
|
||||
return new Place(terrain.originX(), terrain.originZ(), terrain.step());
|
||||
}
|
||||
|
||||
// The same place as the tile's terrain once sampled.
|
||||
static Place of(TerrainTile tile) {
|
||||
TerrainGrid grid = tile.grid();
|
||||
return new Place(grid.originX(), grid.originZ(), grid.step());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.lwjgl.opengl.GL30.*;
|
||||
|
||||
/**
|
||||
* A frame drawn straight into a view's window. Of what was drawn, only the patches Swing paints where panels float
|
||||
* over the window are read back. Only for the GPU thread.
|
||||
*/
|
||||
final class GpuWindowFrame implements GpuFrameTarget {
|
||||
private static final int WINDOW_FRAMEBUFFER = 0;
|
||||
|
||||
private final GlxDisplay windows;
|
||||
private final long window;
|
||||
private final List<Rectangle> seeThrough;
|
||||
private int width;
|
||||
private int height;
|
||||
|
||||
/**
|
||||
* @param seeThrough the parts of the view panels float over, which Swing paints instead of the window
|
||||
*/
|
||||
GpuWindowFrame(GlxDisplay windows, long window, List<Rectangle> seeThrough) {
|
||||
this.windows = windows;
|
||||
this.window = window;
|
||||
this.seeThrough = seeThrough;
|
||||
}
|
||||
|
||||
// The context was first made current without any window, which left it drawing into no buffer; NVIDIA's driver
|
||||
// keeps that once a window comes along.
|
||||
@Override
|
||||
public void bindSized(int nextWidth, int nextHeight) {
|
||||
width = nextWidth;
|
||||
height = nextHeight;
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, WINDOW_FRAMEBUFFER);
|
||||
glDrawBuffer(GL_BACK);
|
||||
glReadBuffer(GL_BACK);
|
||||
glViewport(0, 0, width, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OrbitPicture finish(OrbitProjection projection, double depthRange) {
|
||||
OrbitPicture picture = OrbitPicture.onScreen(width, height, readPatches(),
|
||||
GpuFrameTarget.depthInMiddle(projection, depthRange, width, height));
|
||||
if (!windows.show(window)) {
|
||||
throw new IllegalStateException("The window didn't take the frame");
|
||||
}
|
||||
return picture;
|
||||
}
|
||||
|
||||
private List<PicturePatch> readPatches() {
|
||||
Rectangle frame = new Rectangle(0, 0, width, height);
|
||||
List<PicturePatch> patches = new ArrayList<>(seeThrough.size());
|
||||
for (Rectangle part : seeThrough) {
|
||||
Rectangle inFrame = part.intersection(frame);
|
||||
if (!inFrame.isEmpty()) {
|
||||
patches.add(read(inFrame));
|
||||
}
|
||||
}
|
||||
return patches;
|
||||
}
|
||||
|
||||
// Rows come bottom up.
|
||||
private PicturePatch read(Rectangle area) {
|
||||
int[] rows = new int[area.width * area.height];
|
||||
glReadPixels(area.x, height - area.y - area.height, area.width, area.height, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, rows);
|
||||
BufferedImage image = new BufferedImage(area.width, area.height, BufferedImage.TYPE_INT_RGB);
|
||||
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||
for (int row = 0; row < area.height; row++) {
|
||||
System.arraycopy(rows, (area.height - 1 - row) * area.width, pixels, row * area.width, area.width);
|
||||
}
|
||||
return new PicturePatch(area.x, area.y, image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.DoubleBinaryOperator;
|
||||
|
||||
/**
|
||||
* How the 3D terrain view looks at the ground for one camera: which ground lies under a pixel, and which ground is on
|
||||
* screen. The ground's height is asked for anew, so it holds wherever tiles are still coarse or missing.
|
||||
*
|
||||
* @param left where the part of the view no panel floats over starts
|
||||
* @param floorY the lowest the ground goes, as far as the view knows
|
||||
* @param topY the highest the ground goes, as far as the view knows
|
||||
*/
|
||||
record GroundSight(OrbitProjection projection, int left, int width, int height, int floorY, int topY) {
|
||||
// Across the ground per step along a line of sight, so no column is stepped over.
|
||||
private static final double MARCH_STEP_BLOCKS = 1D;
|
||||
private static final int REFINE_STEPS = 10;
|
||||
|
||||
/**
|
||||
* The ground drawn at a pixel: the first point down the line of sight that lies below the surface.
|
||||
*
|
||||
* @param surfaceY the ground's height at a spot, or the water's where that is higher
|
||||
*/
|
||||
Optional<Vec3> groundAt(double screenX, double screenY, DoubleBinaryOperator surfaceY) {
|
||||
double sinPitch = projection.towardCamera().y();
|
||||
double stepY = MARCH_STEP_BLOCKS * sinPitch / Math.sqrt(Math.max(1E-6, 1D - sinPitch * sinPitch));
|
||||
double aboveY = topY;
|
||||
for (double y = topY; y >= floorY; y -= stepY) {
|
||||
Vec3 point = projection.pointAt(screenX, screenY, y);
|
||||
if (surfaceY.applyAsDouble(point.x(), point.z()) >= y) {
|
||||
return Optional.of(refine(screenX, screenY, aboveY, y, surfaceY));
|
||||
}
|
||||
aboveY = y;
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Halves the stretch of the line of sight where it passes into the ground until the point sits on the surface.
|
||||
private Vec3 refine(double screenX, double screenY, double aboveY, double belowY, DoubleBinaryOperator surfaceY) {
|
||||
double above = aboveY;
|
||||
double below = belowY;
|
||||
for (int step = 0; step < REFINE_STEPS; step++) {
|
||||
double middle = (above + below) / 2D;
|
||||
Vec3 point = projection.pointAt(screenX, screenY, middle);
|
||||
if (surfaceY.applyAsDouble(point.x(), point.z()) >= middle) {
|
||||
below = middle;
|
||||
} else {
|
||||
above = middle;
|
||||
}
|
||||
}
|
||||
Vec3 point = projection.pointAt(screenX, screenY, below);
|
||||
return new Vec3(point.x(), surfaceY.applyAsDouble(point.x(), point.z()), point.z());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spots spread evenly over the ground on screen, about {@code most} of them over the stretch the view can reach. The
|
||||
* view looks along parallel lines, so even on the ground is even on screen, but for slopes and hidden valleys. The
|
||||
* spots keep to a grid fixed in the world, so turning the view a little doesn't change which spots are counted.
|
||||
*/
|
||||
List<Vec3> groundInView(int most, DoubleBinaryOperator surfaceY) {
|
||||
Reach reach = reach();
|
||||
double spacing = gridSpacing(reach, most);
|
||||
List<Vec3> spots = new ArrayList<>();
|
||||
for (double z = Math.floor(reach.minZ() / spacing) * spacing; z <= reach.maxZ(); z += spacing) {
|
||||
for (double x = Math.floor(reach.minX() / spacing) * spacing; x <= reach.maxX(); x += spacing) {
|
||||
double y = surfaceY.applyAsDouble(x, z);
|
||||
if (isOnScreen(x, y, z)) {
|
||||
spots.add(new Vec3(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
return spots;
|
||||
}
|
||||
|
||||
private boolean isOnScreen(double x, double y, double z) {
|
||||
double screenX = projection.screenX(x, y, z);
|
||||
double screenY = projection.screenY(x, y, z);
|
||||
return screenX >= left && screenX < width && screenY >= 0D && screenY < height;
|
||||
}
|
||||
|
||||
// The ground the view's corners look at, from the floor to the top.
|
||||
private Reach reach() {
|
||||
double minX = Double.POSITIVE_INFINITY;
|
||||
double minZ = Double.POSITIVE_INFINITY;
|
||||
double maxX = Double.NEGATIVE_INFINITY;
|
||||
double maxZ = Double.NEGATIVE_INFINITY;
|
||||
for (double screenX : new double[]{left, width}) {
|
||||
for (double screenY : new double[]{0D, height}) {
|
||||
for (double groundY : new double[]{floorY, topY}) {
|
||||
Vec3 point = projection.pointAt(screenX, screenY, groundY);
|
||||
minX = Math.min(minX, point.x());
|
||||
minZ = Math.min(minZ, point.z());
|
||||
maxX = Math.max(maxX, point.x());
|
||||
maxZ = Math.max(maxZ, point.z());
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Reach(minX, minZ, maxX, maxZ);
|
||||
}
|
||||
|
||||
// A whole power of two, so zooming only changes the grid when the spacing has doubled or halved.
|
||||
private static double gridSpacing(Reach reach, int most) {
|
||||
double area = Math.max(1D, (reach.maxX() - reach.minX()) * (reach.maxZ() - reach.minZ()));
|
||||
double spacing = Math.sqrt(area / Math.max(1, most));
|
||||
return Math.pow(2D, Math.ceil(Math.log(Math.max(1D, spacing)) / Math.log(2D)));
|
||||
}
|
||||
|
||||
private record Reach(double minX, double minZ, double maxX, double maxZ) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* A tile's ground as the 3D noise view shows it, less the finer tiles inside it that are ready to fill their part. The
|
||||
* hole is cut when drawing, from the fillers that are drawable by then, so ground is never left out before what fills
|
||||
* it can be drawn.
|
||||
*/
|
||||
record HoledLayer(TerrainTile tile, TerrainLayer layer, List<TerrainTile> fillers) {
|
||||
TerrainHole holeAmong(Predicate<TerrainTile> drawable) {
|
||||
return TerrainHole.of(tile, fillers.stream().filter(drawable).toList());
|
||||
}
|
||||
|
||||
TerrainHole holeOfAllFillers() {
|
||||
return TerrainHole.of(tile, fillers);
|
||||
}
|
||||
|
||||
NoiseTerrain terrain() {
|
||||
return layer.terrain();
|
||||
}
|
||||
}
|
||||
+130
-221
@@ -1,6 +1,5 @@
|
||||
package art.arcane.iris.studio.view;
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.localization.DesktopUiMessages;
|
||||
import art.arcane.iris.localization.IrisLanguage;
|
||||
@@ -32,8 +31,8 @@ import javax.swing.DefaultCellEditor;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JFileChooser;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
@@ -47,17 +46,13 @@ import javax.swing.filechooser.FileNameExtensionFilter;
|
||||
import javax.swing.table.DefaultTableModel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.FlowLayout;
|
||||
import java.awt.Font;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.Insets;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
@@ -66,25 +61,23 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.DoubleBinaryOperator;
|
||||
|
||||
public final class ImageMapStudioGUI {
|
||||
private static final Color BACKGROUND = new Color(12, 15, 22);
|
||||
private static final Color SIDEBAR_BACKGROUND = new Color(17, 20, 29);
|
||||
private static final Color PANEL_BACKGROUND = new Color(23, 27, 38);
|
||||
private static final Color FIELD_BACKGROUND = new Color(29, 34, 47);
|
||||
private static final Color PRIMARY_TEXT = new Color(230, 234, 242);
|
||||
private static final Color SECONDARY_TEXT = new Color(151, 160, 178);
|
||||
private static final Color ACCENT = new Color(99, 161, 255);
|
||||
private static final Color BORDER = new Color(48, 55, 72);
|
||||
private static final Color ERROR = new Color(255, 116, 116);
|
||||
private static final Color SUCCESS = new Color(91, 204, 139);
|
||||
private static final Font BODY_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12);
|
||||
private static final Font LABEL_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 11);
|
||||
/**
|
||||
* The map window's Image maps view: turns a PNG into terrain height, biomes or a mask, previews it on the world and
|
||||
* exports it into the pack.
|
||||
*/
|
||||
final class ImageMapExplorer {
|
||||
private static final int EDITOR_WIDTH = 470;
|
||||
private static final int OVERLAY_COLUMNS = 2;
|
||||
private static final int TABLE_ROW_HEIGHT = 23;
|
||||
private static final String STATUS_SEPARATOR = " · ";
|
||||
|
||||
private final Engine engine;
|
||||
private final File packFolder;
|
||||
private final String dimensionKey;
|
||||
private final JFrame frame;
|
||||
private final ImageMapStudioPreviewPanel previewPanel = new ImageMapStudioPreviewPanel();
|
||||
private final StudioStatusBar statusBar = new StudioStatusBar();
|
||||
private final List<JComponent> controls = new ArrayList<>();
|
||||
private final JSplitPane view;
|
||||
private final JComboBox<String> presetPicker = new JComboBox<>();
|
||||
private final JTextField bindingKeyField = field("image-map");
|
||||
private final JTextField mapKeyField = field("image-map");
|
||||
@@ -132,7 +125,6 @@ public final class ImageMapStudioGUI {
|
||||
private final JPanel typeCards = new JPanel(new CardLayout());
|
||||
private final JTextArea metadataArea = area(3);
|
||||
private final JTextArea diagnosticsArea = area(5);
|
||||
private final JLabel statusLabel = new JLabel();
|
||||
private final JCheckBox chunksOverlay = selectedCheck();
|
||||
private final JCheckBox regionsOverlay = selectedCheck();
|
||||
private final JCheckBox boundaryOverlay = selectedCheck();
|
||||
@@ -141,59 +133,60 @@ public final class ImageMapStudioGUI {
|
||||
private IrisWorldBoundary boundary;
|
||||
private boolean controlsUpdating;
|
||||
|
||||
private ImageMapStudioGUI(Engine engine) {
|
||||
ImageMapExplorer(Engine engine) {
|
||||
this.engine = Objects.requireNonNull(engine, "Image Map Studio engine");
|
||||
this.packFolder = engine.getPackSource().toFile();
|
||||
this.dimensionKey = engine.getDimension().getLoadKey();
|
||||
this.boundary = engine.getDimension().getWorldBoundary() == null
|
||||
? null
|
||||
: IrisWorldBoundary.snapshot(engine.getDimension().getWorldBoundary());
|
||||
this.frame = new JFrame(text(DesktopUiMessages.IMAGEMAP_TITLE));
|
||||
configureFrame();
|
||||
buildControls();
|
||||
this.view = buildWorkspace();
|
||||
setStatus(DesktopUiMessages.IMAGEMAP_READY);
|
||||
configureControls();
|
||||
refreshPresets();
|
||||
}
|
||||
|
||||
public static boolean isAvailable() {
|
||||
return GuiHost.isAvailable() && IrisSettings.get().getGui().isUseServerLaunchedGuis();
|
||||
}
|
||||
|
||||
public static void launch(Engine engine) {
|
||||
if (!GuiHost.isAvailable()) {
|
||||
return;
|
||||
}
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (GuiHost.isAvailable()) {
|
||||
new ImageMapStudioGUI(engine).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void reloadActiveEngine(Engine engine) {
|
||||
engine.hotloadSilently();
|
||||
}
|
||||
|
||||
private void configureFrame() {
|
||||
GuiHost.prepareFrame(frame);
|
||||
frame.getContentPane().setBackground(BACKGROUND);
|
||||
frame.setLayout(new BorderLayout());
|
||||
frame.add(buildToolbar(), BorderLayout.NORTH);
|
||||
frame.add(buildWorkspace(), BorderLayout.CENTER);
|
||||
frame.add(buildStatusBar(), BorderLayout.SOUTH);
|
||||
frame.setSize(1540, 920);
|
||||
frame.setMinimumSize(new Dimension(1040, 680));
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosed(WindowEvent event) {
|
||||
previewPanel.close();
|
||||
}
|
||||
JComponent view() {
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowClosing(WindowEvent event) {
|
||||
previewPanel.close();
|
||||
}
|
||||
});
|
||||
JComponent statusBar() {
|
||||
return statusBar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the editor right of the floating view list instead of under it.
|
||||
*/
|
||||
void setCoveredLeftWidth(int width) {
|
||||
view.setBorder(BorderFactory.createEmptyBorder(0, width, 0, 0));
|
||||
view.setDividerLocation(width + StudioTheme.scale(EDITOR_WIDTH));
|
||||
view.revalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* The toolbar buttons that belong to this view.
|
||||
*/
|
||||
List<JComponent> controls() {
|
||||
return controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the first preset, once the view is on screen for the first time.
|
||||
*/
|
||||
void open() {
|
||||
String selected = (String) presetPicker.getSelectedItem();
|
||||
if (selected != null && !selected.isBlank()) {
|
||||
loadPreset(selected);
|
||||
}
|
||||
}
|
||||
|
||||
void close() {
|
||||
previewPanel.close();
|
||||
}
|
||||
|
||||
private void configureControls() {
|
||||
@@ -214,90 +207,64 @@ public final class ImageMapStudioGUI {
|
||||
updateOverlays();
|
||||
}
|
||||
|
||||
private void show() {
|
||||
frame.setVisible(true);
|
||||
EventQueue.invokeLater(() -> {
|
||||
String selected = (String) presetPicker.getSelectedItem();
|
||||
if (selected != null && !selected.isBlank()) {
|
||||
loadPreset(selected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private JPanel buildToolbar() {
|
||||
JPanel toolbar = new JPanel(new BorderLayout());
|
||||
toolbar.setBackground(SIDEBAR_BACKGROUND);
|
||||
toolbar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER));
|
||||
JPanel leading = new JPanel(new FlowLayout(FlowLayout.LEFT, 7, 6));
|
||||
leading.setOpaque(false);
|
||||
JLabel presetLabel = label(DesktopUiMessages.IMAGEMAP_PRESET);
|
||||
leading.add(presetLabel);
|
||||
private void buildControls() {
|
||||
presetPicker.setPrototypeDisplayValue("terrain/example-long-preset");
|
||||
styleCombo(presetPicker);
|
||||
leading.add(presetPicker);
|
||||
JButton load = button(DesktopUiMessages.IMAGEMAP_LOAD);
|
||||
load.addActionListener(event -> {
|
||||
presetPicker.setToolTipText("Image maps already in the pack");
|
||||
presetPicker.setFocusable(false);
|
||||
presetPicker.setMaximumSize(presetPicker.getPreferredSize());
|
||||
controls.add(StudioToolbar.label(text(DesktopUiMessages.IMAGEMAP_PRESET)));
|
||||
controls.add(presetPicker);
|
||||
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_LOAD), "Load the chosen image map", () -> {
|
||||
String selected = (String) presetPicker.getSelectedItem();
|
||||
if (selected != null) {
|
||||
loadPreset(selected);
|
||||
}
|
||||
});
|
||||
leading.add(load);
|
||||
JButton importButton = button(DesktopUiMessages.IMAGEMAP_IMPORT_PNG);
|
||||
importButton.addActionListener(event -> chooseSource(false));
|
||||
leading.add(importButton);
|
||||
JButton replaceButton = button(DesktopUiMessages.IMAGEMAP_REPLACE_PNG);
|
||||
replaceButton.addActionListener(event -> chooseSource(true));
|
||||
leading.add(replaceButton);
|
||||
toolbar.add(leading, BorderLayout.WEST);
|
||||
|
||||
JPanel trailing = new JPanel(new FlowLayout(FlowLayout.RIGHT, 7, 6));
|
||||
trailing.setOpaque(false);
|
||||
JButton preview = button(DesktopUiMessages.IMAGEMAP_PREVIEW);
|
||||
preview.addActionListener(event -> preview());
|
||||
trailing.add(preview);
|
||||
JButton export = button(DesktopUiMessages.IMAGEMAP_EXPORT);
|
||||
export.setForeground(SUCCESS);
|
||||
export.addActionListener(event -> export());
|
||||
trailing.add(export);
|
||||
toolbar.add(trailing, BorderLayout.EAST);
|
||||
return toolbar;
|
||||
}));
|
||||
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_IMPORT_PNG), "Start a new image map from a PNG",
|
||||
() -> chooseSource(false)));
|
||||
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_REPLACE_PNG), "Swap the PNG behind this image map",
|
||||
() -> chooseSource(true)));
|
||||
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_PREVIEW), "Preview the image map on the world",
|
||||
this::preview));
|
||||
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_EXPORT), "Write the image map into the pack and hotload",
|
||||
this::export));
|
||||
}
|
||||
|
||||
private Component buildWorkspace() {
|
||||
private JSplitPane buildWorkspace() {
|
||||
JScrollPane editorScroll = new JScrollPane(buildEditor());
|
||||
editorScroll.setBorder(BorderFactory.createEmptyBorder());
|
||||
editorScroll.getVerticalScrollBar().setUnitIncrement(18);
|
||||
editorScroll.setPreferredSize(new Dimension(470, 760));
|
||||
editorScroll.getVerticalScrollBar().setUnitIncrement(StudioTheme.scale(18));
|
||||
editorScroll.setPreferredSize(StudioTheme.scaledSize(EDITOR_WIDTH, 760));
|
||||
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, editorScroll, previewPanel);
|
||||
split.setBorder(BorderFactory.createEmptyBorder());
|
||||
split.setDividerSize(5);
|
||||
split.setResizeWeight(0D);
|
||||
split.setDividerLocation(470);
|
||||
split.setDividerLocation(StudioTheme.scale(EDITOR_WIDTH));
|
||||
split.putClientProperty("FlatLaf.style", "style: grip");
|
||||
return split;
|
||||
}
|
||||
|
||||
private JPanel buildEditor() {
|
||||
JPanel editor = new JPanel();
|
||||
JPanel editor = new ViewportWidthPanel();
|
||||
editor.setLayout(new BoxLayout(editor, BoxLayout.Y_AXIS));
|
||||
editor.setBackground(SIDEBAR_BACKGROUND);
|
||||
editor.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
|
||||
editor.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
editor.setBorder(StudioTheme.emptyBorder(10, 10, 10, 10));
|
||||
editor.add(section(DesktopUiMessages.IMAGEMAP_METADATA, metadataPanel()));
|
||||
editor.add(Box.createVerticalStrut(9));
|
||||
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
|
||||
editor.add(section(DesktopUiMessages.IMAGEMAP_RESOURCE, resourcePanel()));
|
||||
editor.add(Box.createVerticalStrut(9));
|
||||
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
|
||||
editor.add(section(DesktopUiMessages.IMAGEMAP_COORDINATES, coordinatePanel()));
|
||||
editor.add(Box.createVerticalStrut(9));
|
||||
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
|
||||
typeCards.add(heightPanel(), "HEIGHT");
|
||||
typeCards.add(colorPanel(), "COLOR");
|
||||
typeCards.add(maskPanel(), "MASK");
|
||||
typeCards.setBackground(SIDEBAR_BACKGROUND);
|
||||
typeCards.setOpaque(false);
|
||||
typeCards.setAlignmentX(Component.LEFT_ALIGNMENT);
|
||||
editor.add(typeCards);
|
||||
editor.add(Box.createVerticalStrut(9));
|
||||
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
|
||||
editor.add(section(DesktopUiMessages.IMAGEMAP_COMPOSED_MASKS, composedMaskPanel()));
|
||||
editor.add(Box.createVerticalStrut(9));
|
||||
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
|
||||
editor.add(section(DesktopUiMessages.IMAGEMAP_OVERLAYS, overlayPanel()));
|
||||
editor.add(Box.createVerticalStrut(9));
|
||||
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
|
||||
editor.add(section(DesktopUiMessages.IMAGEMAP_DIAGNOSTICS, diagnosticsPanel()));
|
||||
return editor;
|
||||
}
|
||||
@@ -358,18 +325,15 @@ public final class ImageMapStudioGUI {
|
||||
}
|
||||
|
||||
private JPanel colorPanel() {
|
||||
JPanel panel = new JPanel(new BorderLayout(0, 7));
|
||||
JPanel panel = new JPanel(new BorderLayout(0, StudioTheme.scale(7)));
|
||||
panel.setOpaque(false);
|
||||
JPanel settings = form();
|
||||
addRow(settings, 0, DesktopUiMessages.IMAGEMAP_COLOR_TOLERANCE, toleranceField);
|
||||
addRow(settings, 1, DesktopUiMessages.IMAGEMAP_UNKNOWN_COLOR, unknownColorPicker);
|
||||
panel.add(settings, BorderLayout.NORTH);
|
||||
configureLegendTable();
|
||||
JScrollPane scroll = new JScrollPane(legendTable);
|
||||
scroll.setPreferredSize(new Dimension(390, 160));
|
||||
scroll.setBorder(BorderFactory.createLineBorder(BORDER));
|
||||
panel.add(scroll, BorderLayout.CENTER);
|
||||
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
|
||||
configureTable(legendTable);
|
||||
panel.add(tableScroll(legendTable, 160), BorderLayout.CENTER);
|
||||
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, StudioTheme.scale(6), 0));
|
||||
actions.setOpaque(false);
|
||||
JButton add = button(DesktopUiMessages.IMAGEMAP_ADD_COLOR);
|
||||
add.addActionListener(event -> legendModel.addRow(new Object[]{"#000000", "iris:"}));
|
||||
@@ -382,7 +346,7 @@ public final class ImageMapStudioGUI {
|
||||
}
|
||||
|
||||
private JPanel overlayPanel() {
|
||||
JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT, 9, 2));
|
||||
JPanel panel = new JPanel(new GridLayout(0, OVERLAY_COLUMNS, StudioTheme.scale(9), StudioTheme.scale(2)));
|
||||
panel.setOpaque(false);
|
||||
chunksOverlay.setText(text(DesktopUiMessages.IMAGEMAP_CHUNKS));
|
||||
regionsOverlay.setText(text(DesktopUiMessages.IMAGEMAP_REGIONS));
|
||||
@@ -396,13 +360,10 @@ public final class ImageMapStudioGUI {
|
||||
}
|
||||
|
||||
private JPanel composedMaskPanel() {
|
||||
JPanel panel = new JPanel(new BorderLayout(0, 7));
|
||||
JPanel panel = new JPanel(new BorderLayout(0, StudioTheme.scale(7)));
|
||||
panel.setOpaque(false);
|
||||
JScrollPane scroll = new JScrollPane(composedMaskTable);
|
||||
scroll.setPreferredSize(new Dimension(390, 120));
|
||||
scroll.setBorder(BorderFactory.createLineBorder(BORDER));
|
||||
panel.add(scroll, BorderLayout.CENTER);
|
||||
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
|
||||
panel.add(tableScroll(composedMaskTable, 120), BorderLayout.CENTER);
|
||||
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, StudioTheme.scale(6), 0));
|
||||
actions.setOpaque(false);
|
||||
JButton add = button(DesktopUiMessages.IMAGEMAP_ADD_MASK);
|
||||
add.addActionListener(event -> composedMaskModel.addRow(new Object[]{
|
||||
@@ -421,21 +382,6 @@ public final class ImageMapStudioGUI {
|
||||
return single(diagnosticsArea);
|
||||
}
|
||||
|
||||
private JPanel buildStatusBar() {
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
panel.setBackground(PANEL_BACKGROUND);
|
||||
panel.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, BORDER),
|
||||
BorderFactory.createEmptyBorder(5, 9, 5, 9)
|
||||
));
|
||||
statusLabel.setForeground(SECONDARY_TEXT);
|
||||
statusLabel.setFont(BODY_FONT);
|
||||
statusLabel.setText(packFolder.getName() + " | " + dimensionKey + " | "
|
||||
+ text(DesktopUiMessages.IMAGEMAP_READY));
|
||||
panel.add(statusLabel, BorderLayout.WEST);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private void refreshPresets() {
|
||||
IrisData data = IrisData.openRuntime(packFolder);
|
||||
try {
|
||||
@@ -518,7 +464,7 @@ public final class ImageMapStudioGUI {
|
||||
? DesktopUiMessages.IMAGEMAP_REPLACE_PNG
|
||||
: DesktopUiMessages.IMAGEMAP_IMPORT_PNG));
|
||||
chooser.setFileFilter(new FileNameExtensionFilter("PNG", "png"));
|
||||
if (chooser.showOpenDialog(frame) != JFileChooser.APPROVE_OPTION) {
|
||||
if (chooser.showOpenDialog(view) != JFileChooser.APPROVE_OPTION) {
|
||||
return;
|
||||
}
|
||||
sourcePath = chooser.getSelectedFile().toPath().toAbsolutePath().normalize();
|
||||
@@ -626,7 +572,7 @@ public final class ImageMapStudioGUI {
|
||||
diagnosticsArea.setText(warnings.isEmpty()
|
||||
? text(DesktopUiMessages.IMAGEMAP_PREVIEW_VALID)
|
||||
: String.join("\n", warnings));
|
||||
diagnosticsArea.setForeground(warnings.isEmpty() ? SUCCESS : PRIMARY_TEXT);
|
||||
diagnosticsArea.setForeground(warnings.isEmpty() ? StudioTheme.SUCCESS : StudioTheme.TEXT);
|
||||
setStatus(DesktopUiMessages.IMAGEMAP_READY);
|
||||
} catch (Exception exception) {
|
||||
showFailure(text(DesktopUiMessages.IMAGEMAP_PREVIEW_FAILED), exception);
|
||||
@@ -682,7 +628,7 @@ public final class ImageMapStudioGUI {
|
||||
for (String warning : result.warnings()) {
|
||||
message.append('\n').append(warning);
|
||||
}
|
||||
diagnosticsArea.setForeground(SUCCESS);
|
||||
diagnosticsArea.setForeground(StudioTheme.SUCCESS);
|
||||
diagnosticsArea.setText(message.toString());
|
||||
setStatus(DesktopUiMessages.IMAGEMAP_EXPORTED);
|
||||
refreshPresets();
|
||||
@@ -853,35 +799,25 @@ public final class ImageMapStudioGUI {
|
||||
);
|
||||
}
|
||||
|
||||
private void configureLegendTable() {
|
||||
legendTable.setBackground(FIELD_BACKGROUND);
|
||||
legendTable.setForeground(PRIMARY_TEXT);
|
||||
legendTable.setGridColor(BORDER);
|
||||
legendTable.setSelectionBackground(new Color(42, 55, 78));
|
||||
legendTable.setSelectionForeground(PRIMARY_TEXT);
|
||||
legendTable.setFont(BODY_FONT);
|
||||
legendTable.setRowHeight(23);
|
||||
legendTable.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
|
||||
legendTable.getTableHeader().setBackground(PANEL_BACKGROUND);
|
||||
legendTable.getTableHeader().setForeground(PRIMARY_TEXT);
|
||||
private static void configureTable(JTable table) {
|
||||
table.setRowHeight(StudioTheme.scale(TABLE_ROW_HEIGHT));
|
||||
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
|
||||
}
|
||||
|
||||
private void configureComposedMaskTable() {
|
||||
composedMaskTable.setBackground(FIELD_BACKGROUND);
|
||||
composedMaskTable.setForeground(PRIMARY_TEXT);
|
||||
composedMaskTable.setGridColor(BORDER);
|
||||
composedMaskTable.setSelectionBackground(new Color(42, 55, 78));
|
||||
composedMaskTable.setSelectionForeground(PRIMARY_TEXT);
|
||||
composedMaskTable.setFont(BODY_FONT);
|
||||
composedMaskTable.setRowHeight(23);
|
||||
composedMaskTable.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
|
||||
composedMaskTable.getTableHeader().setBackground(PANEL_BACKGROUND);
|
||||
composedMaskTable.getTableHeader().setForeground(PRIMARY_TEXT);
|
||||
configureTable(composedMaskTable);
|
||||
composedMaskTable.getColumnModel().getColumn(1).setCellEditor(
|
||||
new DefaultCellEditor(new JComboBox<>(IrisImageMapMaskOperation.values()))
|
||||
);
|
||||
}
|
||||
|
||||
private static JScrollPane tableScroll(JTable table, int height) {
|
||||
JScrollPane scroll = new JScrollPane(table);
|
||||
scroll.setPreferredSize(StudioTheme.scaledSize(390, height));
|
||||
scroll.setBorder(BorderFactory.createLineBorder(StudioTheme.CONTROL_BORDER));
|
||||
return scroll;
|
||||
}
|
||||
|
||||
private void removeLegendRows() {
|
||||
removeRows(legendTable, legendModel);
|
||||
}
|
||||
@@ -927,7 +863,7 @@ public final class ImageMapStudioGUI {
|
||||
return;
|
||||
}
|
||||
String current = diagnosticsArea.getText();
|
||||
diagnosticsArea.setForeground(ERROR);
|
||||
diagnosticsArea.setForeground(StudioTheme.ERROR);
|
||||
diagnosticsArea.setText(current.isBlank() ? diagnostic : current + "\n" + diagnostic);
|
||||
}
|
||||
|
||||
@@ -939,13 +875,13 @@ public final class ImageMapStudioGUI {
|
||||
if (!(cause instanceof IllegalArgumentException)) {
|
||||
IrisLogging.reportError(cause);
|
||||
}
|
||||
diagnosticsArea.setForeground(ERROR);
|
||||
diagnosticsArea.setForeground(StudioTheme.ERROR);
|
||||
diagnosticsArea.setText(prefix + ": " + String.valueOf(cause.getMessage()));
|
||||
statusLabel.setText(prefix);
|
||||
statusBar.showError(prefix);
|
||||
}
|
||||
|
||||
private void setStatus(TextKey key) {
|
||||
statusLabel.setText(packFolder.getName() + " | " + dimensionKey + " | " + text(key));
|
||||
statusBar.showSummary(packFolder.getName() + STATUS_SEPARATOR + dimensionKey + STATUS_SEPARATOR + text(key));
|
||||
}
|
||||
|
||||
private void requireSource() {
|
||||
@@ -961,42 +897,41 @@ public final class ImageMapStudioGUI {
|
||||
}
|
||||
|
||||
private static void addRow(JPanel panel, int row, TextKey key, Component component) {
|
||||
int rowGap = StudioTheme.scale(3);
|
||||
GridBagConstraints labelConstraints = new GridBagConstraints();
|
||||
labelConstraints.gridx = 0;
|
||||
labelConstraints.gridy = row;
|
||||
labelConstraints.anchor = GridBagConstraints.WEST;
|
||||
labelConstraints.insets = new Insets(3, 0, 3, 8);
|
||||
JLabel label = label(key);
|
||||
panel.add(label, labelConstraints);
|
||||
labelConstraints.insets = new Insets(rowGap, 0, rowGap, StudioTheme.scale(8));
|
||||
panel.add(label(key), labelConstraints);
|
||||
|
||||
GridBagConstraints componentConstraints = new GridBagConstraints();
|
||||
componentConstraints.gridx = 1;
|
||||
componentConstraints.gridy = row;
|
||||
componentConstraints.weightx = 1D;
|
||||
componentConstraints.fill = GridBagConstraints.HORIZONTAL;
|
||||
componentConstraints.insets = new Insets(3, 0, 3, 0);
|
||||
componentConstraints.insets = new Insets(rowGap, 0, rowGap, 0);
|
||||
panel.add(component, componentConstraints);
|
||||
}
|
||||
|
||||
private static JPanel section(TextKey title, Component content) {
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
panel.setAlignmentX(Component.LEFT_ALIGNMENT);
|
||||
panel.setBackground(PANEL_BACKGROUND);
|
||||
panel.setBorder(BorderFactory.createLineBorder(BORDER));
|
||||
panel.setOpaque(false);
|
||||
panel.setBorder(BorderFactory.createLineBorder(StudioTheme.CONTROL_BORDER));
|
||||
JLabel heading = new JLabel(text(title));
|
||||
heading.setForeground(PRIMARY_TEXT);
|
||||
heading.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 12));
|
||||
heading.setFont(StudioTheme.sectionFont());
|
||||
heading.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER),
|
||||
BorderFactory.createEmptyBorder(7, 9, 7, 9)
|
||||
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.CONTROL_BORDER),
|
||||
StudioTheme.emptyBorder(7, 9, 7, 9)
|
||||
));
|
||||
panel.add(heading, BorderLayout.NORTH);
|
||||
JPanel body = new JPanel(new BorderLayout());
|
||||
body.setOpaque(false);
|
||||
body.setBorder(BorderFactory.createEmptyBorder(8, 9, 9, 9));
|
||||
body.setBorder(StudioTheme.emptyBorder(8, 9, 9, 9));
|
||||
body.add(content, BorderLayout.CENTER);
|
||||
panel.add(body, BorderLayout.CENTER);
|
||||
panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height + 300));
|
||||
panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height + StudioTheme.scale(300)));
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -1009,34 +944,19 @@ public final class ImageMapStudioGUI {
|
||||
|
||||
private static JLabel label(TextKey key) {
|
||||
JLabel label = new JLabel(text(key));
|
||||
label.setForeground(SECONDARY_TEXT);
|
||||
label.setFont(LABEL_FONT);
|
||||
label.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
label.setFont(StudioTheme.smallFont());
|
||||
return label;
|
||||
}
|
||||
|
||||
private static JButton button(TextKey key) {
|
||||
JButton button = new JButton(text(key));
|
||||
button.setBackground(FIELD_BACKGROUND);
|
||||
button.setForeground(PRIMARY_TEXT);
|
||||
button.setFocusPainted(false);
|
||||
button.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(BORDER),
|
||||
BorderFactory.createEmptyBorder(5, 9, 5, 9)
|
||||
));
|
||||
button.setFocusable(false);
|
||||
return button;
|
||||
}
|
||||
|
||||
private static JTextField field(String value) {
|
||||
JTextField field = new JTextField(value);
|
||||
field.setBackground(FIELD_BACKGROUND);
|
||||
field.setForeground(PRIMARY_TEXT);
|
||||
field.setCaretColor(PRIMARY_TEXT);
|
||||
field.setFont(BODY_FONT);
|
||||
field.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(BORDER),
|
||||
BorderFactory.createEmptyBorder(5, 7, 5, 7)
|
||||
));
|
||||
return field;
|
||||
return new JTextField(value);
|
||||
}
|
||||
|
||||
private static JTextArea area(int rows) {
|
||||
@@ -1044,18 +964,14 @@ public final class ImageMapStudioGUI {
|
||||
area.setEditable(false);
|
||||
area.setLineWrap(true);
|
||||
area.setWrapStyleWord(true);
|
||||
area.setBackground(FIELD_BACKGROUND);
|
||||
area.setForeground(PRIMARY_TEXT);
|
||||
area.setFont(BODY_FONT);
|
||||
area.setBorder(BorderFactory.createEmptyBorder(6, 7, 6, 7));
|
||||
area.setBorder(StudioTheme.emptyBorder(6, 7, 6, 7));
|
||||
return area;
|
||||
}
|
||||
|
||||
private static JCheckBox check() {
|
||||
JCheckBox check = new JCheckBox();
|
||||
check.setOpaque(false);
|
||||
check.setForeground(PRIMARY_TEXT);
|
||||
check.setFocusPainted(false);
|
||||
check.setFocusable(false);
|
||||
return check;
|
||||
}
|
||||
|
||||
@@ -1067,15 +983,8 @@ public final class ImageMapStudioGUI {
|
||||
|
||||
private static <T> JComboBox<T> combo(T[] values) {
|
||||
JComboBox<T> combo = new JComboBox<>(values);
|
||||
styleCombo(combo);
|
||||
return combo;
|
||||
}
|
||||
|
||||
private static void styleCombo(JComboBox<?> combo) {
|
||||
combo.setBackground(FIELD_BACKGROUND);
|
||||
combo.setForeground(PRIMARY_TEXT);
|
||||
combo.setFont(BODY_FONT);
|
||||
combo.setFocusable(false);
|
||||
return combo;
|
||||
}
|
||||
|
||||
private static double number(JTextField field, String name) {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package art.arcane.iris.studio.view;
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.generation.image.IrisImage;
|
||||
import art.arcane.iris.generation.image.IrisImageMap;
|
||||
+19
-25
@@ -1,4 +1,4 @@
|
||||
package art.arcane.iris.studio.view;
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.localization.DesktopUiMessages;
|
||||
import art.arcane.iris.localization.IrisLanguage;
|
||||
@@ -40,11 +40,9 @@ import java.util.function.Consumer;
|
||||
import java.util.function.DoubleBinaryOperator;
|
||||
|
||||
final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
||||
private static final Color BACKGROUND = new Color(12, 15, 22);
|
||||
private static final Color PANEL_BACKGROUND = new Color(20, 24, 33);
|
||||
private static final Color BORDER = new Color(48, 55, 72);
|
||||
private static final Color TEXT = new Color(230, 234, 242);
|
||||
private static final Color SECONDARY = new Color(151, 160, 178);
|
||||
// The images and their overlay lines are drawn for a dark backdrop, in either theme.
|
||||
private static final Color CANVAS_BACKGROUND = new Color(12, 15, 22);
|
||||
private static final Color CANVAS_TEXT = new Color(151, 160, 178);
|
||||
|
||||
private final SourceCanvas sourceCanvas = new SourceCanvas();
|
||||
private final WorldCanvas worldCanvas = new WorldCanvas();
|
||||
@@ -52,21 +50,19 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
||||
|
||||
ImageMapStudioPreviewPanel() {
|
||||
super(new BorderLayout());
|
||||
setBackground(BACKGROUND);
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
JPanel source = wrap(IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_SOURCE), sourceCanvas);
|
||||
JPanel interpreted = wrap(IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_INTERPRETED), worldCanvas);
|
||||
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, source, interpreted);
|
||||
split.setBorder(BorderFactory.createEmptyBorder());
|
||||
split.setDividerSize(5);
|
||||
split.setResizeWeight(0.42D);
|
||||
split.setBackground(BACKGROUND);
|
||||
split.putClientProperty("FlatLaf.style", "style: grip");
|
||||
add(split, BorderLayout.CENTER);
|
||||
statusLabel.setForeground(SECONDARY);
|
||||
statusLabel.setBackground(PANEL_BACKGROUND);
|
||||
statusLabel.setOpaque(true);
|
||||
statusLabel.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
statusLabel.setFont(StudioTheme.smallFont());
|
||||
statusLabel.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, BORDER),
|
||||
BorderFactory.createEmptyBorder(5, 9, 5, 9)
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, StudioTheme.SEPARATOR),
|
||||
StudioTheme.emptyBorder(5, 9, 5, 9)
|
||||
));
|
||||
add(statusLabel, BorderLayout.SOUTH);
|
||||
worldCanvas.setHoverConsumer(statusLabel::setText);
|
||||
@@ -123,15 +119,13 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
||||
|
||||
private static JPanel wrap(String title, JPanel canvas) {
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
panel.setBackground(PANEL_BACKGROUND);
|
||||
panel.setBorder(BorderFactory.createLineBorder(BORDER));
|
||||
panel.setOpaque(false);
|
||||
panel.setBorder(BorderFactory.createLineBorder(StudioTheme.SEPARATOR));
|
||||
JLabel label = new JLabel(title);
|
||||
label.setForeground(TEXT);
|
||||
label.setBackground(PANEL_BACKGROUND);
|
||||
label.setOpaque(true);
|
||||
label.setFont(StudioTheme.sectionFont());
|
||||
label.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER),
|
||||
BorderFactory.createEmptyBorder(6, 9, 6, 9)
|
||||
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR),
|
||||
StudioTheme.emptyBorder(6, 9, 6, 9)
|
||||
));
|
||||
panel.add(label, BorderLayout.NORTH);
|
||||
panel.add(canvas, BorderLayout.CENTER);
|
||||
@@ -142,7 +136,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
||||
private BufferedImage source;
|
||||
|
||||
private SourceCanvas() {
|
||||
setBackground(BACKGROUND);
|
||||
setBackground(CANVAS_BACKGROUND);
|
||||
setPreferredSize(new Dimension(420, 520));
|
||||
}
|
||||
|
||||
@@ -217,7 +211,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
||||
private double dragCenterZ;
|
||||
|
||||
private WorldCanvas() {
|
||||
setBackground(BACKGROUND);
|
||||
setBackground(CANVAS_BACKGROUND);
|
||||
setPreferredSize(new Dimension(620, 520));
|
||||
MouseAdapter mouse = new MouseAdapter() {
|
||||
@Override
|
||||
@@ -414,7 +408,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
||||
);
|
||||
case COLOR_MAP -> categoricalApplication()
|
||||
&& !IrisImageMapRuntime.selectCategorical(maskWeight)
|
||||
? BACKGROUND.getRGB()
|
||||
? CANVAS_BACKGROUND.getRGB()
|
||||
: ImageMapStudioModel.targetColor(compiled.sampleTarget(worldX, worldZ));
|
||||
case BINARY_MASK, GRAYSCALE_MASK, ALPHA_MASK -> grayscale(
|
||||
compiled.sampleNormalized(worldX, worldZ)
|
||||
@@ -603,7 +597,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
||||
private static void drawEmpty(Graphics graphics, String text) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setColor(SECONDARY);
|
||||
canvas.setColor(CANVAS_TEXT);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
int width = canvas.getFontMetrics().stringWidth(text);
|
||||
int x = Math.max(8, (graphics.getClipBounds() == null ? 0 : graphics.getClipBounds().width - width) / 2);
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLayeredPane;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
|
||||
/**
|
||||
* Lays an island over content that fills the whole area behind it. Dragging the island's right edge resizes it,
|
||||
* always leaving the content some room beside it.
|
||||
*/
|
||||
final class IslandOverlay extends JLayeredPane {
|
||||
private static final long serialVersionUID = -4182209135360718342L;
|
||||
|
||||
private final IslandPanel island;
|
||||
private final JComponent background;
|
||||
private final int minimumIslandWidth;
|
||||
private final int minimumUncoveredWidth;
|
||||
private int islandWidth;
|
||||
|
||||
IslandOverlay(IslandPanel island, JComponent background, int islandWidth, int minimumIslandWidth, int minimumUncoveredWidth) {
|
||||
this.island = island;
|
||||
this.background = background;
|
||||
this.islandWidth = islandWidth;
|
||||
this.minimumIslandWidth = minimumIslandWidth;
|
||||
this.minimumUncoveredWidth = minimumUncoveredWidth;
|
||||
add(background, JLayeredPane.DEFAULT_LAYER);
|
||||
add(island, JLayeredPane.PALETTE_LAYER);
|
||||
installResizing();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doLayout() {
|
||||
background.setBounds(0, 0, getWidth(), getHeight());
|
||||
island.setBounds(0, 0, clampedIslandWidth(), getHeight());
|
||||
}
|
||||
|
||||
private int clampedIslandWidth() {
|
||||
int widest = Math.max(minimumIslandWidth, getWidth() - minimumUncoveredWidth);
|
||||
return Math.clamp(islandWidth, minimumIslandWidth, widest);
|
||||
}
|
||||
|
||||
private void installResizing() {
|
||||
MouseAdapter resizer = new MouseAdapter() {
|
||||
private boolean dragging;
|
||||
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent event) {
|
||||
island.setCursor(island.isOnResizeEdge(event.getX(), event.getY())
|
||||
? Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR)
|
||||
: Cursor.getDefaultCursor());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent event) {
|
||||
if (!dragging) {
|
||||
island.setCursor(Cursor.getDefaultCursor());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent event) {
|
||||
dragging = island.isOnResizeEdge(event.getX(), event.getY());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent event) {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
islandWidth = event.getX();
|
||||
islandWidth = clampedIslandWidth();
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent event) {
|
||||
dragging = false;
|
||||
}
|
||||
};
|
||||
island.addMouseListener(resizer);
|
||||
island.addMouseMotionListener(resizer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JPanel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A rounded panel floating over the window content with a gap all around it, like the tool windows of IntelliJ's
|
||||
* islands theme. The gap belongs to the content behind it, except for the strip along the right edge, which grabs the
|
||||
* island to resize it.
|
||||
*/
|
||||
final class IslandPanel extends JPanel implements FloatingPanels.PartlySeeThrough {
|
||||
private static final long serialVersionUID = 7313084027614358519L;
|
||||
private static final int GAP = 6;
|
||||
private static final int CORNER_ARC = 12;
|
||||
private static final int INNER_PADDING = 4;
|
||||
|
||||
IslandPanel(JComponent content) {
|
||||
super(new BorderLayout());
|
||||
setOpaque(false);
|
||||
int inset = GAP + INNER_PADDING;
|
||||
setBorder(StudioTheme.emptyBorder(inset, inset, inset, inset));
|
||||
add(content, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
boolean isOnResizeEdge(int x, int y) {
|
||||
int edgeWidth = StudioTheme.scale(GAP + INNER_PADDING);
|
||||
return x >= getWidth() - edgeWidth && x < getWidth() && y >= 0 && y < getHeight();
|
||||
}
|
||||
|
||||
// Clicks in the gap reach the content behind the island instead of the island itself.
|
||||
@Override
|
||||
public boolean contains(int x, int y) {
|
||||
return island().contains(x, y) || isOnResizeEdge(x, y);
|
||||
}
|
||||
|
||||
// The gap all around, reaching in as far as the rounded corners do.
|
||||
@Override
|
||||
public List<Rectangle> seeThroughParts() {
|
||||
int width = getWidth();
|
||||
int height = getHeight();
|
||||
int gap = StudioTheme.scale(GAP);
|
||||
int edge = gap + (int) Math.ceil(StudioTheme.scale((double) CORNER_ARC) / 2D);
|
||||
return List.of(
|
||||
new Rectangle(0, 0, width, edge),
|
||||
new Rectangle(0, height - edge, width, edge),
|
||||
new Rectangle(0, edge, gap, height - edge * 2),
|
||||
new Rectangle(width - gap, edge, gap, height - edge * 2));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
|
||||
canvas.fill(island());
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private RoundRectangle2D island() {
|
||||
double gap = StudioTheme.scale(GAP);
|
||||
double arc = StudioTheme.scale(CORNER_ARC);
|
||||
return new RoundRectangle2D.Double(gap, gap, getWidth() - gap * 2D, getHeight() - gap * 2D, arc, arc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Measures off the Swing thread, one measurement at a time, and only hands back the newest one asked for.
|
||||
*/
|
||||
final class LatestMeasurement<T> {
|
||||
private final ThreadPoolExecutor worker;
|
||||
private final AtomicLong generation = new AtomicLong();
|
||||
|
||||
LatestMeasurement(String threadName) {
|
||||
this.worker = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(1), task -> {
|
||||
Thread thread = new Thread(task, threadName);
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}, new ThreadPoolExecutor.DiscardOldestPolicy());
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the measurement and shows its result on the Swing thread, unless another was asked for meanwhile or it
|
||||
* measured nothing.
|
||||
*/
|
||||
void measure(Supplier<T> measurement, Consumer<T> show) {
|
||||
measureUntilSuperseded(stillWanted -> measurement.get(), show);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #measure}, for long measurements that ask {@code stillWanted} between steps and give up once another
|
||||
* measurement was asked for.
|
||||
*/
|
||||
void measureUntilSuperseded(Function<BooleanSupplier, T> measurement, Consumer<T> show) {
|
||||
long requested = generation.incrementAndGet();
|
||||
BooleanSupplier stillWanted = () -> requested == generation.get();
|
||||
worker.execute(() -> {
|
||||
T result = measureSafely(() -> measurement.apply(stillWanted));
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (requested == generation.get() && result != null) {
|
||||
show.accept(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the measurement under way, whose result no longer fits.
|
||||
*/
|
||||
void discardPending() {
|
||||
generation.incrementAndGet();
|
||||
}
|
||||
|
||||
void close() {
|
||||
worker.shutdownNow();
|
||||
}
|
||||
|
||||
private static <T> T measureSafely(Supplier<T> measurement) {
|
||||
try {
|
||||
return measurement.get();
|
||||
} catch (Throwable error) {
|
||||
IrisLogging.reportError(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.Path2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
|
||||
/**
|
||||
* A little cube of blocks building itself while something loads: the blocks drop in one by one, the cube holds a
|
||||
* moment, fades, and starts over. Loads shorter than {@link #SHOW_AFTER_MILLIS} never show it, and once it shows it
|
||||
* stays for {@link #SHOW_AT_LEAST_MILLIS}, so it never flashes.
|
||||
*/
|
||||
final class LoadingAnimation {
|
||||
static final int SHOW_AFTER_MILLIS = 300;
|
||||
static final int SHOW_AT_LEAST_MILLIS = 700;
|
||||
static final int FRAME_MILLIS = 33;
|
||||
|
||||
// Back to front, so each block lands on or beside ones already there.
|
||||
private static final int[][] BUILD_ORDER = {
|
||||
{0, 0, 0}, {1, 0, 0}, {0, 0, 1}, {1, 0, 1},
|
||||
{0, 1, 0}, {1, 1, 0}, {0, 1, 1}, {1, 1, 1}};
|
||||
private static final int STEP_MILLIS = 140;
|
||||
private static final int DROP_MILLIS = 260;
|
||||
private static final int HOLD_MILLIS = 450;
|
||||
private static final int FADE_MILLIS = 320;
|
||||
private static final int CYCLE_MILLIS = STEP_MILLIS * (BUILD_ORDER.length - 1) + DROP_MILLIS + HOLD_MILLIS + FADE_MILLIS;
|
||||
private static final double DROP_HEIGHT_BLOCKS = 1.6D;
|
||||
private static final double ISOMETRIC_WIDTH = Math.cos(Math.PI / 6D);
|
||||
private static final double ISOMETRIC_DEPTH = 0.5D;
|
||||
private static final float SIDE_SHADE = 0.78F;
|
||||
private static final float FAR_SIDE_SHADE = 0.6F;
|
||||
|
||||
private LoadingAnimation() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a load that began at {@code loadingSinceMillis} has taken long enough to show the animation.
|
||||
*/
|
||||
static boolean isDue(long loadingSinceMillis) {
|
||||
return System.currentTimeMillis() - loadingSinceMillis >= SHOW_AFTER_MILLIS;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much longer the animation of a load that began at {@code loadingSinceMillis} has to stay up once the load is
|
||||
* done; zero when it never showed or has shown long enough.
|
||||
*/
|
||||
static long remainingShowMillis(long loadingSinceMillis) {
|
||||
if (!isDue(loadingSinceMillis)) {
|
||||
return 0L;
|
||||
}
|
||||
long shownMillis = System.currentTimeMillis() - loadingSinceMillis - SHOW_AFTER_MILLIS;
|
||||
return Math.max(0L, SHOW_AT_LEAST_MILLIS - shownMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the cube centred on a point, {@code blockSize} pixels to a block edge, as it looks
|
||||
* {@code elapsedMillis} after it first showed.
|
||||
*/
|
||||
static void paint(Graphics2D graphics, double centerX, double centerY, double blockSize, long elapsedMillis) {
|
||||
long time = Math.floorMod(elapsedMillis, CYCLE_MILLIS);
|
||||
long fadeStart = CYCLE_MILLIS - FADE_MILLIS;
|
||||
if (time < fadeStart) {
|
||||
drawCube(graphics, centerX, centerY, blockSize, time);
|
||||
return;
|
||||
}
|
||||
fadeCube(graphics, centerX, centerY, blockSize, time, 1F - (time - fadeStart) / (float) FADE_MILLIS);
|
||||
}
|
||||
|
||||
// Faded block by block, the blocks behind would show through; the whole cube is drawn first and faded as one.
|
||||
private static void fadeCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time, float opacity) {
|
||||
int extent = (int) Math.ceil(blockSize * (2D + DROP_HEIGHT_BLOCKS) * 2D);
|
||||
BufferedImage cube = new BufferedImage(extent, extent, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D cubeCanvas = cube.createGraphics();
|
||||
drawCube(cubeCanvas, extent / 2D, extent / 2D, blockSize, time);
|
||||
cubeCanvas.dispose();
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, Math.max(0F, opacity)));
|
||||
canvas.drawImage(cube, (int) Math.round(centerX - extent / 2D), (int) Math.round(centerY - extent / 2D), null);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void drawCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
for (int index = 0; index < BUILD_ORDER.length; index++) {
|
||||
long sinceDropStart = time - (long) index * STEP_MILLIS;
|
||||
if (sinceDropStart < 0) {
|
||||
break;
|
||||
}
|
||||
double fallen = easeOut(Math.min(1D, sinceDropStart / (double) DROP_MILLIS));
|
||||
int[] block = BUILD_ORDER[index];
|
||||
drawBlock(canvas, centerX, centerY, blockSize, block[0], block[1] + DROP_HEIGHT_BLOCKS * (1D - fallen), block[2]);
|
||||
}
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static double easeOut(double progress) {
|
||||
double remaining = 1D - progress;
|
||||
return 1D - remaining * remaining * remaining;
|
||||
}
|
||||
|
||||
// The cube's middle, (1, 1, 1), lands on the centre point; the top and the two sides facing the viewer show.
|
||||
private static void drawBlock(Graphics2D canvas, double centerX, double centerY, double size, double x, double y, double z) {
|
||||
Color top = StudioTheme.ACCENT;
|
||||
canvas.setColor(top);
|
||||
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}}));
|
||||
canvas.setColor(shaded(top, SIDE_SHADE));
|
||||
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{1, 0, 0}, {1, 1, 0}, {1, 1, 1}, {1, 0, 1}}));
|
||||
canvas.setColor(shaded(top, FAR_SIDE_SHADE));
|
||||
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}}));
|
||||
}
|
||||
|
||||
private static Path2D face(double centerX, double centerY, double size, double x, double y, double z, double[][] corners) {
|
||||
Path2D path = new Path2D.Double();
|
||||
for (int corner = 0; corner < corners.length; corner++) {
|
||||
double cornerX = x + corners[corner][0] - 1D;
|
||||
double cornerY = y + corners[corner][1] - 1D;
|
||||
double cornerZ = z + corners[corner][2] - 1D;
|
||||
double screenX = centerX + (cornerX - cornerZ) * ISOMETRIC_WIDTH * size;
|
||||
double screenY = centerY + (cornerX + cornerZ) * ISOMETRIC_DEPTH * size - cornerY * size;
|
||||
if (corner == 0) {
|
||||
path.moveTo(screenX, screenY);
|
||||
} else {
|
||||
path.lineTo(screenX, screenY);
|
||||
}
|
||||
}
|
||||
path.closePath();
|
||||
return path;
|
||||
}
|
||||
|
||||
private static Color shaded(Color color, float shade) {
|
||||
return new Color(Math.round(color.getRed() * shade), Math.round(color.getGreen() * shade), Math.round(color.getBlue() * shade));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.components.MapLabels;
|
||||
import art.arcane.iris.studio.map.components.MapStyle;
|
||||
import art.arcane.iris.studio.map.components.MapTile;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.ToIntFunction;
|
||||
|
||||
/**
|
||||
* Measures the visible part of the map from the tiles already drawn: how much each biome or region covers, and which
|
||||
* heights it spans.
|
||||
*/
|
||||
final class MapCensus {
|
||||
private static final long MAXIMUM_COUNTED_PIXELS = 250_000L;
|
||||
private static final int MINIMUM_HEIGHT_SAMPLES = 64;
|
||||
private static final double LOW_HEIGHT_PERCENTILE = 0.01D;
|
||||
private static final double HIGH_HEIGHT_PERCENTILE = 0.99D;
|
||||
private static final double HEIGHT_RANGE_STEP_BLOCKS = 8D;
|
||||
private static final double MINIMUM_WATER_DEPTH_BLOCKS = 8D;
|
||||
private static final double MINIMUM_LAND_HEIGHT_BLOCKS = 16D;
|
||||
|
||||
private MapCensus() {
|
||||
}
|
||||
|
||||
static List<Share> count(VisionRenderController.Frame frame, ViewArea area, ToIntFunction<MapLabels.Label> colorOf) {
|
||||
Map<Short, long[]> counts = new HashMap<>();
|
||||
MapLabels[] labels = new MapLabels[1];
|
||||
long counted = visitVisiblePixels(frame, area, (tile, pixelX, pixelZ) -> {
|
||||
labels[0] = tile.labels();
|
||||
counts.computeIfAbsent(tile.subjectAt(pixelX, pixelZ), id -> new long[1])[0]++;
|
||||
});
|
||||
return shares(counts, labels[0], counted, colorOf);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heights on screen, ignoring the highest and lowest few percent so one spike doesn't wash out every colour.
|
||||
* Rounded to whole steps, so panning around doesn't recolour the map for every small change.
|
||||
*
|
||||
* @return the range, or null until enough of the map is drawn to tell
|
||||
*/
|
||||
static MapStyle.HeightRange heightRange(VisionRenderController.Frame frame, ViewArea area, double seaLevel) {
|
||||
DoubleList heights = new DoubleList();
|
||||
visitVisiblePixels(frame, area, (tile, pixelX, pixelZ) -> {
|
||||
if (tile.hasHeights()) {
|
||||
heights.add(tile.heightAt(pixelX, pixelZ));
|
||||
}
|
||||
});
|
||||
if (heights.size() < MINIMUM_HEIGHT_SAMPLES) {
|
||||
return null;
|
||||
}
|
||||
double[] sorted = heights.sorted();
|
||||
double low = sorted[(int) ((sorted.length - 1) * LOW_HEIGHT_PERCENTILE)];
|
||||
double high = sorted[(int) ((sorted.length - 1) * HIGH_HEIGHT_PERCENTILE)];
|
||||
double lowest = Math.floor(Math.min(low, seaLevel - MINIMUM_WATER_DEPTH_BLOCKS) / HEIGHT_RANGE_STEP_BLOCKS)
|
||||
* HEIGHT_RANGE_STEP_BLOCKS;
|
||||
double highest = Math.ceil(Math.max(high, seaLevel + MINIMUM_LAND_HEIGHT_BLOCKS) / HEIGHT_RANGE_STEP_BLOCKS)
|
||||
* HEIGHT_RANGE_STEP_BLOCKS;
|
||||
return new MapStyle.HeightRange(lowest, seaLevel, highest);
|
||||
}
|
||||
|
||||
private static long visitVisiblePixels(VisionRenderController.Frame frame, ViewArea area, PixelVisitor visitor) {
|
||||
if (frame == null) {
|
||||
return 0L;
|
||||
}
|
||||
double blocksPerPixel = frame.renderBlocksPerPixel();
|
||||
int stride = countingStride(area.maximumX() - area.minimumX(), area.maximumZ() - area.minimumZ(), blocksPerPixel);
|
||||
double tileSpan = VisionRenderController.TILE_PIXELS * blocksPerPixel;
|
||||
long visited = 0L;
|
||||
for (VisionRenderController.VisibleTile visible : frame.tiles()) {
|
||||
MapTile tile = visible.tile();
|
||||
if (tile == null) {
|
||||
continue;
|
||||
}
|
||||
double originX = visible.tileX() * tileSpan;
|
||||
double originZ = visible.tileZ() * tileSpan;
|
||||
int firstX = firstPixel(area.minimumX() - originX, blocksPerPixel, stride, visible.tileX());
|
||||
int firstZ = firstPixel(area.minimumZ() - originZ, blocksPerPixel, stride, visible.tileZ());
|
||||
int endX = endPixel(area.maximumX() - originX, blocksPerPixel, tile.size());
|
||||
int endZ = endPixel(area.maximumZ() - originZ, blocksPerPixel, tile.size());
|
||||
for (int pixelZ = firstZ; pixelZ < endZ; pixelZ += stride) {
|
||||
for (int pixelX = firstX; pixelX < endX; pixelX += stride) {
|
||||
visitor.visit(tile, pixelX, pixelZ);
|
||||
visited++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return visited;
|
||||
}
|
||||
|
||||
private static List<Share> shares(Map<Short, long[]> counts, MapLabels labels, long counted,
|
||||
ToIntFunction<MapLabels.Label> colorOf) {
|
||||
if (labels == null || counted == 0L) {
|
||||
return List.of();
|
||||
}
|
||||
List<Share> shares = new ArrayList<>(counts.size());
|
||||
for (Map.Entry<Short, long[]> entry : counts.entrySet()) {
|
||||
MapLabels.Label label = labels.label(entry.getKey());
|
||||
if (label != null) {
|
||||
shares.add(new Share(label, colorOf.applyAsInt(label), entry.getValue()[0] / (double) counted));
|
||||
}
|
||||
}
|
||||
shares.sort(Comparator.comparingDouble(Share::fraction).reversed().thenComparing(share -> share.label().name()));
|
||||
return List.copyOf(shares);
|
||||
}
|
||||
|
||||
// Counting every pixel of a big screen is wasted work; an evenly spread sample gives the same percentages.
|
||||
private static int countingStride(double widthBlocks, double heightBlocks, double blocksPerPixel) {
|
||||
double pixels = (widthBlocks / blocksPerPixel) * (heightBlocks / blocksPerPixel);
|
||||
return Math.max(1, (int) Math.ceil(Math.sqrt(pixels / MAXIMUM_COUNTED_PIXELS)));
|
||||
}
|
||||
|
||||
// Starting on the global stride grid keeps neighbouring tiles from sampling the same columns twice as densely.
|
||||
private static int firstPixel(double offsetBlocks, double blocksPerPixel, int stride, long tileIndex) {
|
||||
int first = Math.max(0, (int) Math.ceil(offsetBlocks / blocksPerPixel));
|
||||
long globalPixel = tileIndex * VisionRenderController.TILE_PIXELS + first;
|
||||
int misalignment = (int) Math.floorMod(globalPixel, (long) stride);
|
||||
return misalignment == 0 ? first : first + stride - misalignment;
|
||||
}
|
||||
|
||||
private static int endPixel(double offsetBlocks, double blocksPerPixel, int size) {
|
||||
return Math.min(size, (int) Math.ceil(offsetBlocks / blocksPerPixel));
|
||||
}
|
||||
|
||||
record Share(MapLabels.Label label, int color, double fraction) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The world area the screen shows, in blocks.
|
||||
*/
|
||||
record ViewArea(double minimumX, double minimumZ, double maximumX, double maximumZ) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface PixelVisitor {
|
||||
void visit(MapTile tile, int pixelX, int pixelZ);
|
||||
}
|
||||
|
||||
private static final class DoubleList {
|
||||
private double[] values = new double[1024];
|
||||
private int size;
|
||||
|
||||
void add(double value) {
|
||||
if (size == values.length) {
|
||||
values = Arrays.copyOf(values, size * 2);
|
||||
}
|
||||
values[size++] = value;
|
||||
}
|
||||
|
||||
int size() {
|
||||
return size;
|
||||
}
|
||||
|
||||
double[] sorted() {
|
||||
double[] sorted = Arrays.copyOf(values, size);
|
||||
Arrays.sort(sorted);
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.PreservationRegistry;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.studio.view.GuiHost;
|
||||
import art.arcane.iris.studio.view.GuiOverlay;
|
||||
|
||||
import javax.swing.JFrame;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
/**
|
||||
* What the studio windows need from the server they run in: frames it closes on shutdown, engines and hotload
|
||||
* notices from the platform's {@link GuiHost.Provider}, and threads it stops with the plugin.
|
||||
*/
|
||||
public final class MapHost {
|
||||
private MapHost() {
|
||||
}
|
||||
|
||||
static boolean isAvailable() {
|
||||
return GuiHost.isAvailable();
|
||||
}
|
||||
|
||||
static JFrame createFrame(String title) {
|
||||
XwaylandWindowManagerHint.apply();
|
||||
StudioTheme.install();
|
||||
JFrame frame = new JFrame(title);
|
||||
GuiHost.prepareFrame(frame);
|
||||
frame.addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowActivated(WindowEvent event) {
|
||||
StudioTheme.followSystem();
|
||||
}
|
||||
});
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps rendered pixels in an image Java2D draws straight from memory. Left to itself Java2D copies every image it
|
||||
* draws into a texture inside the display server; with a map's hundreds of tiles that crashed Xwayland's Intel driver
|
||||
* and took every X window on the desktop down with it.
|
||||
*/
|
||||
public static BufferedImage uncachedImage(int[] pixels, int width, int height) {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
image.getRaster().setDataElements(0, 0, width, height, pixels);
|
||||
image.setAccelerationPriority(0F);
|
||||
return image;
|
||||
}
|
||||
|
||||
// A scaled-up default size can be bigger than a small or low resolution screen.
|
||||
static Dimension fitToScreen(Dimension preferred) {
|
||||
Rectangle usable = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
|
||||
return new Dimension(Math.min(preferred.width, usable.width), Math.min(preferred.height, usable.height));
|
||||
}
|
||||
|
||||
static Engine findActiveEngine() {
|
||||
return usable(GuiHost.get().findActiveEngine());
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine of the world the window was opened for, or the active one on platforms that can't look worlds up.
|
||||
*/
|
||||
static Engine findEngine(UUID worldId) {
|
||||
Engine engine = worldId == null ? null : usable(GuiHost.get().findEngine(worldId));
|
||||
return engine != null ? engine : findActiveEngine();
|
||||
}
|
||||
|
||||
static GuiOverlay overlayFor(Engine engine, UUID openerId) {
|
||||
return engine == null ? null : GuiHost.get().overlayFor(engine, openerId);
|
||||
}
|
||||
|
||||
static void registerHotloadHook(Runnable onHotload) {
|
||||
GuiHost.get().registerHotloadHook(onHotload);
|
||||
}
|
||||
|
||||
static void unregisterHotloadHook(Runnable onHotload) {
|
||||
GuiHost.get().unregisterHotloadHook(onHotload);
|
||||
}
|
||||
|
||||
static void interruptOnPluginShutdown(ExecutorService executor) {
|
||||
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
|
||||
if (preservation != null) {
|
||||
preservation.register(executor);
|
||||
}
|
||||
}
|
||||
|
||||
static void interruptOnPluginShutdown(Thread thread) {
|
||||
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
|
||||
if (preservation != null) {
|
||||
preservation.register(thread);
|
||||
}
|
||||
}
|
||||
|
||||
private static Engine usable(Engine engine) {
|
||||
return engine == null || engine.isClosed() || engine.isClosing() ? null : engine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.MapHoverProbe.PickStep;
|
||||
import art.arcane.iris.studio.map.MapHoverProbe.StepKind;
|
||||
import art.arcane.iris.studio.map.NoisePickSpread.Band;
|
||||
import art.arcane.iris.studio.map.components.MapLabels;
|
||||
import art.arcane.iris.studio.map.components.TileType;
|
||||
import art.arcane.iris.pack.loading.IrisRegistrant;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The card that follows the cursor: the biome and its region, the ground's height, and a row per noise that picked
|
||||
* them, each split into one segment per option with the picked one lit and a line at the spot's value. Shift adds the
|
||||
* terrain, location and pack files; a footer bar lists the shortcuts.
|
||||
*/
|
||||
record MapHoverCard(Header header, String subtitle, List<NoiseRow> noiseRows, List<Group> groups, List<Hint> hints) {
|
||||
private static final int MINIMUM_CONTENT_WIDTH = 240;
|
||||
private static final int MINIMUM_SEGMENTS_WIDTH = 120;
|
||||
private static final int PADDING_X = 12;
|
||||
private static final int PADDING_TOP = 9;
|
||||
private static final int PADDING_BOTTOM = 11;
|
||||
private static final int ICON_SIZE = 12;
|
||||
private static final int ICON_GAP = 8;
|
||||
private static final int REGION_GAP = 16;
|
||||
private static final int ROWS_GAP = 9;
|
||||
private static final int ROW_HEIGHT = 18;
|
||||
private static final int COLUMN_GAP = 8;
|
||||
private static final int SEGMENT_HEIGHT = 6;
|
||||
private static final int WIDE_SEGMENT_GAP = 2;
|
||||
private static final int NARROW_SEGMENT_GAP = 1;
|
||||
private static final int WIDE_SEGMENT_WIDTH = 8;
|
||||
private static final int NARROW_SEGMENT_WIDTH = 3;
|
||||
private static final int MARKER_WIDTH = 2;
|
||||
private static final int MARKER_HEIGHT = 12;
|
||||
private static final int GROUP_GAP = 8;
|
||||
private static final int GROUP_TITLE_GAP = 4;
|
||||
private static final int DETAIL_COLUMN_GAP = 14;
|
||||
private static final int DETAIL_ROW_GAP = 3;
|
||||
private static final int FOOTER_PADDING_Y = 7;
|
||||
private static final int KEY_PADDING_X = 4;
|
||||
private static final int KEY_GAP = 4;
|
||||
private static final int HINT_GAP = 14;
|
||||
private static final Color ICON_EDGE = new ThemeColor(new Color(0, 0, 0, 38), new Color(255, 255, 255, 38));
|
||||
|
||||
/**
|
||||
* @param subject the biome, or region in the region view, the drawn tile shows; null while that tile is drawing
|
||||
* @param region the region the drawn tile shows
|
||||
* @param details the probed facts about the spot, or null until they arrive
|
||||
* @param detailed whether Shift is held, which adds the terrain, location and pack files
|
||||
*/
|
||||
static MapHoverCard of(MapLabels.Label subject, MapLabels.Label region, MapHoverProbe.Details details,
|
||||
TileType type, boolean detailed, double worldX, double worldZ) {
|
||||
List<PickStep> path = details == null ? List.of() : details.pickPath();
|
||||
IrisBiome biome = cardBiome(subject, details, type);
|
||||
List<Group> groups = new ArrayList<>();
|
||||
if (detailed) {
|
||||
addTerrainGroup(groups, details, type);
|
||||
groups.add(locationGroup(worldX, worldZ));
|
||||
addFilesGroup(groups, biome, details != null ? details.region() : region == null ? null : region.subject());
|
||||
}
|
||||
return new MapHoverCard(header(subject, region, biome, path), subtitle(details, path), noiseRows(path), groups,
|
||||
hints(detailed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the card with its top left corner as close to the given point as fits inside the bounds.
|
||||
*/
|
||||
void paint(Graphics2D canvas, int anchorX, int anchorY, int boundsWidth, int boundsHeight) {
|
||||
Graphics2D card = (Graphics2D) canvas.create();
|
||||
card.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
card.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
int paddingX = StudioTheme.scale(PADDING_X);
|
||||
int contentWidth = contentWidth(card);
|
||||
int width = contentWidth + paddingX * 2;
|
||||
int footerHeight = footerHeight(card);
|
||||
int height = StudioTheme.scale(PADDING_TOP) + headerHeight(card) + rowsHeight() + groupsHeight(card)
|
||||
+ StudioTheme.scale(PADDING_BOTTOM) + footerHeight;
|
||||
int x = Math.max(paddingX, Math.min(boundsWidth - width - paddingX, anchorX));
|
||||
int y = Math.max(paddingX, Math.min(boundsHeight - height - paddingX, anchorY));
|
||||
StudioOverlay.drawCard(card, x, y, width, height, footerHeight);
|
||||
|
||||
int left = x + paddingX;
|
||||
int top = y + StudioTheme.scale(PADDING_TOP);
|
||||
top = drawHeader(card, left, top, contentWidth);
|
||||
top = drawNoiseRows(card, left, top, contentWidth);
|
||||
drawGroups(card, left, top);
|
||||
drawFooter(card, left, y + height - footerHeight);
|
||||
card.dispose();
|
||||
}
|
||||
|
||||
private int contentWidth(Graphics2D canvas) {
|
||||
int width = Math.max(headerWidth(canvas), rowsWidth(canvas));
|
||||
width = Math.max(width, groupsWidth(canvas));
|
||||
width = Math.max(width, footerWidth(canvas));
|
||||
return Math.max(StudioTheme.scale(MINIMUM_CONTENT_WIDTH), width);
|
||||
}
|
||||
|
||||
// Header: the biome's colour and name with its region on the right, and the ground below.
|
||||
private int headerTextOffset() {
|
||||
return header.color() == null ? 0 : StudioTheme.scale(ICON_SIZE + ICON_GAP);
|
||||
}
|
||||
|
||||
private int headerWidth(Graphics2D canvas) {
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
int width = headerTextOffset() + canvas.getFontMetrics(StudioTheme.boldFont()).stringWidth(header.name());
|
||||
if (header.region() != null) {
|
||||
width += StudioTheme.scale(REGION_GAP) + small.stringWidth(header.region());
|
||||
}
|
||||
if (subtitle != null) {
|
||||
width = Math.max(width, headerTextOffset() + small.stringWidth(subtitle));
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
private int headerHeight(Graphics2D canvas) {
|
||||
int height = canvas.getFontMetrics(StudioTheme.boldFont()).getHeight();
|
||||
if (subtitle != null) {
|
||||
height += canvas.getFontMetrics(StudioTheme.smallFont()).getHeight();
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private int drawHeader(Graphics2D canvas, int left, int top, int contentWidth) {
|
||||
FontMetrics bold = canvas.getFontMetrics(StudioTheme.boldFont());
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
if (header.color() != null) {
|
||||
int iconSize = StudioTheme.scale(ICON_SIZE);
|
||||
drawIcon(canvas, header.color(), left, top + (bold.getHeight() - iconSize) / 2, iconSize);
|
||||
}
|
||||
int textLeft = left + headerTextOffset();
|
||||
int baseline = top + bold.getAscent();
|
||||
drawText(canvas, header.name(), StudioTheme.boldFont(), StudioTheme.TEXT, textLeft, baseline);
|
||||
if (header.region() != null) {
|
||||
drawText(canvas, header.region(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT,
|
||||
left + contentWidth - small.stringWidth(header.region()), baseline);
|
||||
}
|
||||
top += bold.getHeight();
|
||||
if (subtitle != null) {
|
||||
drawText(canvas, subtitle, StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT, textLeft, top + small.getAscent());
|
||||
top += small.getHeight();
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
private static void drawIcon(Graphics2D canvas, int rgb, int left, int top, int size) {
|
||||
int arc = Math.max(2, size / 2);
|
||||
canvas.setColor(new Color(rgb));
|
||||
canvas.fillRoundRect(left, top, size, size, arc, arc);
|
||||
canvas.setColor(ICON_EDGE);
|
||||
canvas.drawRoundRect(left, top, size - 1, size - 1, arc, arc);
|
||||
}
|
||||
|
||||
// Noise rows: a muted label, the segments (or in hybrid terrain how the biome won), and the value on the right.
|
||||
private int labelColumnWidth(Graphics2D canvas) {
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
return noiseRows.stream().mapToInt(row -> small.stringWidth(row.label())).max().orElse(0);
|
||||
}
|
||||
|
||||
private int valueColumnWidth(Graphics2D canvas) {
|
||||
FontMetrics value = canvas.getFontMetrics(valueFont());
|
||||
return noiseRows.stream().mapToInt(row -> value.stringWidth(row.value())).max().orElse(0);
|
||||
}
|
||||
|
||||
private int rowsWidth(Graphics2D canvas) {
|
||||
if (noiseRows.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
int middle = noiseRows.stream()
|
||||
.mapToInt(row -> row.showsSegments() ? StudioTheme.scale(MINIMUM_SEGMENTS_WIDTH) : small.stringWidth(row.note()))
|
||||
.max().orElse(0);
|
||||
return labelColumnWidth(canvas) + valueColumnWidth(canvas) + middle + StudioTheme.scale(COLUMN_GAP) * 2;
|
||||
}
|
||||
|
||||
private int rowsHeight() {
|
||||
return noiseRows.isEmpty() ? 0 : StudioTheme.scale(ROWS_GAP) + noiseRows.size() * StudioTheme.scale(ROW_HEIGHT);
|
||||
}
|
||||
|
||||
private int drawNoiseRows(Graphics2D canvas, int left, int top, int contentWidth) {
|
||||
if (noiseRows.isEmpty()) {
|
||||
return top;
|
||||
}
|
||||
top += StudioTheme.scale(ROWS_GAP);
|
||||
int rowHeight = StudioTheme.scale(ROW_HEIGHT);
|
||||
int columnGap = StudioTheme.scale(COLUMN_GAP);
|
||||
int middleLeft = left + labelColumnWidth(canvas) + columnGap;
|
||||
int valueRight = left + contentWidth;
|
||||
int middleWidth = valueRight - valueColumnWidth(canvas) - columnGap - middleLeft;
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
FontMetrics value = canvas.getFontMetrics(valueFont());
|
||||
for (NoiseRow row : noiseRows) {
|
||||
int baseline = top + (rowHeight - small.getHeight()) / 2 + small.getAscent();
|
||||
drawText(canvas, row.label(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, baseline);
|
||||
if (row.showsSegments()) {
|
||||
drawSegments(canvas, row, middleLeft, top + (rowHeight - StudioTheme.scale(SEGMENT_HEIGHT)) / 2, middleWidth);
|
||||
} else {
|
||||
drawText(canvas, row.note(), StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT, middleLeft, baseline);
|
||||
}
|
||||
drawText(canvas, row.value(), valueFont(), StudioTheme.SECONDARY_TEXT, valueRight - value.stringWidth(row.value()),
|
||||
baseline);
|
||||
top += rowHeight;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
// One segment per band, as wide as the band's share of the noise; the picked one in its option's colour.
|
||||
private static void drawSegments(Graphics2D canvas, NoiseRow row, int left, int top, int width) {
|
||||
List<Band> bands = row.bands();
|
||||
int height = StudioTheme.scale(SEGMENT_HEIGHT);
|
||||
int gap = segmentGap(width, bands.size());
|
||||
int picked = pickedBandIndex(bands, row.noise());
|
||||
int arc = StudioTheme.scale(2);
|
||||
for (int index = 0; index < bands.size(); index++) {
|
||||
Band band = bands.get(index);
|
||||
int from = left + (int) Math.round(band.fromNoise() * width);
|
||||
int to = left + (int) Math.round(band.toNoise() * width);
|
||||
int segmentWidth = Math.max(1, to - from - (index == bands.size() - 1 ? 0 : gap));
|
||||
canvas.setColor(index == picked ? new Color(band.option().color()) : StudioTheme.OVERLAY_BORDER);
|
||||
canvas.fillRoundRect(from, top, segmentWidth, height, arc, arc);
|
||||
}
|
||||
drawMarker(canvas, left + (int) Math.round(Math.clamp(row.noise(), 0D, 1D) * width), top + height / 2);
|
||||
}
|
||||
|
||||
private static int segmentGap(int width, int segmentCount) {
|
||||
int averageWidth = width / Math.max(1, segmentCount);
|
||||
if (averageWidth >= StudioTheme.scale(WIDE_SEGMENT_WIDTH)) {
|
||||
return StudioTheme.scale(WIDE_SEGMENT_GAP);
|
||||
}
|
||||
return averageWidth >= StudioTheme.scale(NARROW_SEGMENT_WIDTH) ? StudioTheme.scale(NARROW_SEGMENT_GAP) : 0;
|
||||
}
|
||||
|
||||
// The engine gives values below 0 the first option and values above 1 the last, the same as this.
|
||||
private static int pickedBandIndex(List<Band> bands, double noise) {
|
||||
for (int index = 0; index < bands.size(); index++) {
|
||||
if (noise <= bands.get(index).toNoise()) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return bands.size() - 1;
|
||||
}
|
||||
|
||||
private static void drawMarker(Graphics2D canvas, int centerX, int centerY) {
|
||||
int width = StudioTheme.scale(MARKER_WIDTH);
|
||||
int height = StudioTheme.scale(MARKER_HEIGHT);
|
||||
int left = centerX - width / 2;
|
||||
int top = centerY - height / 2;
|
||||
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
|
||||
canvas.fillRect(left - 1, top - 1, width + 2, height + 2);
|
||||
canvas.setColor(StudioTheme.TEXT);
|
||||
canvas.fillRect(left, top, width, height);
|
||||
}
|
||||
|
||||
private static Font valueFont() {
|
||||
return StudioTheme.monospacedFont().deriveFont(StudioTheme.smallFont().getSize2D());
|
||||
}
|
||||
|
||||
// Groups: sentence-case titles over rows of muted labels and values, all labels in one column.
|
||||
private int detailLabelWidth(Graphics2D canvas) {
|
||||
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
|
||||
return groups.stream().flatMap(group -> group.rows().stream())
|
||||
.mapToInt(row -> metrics.stringWidth(row.label())).max().orElse(0);
|
||||
}
|
||||
|
||||
private int groupsWidth(Graphics2D canvas) {
|
||||
if (groups.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
|
||||
int valueWidth = groups.stream().flatMap(group -> group.rows().stream())
|
||||
.mapToInt(row -> metrics.stringWidth(row.value())).max().orElse(0);
|
||||
return detailLabelWidth(canvas) + StudioTheme.scale(DETAIL_COLUMN_GAP) + valueWidth;
|
||||
}
|
||||
|
||||
private int groupsHeight(Graphics2D canvas) {
|
||||
int rowHeight = canvas.getFontMetrics(StudioTheme.font()).getHeight() + StudioTheme.scale(DETAIL_ROW_GAP);
|
||||
return groups.stream().mapToInt(group -> groupTitleHeight(canvas) + group.rows().size() * rowHeight).sum();
|
||||
}
|
||||
|
||||
private void drawGroups(Graphics2D canvas, int left, int top) {
|
||||
FontMetrics regular = canvas.getFontMetrics(StudioTheme.font());
|
||||
int valueLeft = left + detailLabelWidth(canvas) + StudioTheme.scale(DETAIL_COLUMN_GAP);
|
||||
for (Group group : groups) {
|
||||
top = drawGroupTitle(canvas, group.title(), left, top);
|
||||
for (Row row : group.rows()) {
|
||||
int baseline = top + regular.getAscent();
|
||||
drawText(canvas, row.label(), StudioTheme.font(), StudioTheme.SECONDARY_TEXT, left, baseline);
|
||||
drawText(canvas, row.value(), StudioTheme.font(), row.link() ? StudioTheme.LINK : StudioTheme.TEXT, valueLeft,
|
||||
baseline);
|
||||
top += regular.getHeight() + StudioTheme.scale(DETAIL_ROW_GAP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int groupTitleHeight(Graphics2D canvas) {
|
||||
return StudioTheme.scale(GROUP_GAP) + canvas.getFontMetrics(StudioTheme.smallFont()).getHeight()
|
||||
+ StudioTheme.scale(GROUP_TITLE_GAP);
|
||||
}
|
||||
|
||||
private static int drawGroupTitle(Graphics2D canvas, String title, int left, int top) {
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
top += StudioTheme.scale(GROUP_GAP);
|
||||
drawText(canvas, title, StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, top + small.getAscent());
|
||||
return top + small.getHeight() + StudioTheme.scale(GROUP_TITLE_GAP);
|
||||
}
|
||||
|
||||
// Footer: the shortcuts as key caps, each followed by what it does.
|
||||
private int footerWidth(Graphics2D canvas) {
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
int width = 0;
|
||||
for (Hint hint : hints) {
|
||||
width += keyWidth(small, hint.key()) + StudioTheme.scale(KEY_GAP) + small.stringWidth(hint.action());
|
||||
}
|
||||
return width + StudioTheme.scale(HINT_GAP) * Math.max(0, hints.size() - 1);
|
||||
}
|
||||
|
||||
private static int footerHeight(Graphics2D canvas) {
|
||||
return canvas.getFontMetrics(StudioTheme.smallFont()).getHeight() + StudioTheme.scale(FOOTER_PADDING_Y) * 2;
|
||||
}
|
||||
|
||||
private static int keyWidth(FontMetrics small, String key) {
|
||||
return small.stringWidth(key) + StudioTheme.scale(KEY_PADDING_X) * 2;
|
||||
}
|
||||
|
||||
private void drawFooter(Graphics2D canvas, int left, int top) {
|
||||
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
|
||||
int textTop = top + StudioTheme.scale(FOOTER_PADDING_Y);
|
||||
int baseline = textTop + small.getAscent();
|
||||
int arc = StudioTheme.scale(6);
|
||||
for (Hint hint : hints) {
|
||||
int keyWidth = keyWidth(small, hint.key());
|
||||
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
||||
canvas.fillRoundRect(left, textTop, keyWidth, small.getHeight(), arc, arc);
|
||||
drawText(canvas, hint.key(), StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT,
|
||||
left + StudioTheme.scale(KEY_PADDING_X), baseline);
|
||||
left += keyWidth + StudioTheme.scale(KEY_GAP);
|
||||
drawText(canvas, hint.action(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, baseline);
|
||||
left += small.stringWidth(hint.action()) + StudioTheme.scale(HINT_GAP);
|
||||
}
|
||||
}
|
||||
|
||||
private static void drawText(Graphics2D canvas, String text, Font font, Color color, int left, int baseline) {
|
||||
canvas.setFont(font);
|
||||
canvas.setColor(color);
|
||||
canvas.drawString(text, left, baseline);
|
||||
}
|
||||
|
||||
// The card is about a biome; the region view's tiles only know the region until the probe finds the biome.
|
||||
private static IrisBiome cardBiome(MapLabels.Label subject, MapHoverProbe.Details details, TileType type) {
|
||||
if (subject != null && subject.subject() instanceof IrisBiome biome) {
|
||||
return biome;
|
||||
}
|
||||
if (details == null) {
|
||||
return null;
|
||||
}
|
||||
return type == TileType.CAVE_LAND ? details.caveBiome() : details.surfaceBiome();
|
||||
}
|
||||
|
||||
private static Header header(MapLabels.Label subject, MapLabels.Label region, IrisBiome biome, List<PickStep> path) {
|
||||
String regionName = path.stream()
|
||||
.filter(step -> step.kind() == StepKind.REGION_NOISE || step.kind() == StepKind.PREVIEWED_REGION)
|
||||
.map(step -> step.picked().name())
|
||||
.findFirst()
|
||||
.orElse(region == null ? null : region.name());
|
||||
if (biome != null) {
|
||||
return new Header(biome.getName(), colorOf(biome, path), regionName);
|
||||
}
|
||||
if (subject != null) {
|
||||
return new Header(subject.name(), null, subject.kind() == MapLabels.Kind.REGION ? null : regionName);
|
||||
}
|
||||
return new Header("Drawing…", null, null);
|
||||
}
|
||||
|
||||
private static Integer colorOf(IrisBiome biome, List<PickStep> path) {
|
||||
return path.stream()
|
||||
.filter(step -> step.picked().key().equals(biome.getLoadKey()))
|
||||
.reduce((first, last) -> last)
|
||||
.map(step -> step.picked().color())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static String subtitle(MapHoverProbe.Details details, List<PickStep> path) {
|
||||
if (details == null) {
|
||||
return null;
|
||||
}
|
||||
String ground = Math.round(details.height()) + " · " + signedHeight(details.height() - details.fluidHeight())
|
||||
+ " · " + Math.round(details.slopeDegrees()) + "°";
|
||||
return parentOfChild(path).map(parent -> "child of " + parent + " · " + ground).orElse(ground);
|
||||
}
|
||||
|
||||
// A child biome took the spot from the biome the noise picked, which is the step before it.
|
||||
private static Optional<String> parentOfChild(List<PickStep> path) {
|
||||
for (int index = 1; index < path.size(); index++) {
|
||||
if (path.get(index).kind() == StepKind.CHILD) {
|
||||
return Optional.of(path.get(index - 1).picked().name());
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static List<NoiseRow> noiseRows(List<PickStep> path) {
|
||||
List<NoiseRow> rows = new ArrayList<>();
|
||||
for (PickStep step : path) {
|
||||
switch (step.kind()) {
|
||||
case REGION_NOISE -> rows.add(new NoiseRow("Region", step.bands(), step.noise(), null, step.value()));
|
||||
case BIOME_NOISE -> rows.add(new NoiseRow(capitalize(step.detail()), step.bands(), step.noise(), null,
|
||||
step.value()));
|
||||
case PREVIEWED_REGION, CHILD -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static void addTerrainGroup(List<Group> groups, MapHoverProbe.Details details, TileType type) {
|
||||
if (details == null) {
|
||||
return;
|
||||
}
|
||||
List<Row> rows = new ArrayList<>();
|
||||
IrisBiome surface = details.surfaceBiome();
|
||||
IrisBiome cave = details.caveBiome();
|
||||
if (type == TileType.CAVE_LAND && surface != null) {
|
||||
rows.add(new Row("Surface biome", surface.getName(), false));
|
||||
} else if (type != TileType.CAVE_LAND && cave != null && cave != surface) {
|
||||
rows.add(new Row("Cave biome", cave.getName(), false));
|
||||
}
|
||||
if (details.vanillaBiome() != null) {
|
||||
rows.add(new Row("Minecraft", details.vanillaBiome().replace('_', ' '), false));
|
||||
}
|
||||
if (!rows.isEmpty()) {
|
||||
groups.add(new Group("Terrain", rows));
|
||||
}
|
||||
}
|
||||
|
||||
private static Group locationGroup(double worldX, double worldZ) {
|
||||
int blockX = MapCanvas.floorWorldCoordinate(worldX);
|
||||
int blockZ = MapCanvas.floorWorldCoordinate(worldZ);
|
||||
return new Group("Location", List.of(
|
||||
new Row("Block", blockX + ", " + blockZ, false),
|
||||
new Row("Chunk", (blockX >> 4) + ", " + (blockZ >> 4), false),
|
||||
new Row("Region file", "r." + (blockX >> 9) + "." + (blockZ >> 9) + ".mca", false)));
|
||||
}
|
||||
|
||||
private static void addFilesGroup(List<Group> groups, IrisBiome biome, Object region) {
|
||||
List<Row> rows = new ArrayList<>();
|
||||
if (biome != null) {
|
||||
rows.add(new Row("Biome", packPath(biome), true));
|
||||
}
|
||||
if (region instanceof IrisRegistrant registrant) {
|
||||
rows.add(new Row("Region", packPath(registrant), true));
|
||||
}
|
||||
if (!rows.isEmpty()) {
|
||||
groups.add(new Group("Files", rows));
|
||||
}
|
||||
}
|
||||
|
||||
// The key is the path under the type's folder, so folder, key and extension give the file within the pack.
|
||||
private static String packPath(IrisRegistrant registrant) {
|
||||
File file = registrant.getLoadFile();
|
||||
String fileName = file == null ? "" : file.getName();
|
||||
int extensionStart = fileName.lastIndexOf('.');
|
||||
String extension = extensionStart < 0 ? "" : fileName.substring(extensionStart);
|
||||
return registrant.getFolderName() + "/" + registrant.getLoadKey() + extension;
|
||||
}
|
||||
|
||||
private static List<Hint> hints(boolean detailed) {
|
||||
List<Hint> hints = new ArrayList<>();
|
||||
if (!detailed) {
|
||||
hints.add(new Hint("Shift", "More"));
|
||||
}
|
||||
hints.add(new Hint("H", "Highlight"));
|
||||
hints.add(new Hint("Alt+Click", "Open file"));
|
||||
return hints;
|
||||
}
|
||||
|
||||
private static String signedHeight(double height) {
|
||||
long rounded = Math.round(height);
|
||||
return rounded < 0 ? "−" + -rounded : "+" + rounded;
|
||||
}
|
||||
|
||||
private static String capitalize(String text) {
|
||||
return text.substring(0, 1).toUpperCase(Locale.ROOT) + text.substring(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param color the biome's map colour, or null when no step of the path tells it
|
||||
* @param region the region's name, or null when the title already is the region
|
||||
*/
|
||||
record Header(String name, Integer color, String region) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bands the bands the noise splits into, empty when no noise picked the step
|
||||
* @param note how the step was picked when no noise did, or null
|
||||
*/
|
||||
record NoiseRow(String label, List<Band> bands, double noise, String note, String value) {
|
||||
boolean showsSegments() {
|
||||
return !bands.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
record Group(String title, List<Row> rows) {
|
||||
}
|
||||
|
||||
record Row(String label, String value, boolean link) {
|
||||
}
|
||||
|
||||
record Hint(String key, String action) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.NoisePickSpread.Band;
|
||||
import art.arcane.iris.studio.map.NoisePickSpread.Option;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.terrain.InferredType;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.generation.terrain.IrisRegion;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Looks up everything the hover card can tell about one spot of the world; runs off the Swing thread.
|
||||
*/
|
||||
final class MapHoverProbe {
|
||||
// IrisComplex measures slope as the height change over this many blocks.
|
||||
private static final double SLOPE_STREAM_RANGE_BLOCKS = 3D;
|
||||
|
||||
private MapHoverProbe() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param previewRegionKey the region the region preview treats every spot as part of, or null for the real world
|
||||
* @param explainBiomePick whether the card shows the surface biome, so the whole way to it is worth looking up;
|
||||
* otherwise only how the region was picked
|
||||
*/
|
||||
static Details sample(Engine engine, double worldX, double worldZ, String previewRegionKey, boolean explainBiomePick) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
IrisRegion previewRegion = previewRegionKey == null ? null : engine.getData().getRegionLoader().load(previewRegionKey);
|
||||
IrisRegion region = previewRegion != null ? previewRegion : complex.getRegionStream().get(worldX, worldZ);
|
||||
IrisComplex.BiomePick surfacePick = previewRegion != null
|
||||
? complex.surfacePickAsIfIn(region, worldX, worldZ)
|
||||
: complex.basePickIn(region, worldX, worldZ);
|
||||
String vanillaBiomeKey = complex.getTrueBiomeStream().get(worldX, worldZ).getDerivativeKey();
|
||||
double slope = complex.getSlopeStream().getDouble(worldX, worldZ);
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
return new Details(
|
||||
worldX,
|
||||
worldZ,
|
||||
surfacePick.biome(),
|
||||
complex.getCaveBiomeStream().get(worldX, worldZ),
|
||||
region,
|
||||
vanillaBiomeKey == null ? null : vanillaBiomeKey.substring(vanillaBiomeKey.indexOf(':') + 1),
|
||||
complex.getHeightStream().getDouble(worldX, worldZ),
|
||||
dimension == null ? 0D : dimension.getFluidHeight(),
|
||||
Math.toDegrees(Math.atan(slope / SLOPE_STREAM_RANGE_BLOCKS)),
|
||||
pickPath(engine, region, previewRegion != null, explainBiomePick ? surfacePick : null, worldX, worldZ)
|
||||
);
|
||||
}
|
||||
|
||||
// The region preview chose the region itself; the real map picks it by the region noise. Each explains the biome it
|
||||
// draws: the preview its surface biome, the real map its base biome, before the height corrects it near coasts.
|
||||
private static List<PickStep> pickPath(Engine engine, IrisRegion region, boolean previewed,
|
||||
IrisComplex.BiomePick surfacePick, double x, double z) {
|
||||
List<PickStep> path = new ArrayList<>();
|
||||
path.add(previewed ? previewedRegionStep(engine, region) : regionStep(engine, region, x, z));
|
||||
if (surfacePick == null || surfacePick.type() == null || engine.getComplex().getFocusBiome() != null) {
|
||||
return path;
|
||||
}
|
||||
PickStep biomeStep = noiseStep(engine, region, surfacePick.type(), x, z);
|
||||
path.add(biomeStep);
|
||||
if (!biomeStep.picked().key().equals(surfacePick.biome().getLoadKey())) {
|
||||
path.add(new PickStep(StepKind.CHILD, PickMeasuring.biomeOption(engine, surfacePick.biome()), null, null, List.of(),
|
||||
Double.NaN));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private static PickStep regionStep(Engine engine, IrisRegion region, double x, double z) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
double noise = complex.getRegionStyleStream().get(x, z);
|
||||
List<Option> regions = complex.noisePickedRegions().stream()
|
||||
.map(candidate -> PickMeasuring.regionOption(engine, candidate))
|
||||
.toList();
|
||||
return new PickStep(StepKind.REGION_NOISE, PickMeasuring.regionOption(engine, region), null, noiseText(noise),
|
||||
NoisePickSpread.splitNoise(regions), noise);
|
||||
}
|
||||
|
||||
private static PickStep previewedRegionStep(Engine engine, IrisRegion region) {
|
||||
return new PickStep(StepKind.PREVIEWED_REGION, PickMeasuring.regionOption(engine, region), null, null, List.of(),
|
||||
Double.NaN);
|
||||
}
|
||||
|
||||
private static PickStep noiseStep(Engine engine, IrisRegion region, InferredType type, double x, double z) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
double noise = complex.biomePickNoise(region, type, x, z);
|
||||
List<Option> biomes = complex.noisePickedBiomes(region, type).stream()
|
||||
.map(biome -> PickMeasuring.biomeOption(engine, biome))
|
||||
.toList();
|
||||
IrisBiome picked = complex.biomePickedByNoise(region, type, x, z);
|
||||
return new PickStep(StepKind.BIOME_NOISE, PickMeasuring.biomeOption(engine, picked), typeName(type), noiseText(noise),
|
||||
NoisePickSpread.splitNoise(biomes), noise);
|
||||
}
|
||||
|
||||
private static String typeName(InferredType type) {
|
||||
return type.name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String noiseText(double noise) {
|
||||
return String.format(Locale.ROOT, "%.3f", noise);
|
||||
}
|
||||
|
||||
record Details(
|
||||
double worldX,
|
||||
double worldZ,
|
||||
IrisBiome surfaceBiome,
|
||||
IrisBiome caveBiome,
|
||||
IrisRegion region,
|
||||
String vanillaBiome,
|
||||
double height,
|
||||
double fluidHeight,
|
||||
double slopeDegrees,
|
||||
List<PickStep> pickPath
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* How a step on the way to the spot's biome was picked.
|
||||
*/
|
||||
enum StepKind {
|
||||
REGION_NOISE,
|
||||
PREVIEWED_REGION,
|
||||
BIOME_NOISE,
|
||||
CHILD
|
||||
}
|
||||
|
||||
/**
|
||||
* One step on the way to the spot's biome, starting with its region.
|
||||
*
|
||||
* @param detail the biome list the step picked from; null for the rest
|
||||
* @param value the noise value that picked it, or null
|
||||
* @param bands the noise bands of all the options the noise picked from, empty when no noise picked it
|
||||
*/
|
||||
record PickStep(StepKind kind, Option picked, String detail, String value, List<Band> bands, double noise) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.components.MapStyle;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The biomes or regions on screen with how much of it each covers; clicking one highlights it on the map.
|
||||
*/
|
||||
final class MapLegendPanel extends JPanel {
|
||||
private static final long serialVersionUID = 4471297416386034128L;
|
||||
private static final int PANEL_WIDTH = 250;
|
||||
private static final int ROW_HEIGHT = 26;
|
||||
private static final int SWATCH_SIZE = 12;
|
||||
private static final int SWATCH_GAP = 8;
|
||||
private static final int ROW_PADDING = 10;
|
||||
private static final int PERCENT_WIDTH = 42;
|
||||
private static final String DRAWING_MESSAGE = "Drawing the map…";
|
||||
|
||||
private final JLabel title = new JLabel();
|
||||
private final DefaultListModel<MapCensus.Share> rows = new DefaultListModel<>();
|
||||
private final JList<MapCensus.Share> list = new JList<>(rows) {
|
||||
// Long names shorten with an ellipsis instead of pushing the percentages out of sight.
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
private final Set<String> sharedNames = new HashSet<>();
|
||||
private MapStyle.Highlight highlight;
|
||||
|
||||
MapLegendPanel(Consumer<MapCensus.Share> onRowClicked) {
|
||||
this();
|
||||
highlightOnClick(onRowClicked);
|
||||
}
|
||||
|
||||
/**
|
||||
* A legend whose rows only tell what is on screen; clicking them does nothing.
|
||||
*/
|
||||
static MapLegendPanel listingOnly() {
|
||||
return new MapLegendPanel();
|
||||
}
|
||||
|
||||
private MapLegendPanel() {
|
||||
super(new BorderLayout());
|
||||
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
|
||||
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
|
||||
title.setFont(StudioTheme.sectionFont());
|
||||
title.setForeground(StudioTheme.MUTED_TEXT);
|
||||
title.setBorder(StudioTheme.emptyBorder(10, 16, 6, 16));
|
||||
add(title, BorderLayout.NORTH);
|
||||
|
||||
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
list.setFocusable(false);
|
||||
list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
list.setCellRenderer(new ShareRenderer());
|
||||
list.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
|
||||
add(StudioToolbar.scroll(list), BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
private void highlightOnClick(Consumer<MapCensus.Share> onRowClicked) {
|
||||
list.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent event) {
|
||||
MapCensus.Share share = shareAt(event.getPoint());
|
||||
if (share != null) {
|
||||
onRowClicked.accept(share);
|
||||
}
|
||||
}
|
||||
});
|
||||
JLabel hint = new JLabel("Click to highlight · Esc clears");
|
||||
hint.setFont(StudioTheme.smallFont());
|
||||
hint.setForeground(StudioTheme.MUTED_TEXT);
|
||||
hint.setBorder(StudioTheme.emptyBorder(6, 16, 8, 16));
|
||||
add(hint, BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
void show(String subjectTitle, List<MapCensus.Share> shares, MapStyle.Highlight nextHighlight) {
|
||||
title.setText(subjectTitle.toUpperCase(Locale.ROOT));
|
||||
highlight = nextHighlight;
|
||||
rememberSharedNames(shares);
|
||||
if (!sameRows(shares)) {
|
||||
rows.clear();
|
||||
rows.addAll(shares);
|
||||
}
|
||||
list.repaint();
|
||||
}
|
||||
|
||||
// Two biomes can share a display name; those rows show their key so they can be told apart.
|
||||
private void rememberSharedNames(List<MapCensus.Share> shares) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
sharedNames.clear();
|
||||
for (MapCensus.Share share : shares) {
|
||||
if (!seen.add(share.label().name())) {
|
||||
sharedNames.add(share.label().name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String rowText(MapCensus.Share share) {
|
||||
String name = share.label().name();
|
||||
return sharedNames.contains(name) ? name + " (" + share.label().key() + ")" : name;
|
||||
}
|
||||
|
||||
private boolean sameRows(List<MapCensus.Share> shares) {
|
||||
if (rows.size() != shares.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int index = 0; index < shares.size(); index++) {
|
||||
if (!rows.get(index).equals(shares.get(index))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private MapCensus.Share shareAt(Point point) {
|
||||
int index = list.locationToIndex(point);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
Rectangle bounds = list.getCellBounds(index, index);
|
||||
return bounds != null && bounds.contains(point) ? rows.get(index) : null;
|
||||
}
|
||||
|
||||
private boolean isHighlighted(MapCensus.Share share) {
|
||||
return highlight != null
|
||||
&& highlight.kind() == share.label().kind()
|
||||
&& highlight.key().equals(share.label().key());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintChildren(Graphics graphics) {
|
||||
super.paintChildren(graphics);
|
||||
if (!rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
canvas.drawString(DRAWING_MESSAGE, StudioTheme.scale(16),
|
||||
title.getHeight() + StudioTheme.scale(4) + canvas.getFontMetrics().getAscent());
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private final class ShareRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = -2350212934617052618L;
|
||||
private Color swatch = Color.BLACK;
|
||||
private String percent = "";
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
|
||||
MapCensus.Share share = (MapCensus.Share) value;
|
||||
super.getListCellRendererComponent(owner, rowText(share), index, isHighlighted(share), false);
|
||||
setFont(StudioTheme.font());
|
||||
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + SWATCH_SIZE + SWATCH_GAP, 0, ROW_PADDING + PERCENT_WIDTH));
|
||||
setToolTipText(share.label().key());
|
||||
swatch = new Color(share.color());
|
||||
percent = formatPercent(share.fraction());
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
super.paintComponent(graphics);
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
int size = StudioTheme.scale(SWATCH_SIZE);
|
||||
int swatchX = StudioTheme.scale(ROW_PADDING);
|
||||
int swatchY = (getHeight() - size) / 2;
|
||||
int arc = StudioTheme.scale(4);
|
||||
canvas.setColor(swatch);
|
||||
canvas.fillRoundRect(swatchX, swatchY, size, size, arc, arc);
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawRoundRect(swatchX, swatchY, size - 1, size - 1, arc, arc);
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(percent);
|
||||
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
|
||||
canvas.drawString(percent, getWidth() - StudioTheme.scale(ROW_PADDING) - textWidth, baseline);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String formatPercent(double fraction) {
|
||||
double percent = fraction * 100D;
|
||||
if (percent < 0.1D) {
|
||||
return "<0.1%";
|
||||
}
|
||||
return percent < 10D
|
||||
? String.format(Locale.ROOT, "%.1f%%", percent)
|
||||
: String.format(Locale.ROOT, "%.0f%%", percent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.components.TileType;
|
||||
|
||||
import java.awt.event.KeyEvent;
|
||||
|
||||
/**
|
||||
* Everything the map window can show, in the order and groups of its view list.
|
||||
*/
|
||||
enum MapView {
|
||||
BIOMES("Biomes", "The biome at each spot, in the biome's colour", Group.WORLD, TileType.BIOME, KeyEvent.VK_1),
|
||||
REGIONS("Regions", "The region each spot belongs to; pick one to see the biomes it places",
|
||||
Group.WORLD, TileType.REGION, KeyEvent.VK_2),
|
||||
HEIGHT("Height", "Terrain height, from deep water to snowy peaks", Group.WORLD, TileType.HEIGHT, KeyEvent.VK_3),
|
||||
RIVERS("Rivers", "Rivers, falls, grottos and the rest of the hydrology the engine plans",
|
||||
Group.WORLD, TileType.RIVER, KeyEvent.VK_4),
|
||||
WORLD_3D("3D world", "The terrain in 3D, coloured by biome or region", Group.WORLD, null, KeyEvent.VK_5),
|
||||
LAND_BIOMES("Land biomes", "The land biome each spot would get, also where the sea is",
|
||||
Group.BIOME_STREAMS, TileType.BIOME_LAND, KeyEvent.VK_6),
|
||||
SEA_BIOMES("Sea biomes", "The sea biome each spot would get, also where the land is",
|
||||
Group.BIOME_STREAMS, TileType.BIOME_SEA, KeyEvent.VK_7),
|
||||
CAVE_BIOMES("Cave biomes", "The cave biome under each spot", Group.BIOME_STREAMS, TileType.CAVE_LAND, KeyEvent.VK_8),
|
||||
PALETTES("Palettes", "The blocks each biome's palette layers place, with their textures",
|
||||
Group.BLOCKS, null, KeyEvent.VK_P),
|
||||
OBJECTS("Objects", "Every object in the pack in 3D, with the blocks it is made of",
|
||||
Group.BLOCKS, null, KeyEvent.VK_O),
|
||||
PROCEDURAL("Procedural", "Trees, rock formations and more that Iris generates from settings, every variant in 3D",
|
||||
Group.BLOCKS, null, KeyEvent.VK_T),
|
||||
NOISE("Noise", "The pack's noise generators and Iris' built-in noise styles",
|
||||
Group.GENERATORS, null, KeyEvent.VK_N),
|
||||
IMAGE_MAPS("Image maps", "Terrain height, biomes or masks painted from a PNG, previewed and exported into the pack",
|
||||
Group.GENERATORS, null, KeyEvent.VK_I);
|
||||
|
||||
private final String label;
|
||||
private final String description;
|
||||
private final Group group;
|
||||
private final TileType renderType;
|
||||
private final int shortcutKey;
|
||||
|
||||
MapView(String label, String description, Group group, TileType renderType, int shortcutKey) {
|
||||
this.label = label;
|
||||
this.description = description;
|
||||
this.group = group;
|
||||
this.renderType = renderType;
|
||||
this.shortcutKey = shortcutKey;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
String description() {
|
||||
return description;
|
||||
}
|
||||
|
||||
Group group() {
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile renderer's mode for this view, or null for the 3D world, palette, object, procedural, noise and image map
|
||||
* views, which draw themselves.
|
||||
*/
|
||||
TileType renderType() {
|
||||
return renderType;
|
||||
}
|
||||
|
||||
int shortcutKey() {
|
||||
return shortcutKey;
|
||||
}
|
||||
|
||||
String shortcutText() {
|
||||
return KeyEvent.getKeyText(shortcutKey);
|
||||
}
|
||||
|
||||
boolean isTileMap() {
|
||||
return renderType != null;
|
||||
}
|
||||
|
||||
static MapView forShortcut(int keyCode) {
|
||||
for (MapView view : values()) {
|
||||
if (view.shortcutKey == keyCode) {
|
||||
return view;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
enum Group {
|
||||
WORLD("World"),
|
||||
BIOME_STREAMS("Biome layers"),
|
||||
BLOCKS("Blocks"),
|
||||
GENERATORS("Generators");
|
||||
|
||||
private final String label;
|
||||
|
||||
Group(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon;
|
||||
|
||||
import javax.swing.Icon;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The icon of each map view, taken from IntelliJ's new UI icon set (see the NOTICE in iris/icons).
|
||||
*/
|
||||
final class MapViewIcon {
|
||||
private static final String ICON_FOLDER = "iris/icons/views/";
|
||||
private static final int SIZE = 16;
|
||||
private static final Map<MapView, Icon> LOADED = new EnumMap<>(MapView.class);
|
||||
|
||||
private MapViewIcon() {
|
||||
}
|
||||
|
||||
static Icon of(MapView view) {
|
||||
return LOADED.computeIfAbsent(view, shown -> load(shown, SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* The view's icon drawn larger, for an empty view.
|
||||
*/
|
||||
static Icon large(MapView view, int size) {
|
||||
return load(view, size);
|
||||
}
|
||||
|
||||
private static Icon load(MapView view, int size) {
|
||||
String fileName = view.name().toLowerCase(Locale.ROOT).replace('_', '-') + ".svg";
|
||||
return new FlatSVGIcon(ICON_FOLDER + fileName, size, size, MapViewIcon.class.getClassLoader());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import javax.swing.UIManager;
|
||||
import java.awt.Component;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Insets;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The map window's list of views, grouped under headers, each row with a picture of the view and the key that opens
|
||||
* it.
|
||||
*/
|
||||
final class MapViewList extends JList<MapViewList.Row> {
|
||||
private static final long serialVersionUID = -4130872409914437415L;
|
||||
private static final int ROW_HEIGHT = 28;
|
||||
private static final int NO_ROW = -1;
|
||||
|
||||
private final DefaultListModel<Row> rows = new DefaultListModel<>();
|
||||
private int lastViewRow;
|
||||
private int hoveredRow = NO_ROW;
|
||||
|
||||
MapViewList(Consumer<MapView> onViewChosen) {
|
||||
fillRows();
|
||||
setModel(rows);
|
||||
setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
setCellRenderer(new RowRenderer());
|
||||
setBorder(StudioTheme.emptyBorder(4, 6, 4, 6));
|
||||
addListSelectionListener(event -> {
|
||||
if (event.getValueIsAdjusting() || skipHeaderRow()) {
|
||||
return;
|
||||
}
|
||||
Row selected = getSelectedValue();
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
lastViewRow = getSelectedIndex();
|
||||
onViewChosen.accept(selected.view());
|
||||
});
|
||||
MouseAdapter hover = new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent event) {
|
||||
hover(viewRowAt(event));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent event) {
|
||||
hover(NO_ROW);
|
||||
}
|
||||
};
|
||||
addMouseListener(hover);
|
||||
addMouseMotionListener(hover);
|
||||
}
|
||||
|
||||
private int viewRowAt(MouseEvent event) {
|
||||
int row = locationToIndex(event.getPoint());
|
||||
if (row < 0 || rows.get(row).isHeader() || !getCellBounds(row, row).contains(event.getPoint())) {
|
||||
return NO_ROW;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private void hover(int row) {
|
||||
if (row == hoveredRow) {
|
||||
return;
|
||||
}
|
||||
repaintRow(hoveredRow);
|
||||
hoveredRow = row;
|
||||
repaintRow(hoveredRow);
|
||||
}
|
||||
|
||||
private void repaintRow(int row) {
|
||||
Rectangle bounds = row == NO_ROW ? null : getCellBounds(row, row);
|
||||
if (bounds != null) {
|
||||
repaint(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
void select(MapView view) {
|
||||
for (int row = 0; row < rows.size(); row++) {
|
||||
if (rows.get(row).view() == view) {
|
||||
lastViewRow = row;
|
||||
setSelectedIndex(row);
|
||||
ensureIndexIsVisible(row);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void fillRows() {
|
||||
MapView.Group currentGroup = null;
|
||||
for (MapView view : MapView.values()) {
|
||||
if (view.group() != currentGroup) {
|
||||
currentGroup = view.group();
|
||||
rows.addElement(Row.header(currentGroup));
|
||||
}
|
||||
rows.addElement(Row.of(view));
|
||||
}
|
||||
}
|
||||
|
||||
// Headers can't be chosen: clicking one keeps the current view, and the arrow keys carry on past it.
|
||||
private boolean skipHeaderRow() {
|
||||
int row = getSelectedIndex();
|
||||
if (row < 0 || !rows.get(row).isHeader()) {
|
||||
return false;
|
||||
}
|
||||
int step = row >= lastViewRow ? 1 : -1;
|
||||
int next = nextViewRow(row, step);
|
||||
if (next < 0) {
|
||||
next = nextViewRow(row, -step);
|
||||
}
|
||||
setSelectedIndex(next < 0 ? lastViewRow : next);
|
||||
ensureIndexIsVisible(getSelectedIndex());
|
||||
return true;
|
||||
}
|
||||
|
||||
private int nextViewRow(int from, int step) {
|
||||
for (int row = from + step; row >= 0 && row < rows.size(); row += step) {
|
||||
if (!rows.get(row).isHeader()) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
record Row(MapView.Group group, MapView view) {
|
||||
static Row header(MapView.Group group) {
|
||||
return new Row(group, null);
|
||||
}
|
||||
|
||||
static Row of(MapView view) {
|
||||
return new Row(view.group(), view);
|
||||
}
|
||||
|
||||
boolean isHeader() {
|
||||
return view == null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RowRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = 6302156810211578474L;
|
||||
private static final int ICON_TEXT_GAP = 8;
|
||||
private static final int KEYCAP_SIZE = 17;
|
||||
private static final int KEYCAP_ARC = 5;
|
||||
private String shortcut;
|
||||
private boolean hovered;
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean selected, boolean focused) {
|
||||
Row row = (Row) value;
|
||||
super.getListCellRendererComponent(list, row.isHeader() ? "" : row.view().label(), index, selected && !row.isHeader(), false);
|
||||
if (row.isHeader()) {
|
||||
setText(row.group().label().toUpperCase(Locale.ROOT));
|
||||
setFont(StudioTheme.sectionFont());
|
||||
setForeground(StudioTheme.MUTED_TEXT);
|
||||
setBorder(StudioTheme.emptyBorder(index == 0 ? 2 : 10, 8, 0, 6));
|
||||
setIcon(null);
|
||||
setToolTipText(null);
|
||||
shortcut = null;
|
||||
hovered = false;
|
||||
setOpaque(true);
|
||||
return this;
|
||||
}
|
||||
setFont(StudioTheme.font());
|
||||
setBorder(StudioTheme.emptyBorder(0, 8, 0, 8));
|
||||
setIcon(MapViewIcon.of(row.view()));
|
||||
setIconTextGap(StudioTheme.scale(ICON_TEXT_GAP));
|
||||
setToolTipText(row.view().description());
|
||||
shortcut = row.view().shortcutText();
|
||||
hovered = !selected && list instanceof MapViewList viewList && viewList.hoveredRow == index;
|
||||
setOpaque(!hovered);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
if (hovered) {
|
||||
paintHoverBackground(canvas);
|
||||
}
|
||||
super.paintComponent(graphics);
|
||||
if (shortcut != null) {
|
||||
paintKeycap(canvas);
|
||||
}
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Shaped like the selection the look and feel paints, so moving between them doesn't jump.
|
||||
private void paintHoverBackground(Graphics2D canvas) {
|
||||
Insets inset = UIManager.getInsets("List.selectionInsets");
|
||||
int left = inset == null ? 0 : StudioTheme.scale(inset.left);
|
||||
int right = inset == null ? 0 : StudioTheme.scale(inset.right);
|
||||
int arc = StudioTheme.scale(UIManager.getInt("List.selectionArc"));
|
||||
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
||||
canvas.fillRoundRect(left, 0, getWidth() - left - right, getHeight(), arc, arc);
|
||||
}
|
||||
|
||||
private void paintKeycap(Graphics2D canvas) {
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(shortcut);
|
||||
int height = StudioTheme.scale(KEYCAP_SIZE);
|
||||
int width = Math.max(height, textWidth + StudioTheme.scale(8));
|
||||
int x = getWidth() - getInsets().right - width;
|
||||
int y = (getHeight() - height) / 2;
|
||||
int arc = StudioTheme.scale(KEYCAP_ARC);
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawRoundRect(x, y, width - 1, height - 1, arc, arc);
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
int baseline = y + (height + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
|
||||
canvas.drawString(shortcut, x + (width - textWidth) / 2, baseline);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.components.MapStyle;
|
||||
|
||||
import javax.swing.AbstractAction;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRootPane;
|
||||
import javax.swing.JSplitPane;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.JToolBar;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingConstants;
|
||||
import javax.swing.text.JTextComponent;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.KeyboardFocusManager;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ComponentAdapter;
|
||||
import java.awt.event.ComponentEvent;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
/**
|
||||
* The studio map: one window showing the world as tiles in several views, with the palette, object, noise and image map
|
||||
* explorers as its Palettes, Objects, Noise and Image maps views.
|
||||
*/
|
||||
public final class MapWindow implements MapCanvas.Listener {
|
||||
private static final String MAP_CARD = "map";
|
||||
private static final String PALETTES_CARD = "palettes";
|
||||
private static final String NOISE_CARD = "noise";
|
||||
private static final String WORLD_CARD = "world";
|
||||
private static final String IMAGE_MAPS_CARD = "image maps";
|
||||
private static final int SIDEBAR_WIDTH = 270;
|
||||
private static final int SIDEBAR_MINIMUM_WIDTH = 200;
|
||||
private static final int CONTENT_MINIMUM_WIDTH = 320;
|
||||
private static final double PALETTE_RECENTER_DISTANCE_BLOCKS = 1D;
|
||||
private static final double WORLD_RECENTER_DISTANCE_BLOCKS = 1D;
|
||||
private static final int VIEW_LIST_SCROLL_INSETS = 4;
|
||||
private static final int EXPLORER_DIVIDER_HEIGHT = 7;
|
||||
|
||||
private final JFrame frame;
|
||||
private final PaletteExplorerStart start;
|
||||
private final MapCanvas canvas;
|
||||
private final MapViewList viewList;
|
||||
private final JLabel viewTitle = new JLabel();
|
||||
private final JPanel content = new JPanel(new CardLayout());
|
||||
private final JPanel statusBars = new JPanel(new CardLayout());
|
||||
private final JPanel sidebar = new JPanel(new BorderLayout());
|
||||
private final IslandPanel sidebarIsland = new IslandPanel(sidebar);
|
||||
private final JPanel inspectorSlot = new JPanel(new BorderLayout());
|
||||
private final JComponent viewListScroll;
|
||||
private final JSplitPane explorerSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
|
||||
private final JSplitPane pickInspectorSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
|
||||
private final List<JComponent> mapControls = new ArrayList<>();
|
||||
private final List<JComponent> explorerControls = new ArrayList<>();
|
||||
private final List<JComponent> noiseControls = new ArrayList<>();
|
||||
private final List<JComponent> worldControls = new ArrayList<>();
|
||||
private final List<JComponent> imageMapControls = new ArrayList<>();
|
||||
private final JToolBar toolbar = StudioToolbar.create();
|
||||
private final JComponent resetViewButton = StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)",
|
||||
this::resetExplorerView);
|
||||
private final JToggleButton gridToggle = StudioToolbar.toggle("Grid", "Show chunk and region borders (G)");
|
||||
private final JToggleButton entitiesToggle = StudioToolbar.toggle("Entities", "Show mobs and other entities (E)");
|
||||
private final JToggleButton followToggle = StudioToolbar.toggle("Follow", "Keep your player in the middle (F)");
|
||||
private final JToggleButton shadingToggle = StudioToolbar.toggle("Shading", "Shade hills and valleys on every view (S)");
|
||||
private final JToggleButton bordersToggle = StudioToolbar.toggle("Borders", "Outline the regions on every view (B)");
|
||||
private final ResourceUsage resourceUsage = new ResourceUsage();
|
||||
private final MapLegendPanel legendPanel;
|
||||
private final RegionExplorer regionExplorer;
|
||||
private final BiomeExplorer biomeExplorer;
|
||||
|
||||
private NoiseExplorerGUI paletteExplorer;
|
||||
private final Map<MapView, ObjectExplorer> objectExplorers = new EnumMap<>(MapView.class);
|
||||
private NoiseExplorerGUI noiseExplorer;
|
||||
private JComponent noiseSidebar;
|
||||
private WorldExplorer worldExplorer;
|
||||
private ImageMapExplorer imageMapExplorer;
|
||||
private double worldCenterX;
|
||||
private double worldCenterZ;
|
||||
private MapView currentView;
|
||||
private double paletteCenterX;
|
||||
private double paletteCenterZ;
|
||||
|
||||
private MapWindow(PaletteExplorerStart start) {
|
||||
this.start = start;
|
||||
this.frame = MapHost.createFrame("Iris Map");
|
||||
this.canvas = new MapCanvas(start.engine(), start.worldId(), start.playerId(), this);
|
||||
this.viewList = new MapViewList(this::showView);
|
||||
this.viewListScroll = StudioToolbar.scroll(viewList);
|
||||
this.legendPanel = new MapLegendPanel(share -> canvas.toggleHighlight(share.label()));
|
||||
this.regionExplorer = new RegionExplorer(canvas::engine, canvas::previewRegion, canvas::showPicksAs);
|
||||
this.biomeExplorer = new BiomeExplorer(canvas::engine, canvas::showPicksAs);
|
||||
canvas.centerOn(start.centerX(), start.centerZ());
|
||||
buildFrame();
|
||||
}
|
||||
|
||||
public static void openMap(PaletteExplorerStart start) {
|
||||
openLater(start, MapView.BIOMES);
|
||||
}
|
||||
|
||||
public static void openPalettes(PaletteExplorerStart start) {
|
||||
openLater(start, MapView.PALETTES);
|
||||
}
|
||||
|
||||
public static void openNoise(PaletteExplorerStart start) {
|
||||
openLater(start, MapView.NOISE);
|
||||
}
|
||||
|
||||
public static void openImageMaps(PaletteExplorerStart start) {
|
||||
openLater(start, MapView.IMAGE_MAPS);
|
||||
}
|
||||
|
||||
private static void openLater(PaletteExplorerStart start, MapView initialView) {
|
||||
if (!MapHost.isAvailable()) {
|
||||
return;
|
||||
}
|
||||
EventQueue.invokeLater(() -> open(start, initialView));
|
||||
}
|
||||
|
||||
private static void open(PaletteExplorerStart start, MapView initialView) {
|
||||
if (!MapHost.isAvailable()) {
|
||||
return;
|
||||
}
|
||||
StudioTheme.install();
|
||||
MapWindow window = new MapWindow(start);
|
||||
window.frame.setVisible(true);
|
||||
window.canvas.start();
|
||||
window.resourceUsage.start();
|
||||
window.showView(initialView);
|
||||
}
|
||||
|
||||
private void buildFrame() {
|
||||
styleExplorerSplit();
|
||||
stylePickInspectorSplit();
|
||||
content.add(canvas, MAP_CARD);
|
||||
statusBars.add(canvas.statusBar(), MAP_CARD);
|
||||
sidebar.add(viewListScroll, BorderLayout.CENTER);
|
||||
|
||||
frame.setLayout(new BorderLayout());
|
||||
frame.add(buildToolbar(), BorderLayout.NORTH);
|
||||
frame.add(new IslandOverlay(sidebarIsland, content, StudioTheme.scale(SIDEBAR_WIDTH),
|
||||
StudioTheme.scale(SIDEBAR_MINIMUM_WIDTH), StudioTheme.scale(CONTENT_MINIMUM_WIDTH)), BorderLayout.CENTER);
|
||||
frame.add(inspectorSlot, BorderLayout.EAST);
|
||||
frame.add(statusBars, BorderLayout.SOUTH);
|
||||
installShortcuts(frame.getRootPane());
|
||||
keepClearOfIsland(canvas::setCoveredLeftWidth);
|
||||
frame.setSize(MapHost.fitToScreen(StudioTheme.scaledSize(1440, 880)));
|
||||
frame.setMinimumSize(StudioTheme.scaledSize(820, 520));
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosed(WindowEvent event) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private JToolBar buildToolbar() {
|
||||
viewTitle.setFont(StudioTheme.titleFont());
|
||||
viewTitle.setBorder(StudioTheme.emptyBorder(0, 6, 0, 14));
|
||||
toolbar.add(viewTitle);
|
||||
|
||||
mapControls.add(StudioToolbar.button("Refresh", "Redraw the map with the latest pack files (R)", canvas::refreshContent));
|
||||
mapControls.add(StudioToolbar.button("Reset zoom", "Go back to the default zoom (\\)", canvas::resetZoom));
|
||||
mapControls.add(gridToggle);
|
||||
mapControls.add(entitiesToggle);
|
||||
mapControls.add(followToggle);
|
||||
mapControls.add(toolbarSeparator());
|
||||
mapControls.add(shadingToggle);
|
||||
mapControls.add(bordersToggle);
|
||||
gridToggle.addActionListener(event -> canvas.toggleGrid());
|
||||
entitiesToggle.addActionListener(event -> canvas.toggleEntities());
|
||||
followToggle.addActionListener(event -> canvas.toggleFollow());
|
||||
shadingToggle.addActionListener(event -> canvas.toggleShading());
|
||||
bordersToggle.addActionListener(event -> canvas.toggleRegionBorders());
|
||||
mapControls.forEach(toolbar::add);
|
||||
|
||||
explorerControls.add(resetViewButton);
|
||||
explorerControls.forEach(toolbar::add);
|
||||
|
||||
toolbar.add(Box.createHorizontalGlue());
|
||||
toolbar.add(resourceUsage);
|
||||
toolbar.add(StudioToolbar.themeButton());
|
||||
JComponent help = StudioToolbar.button("Shortcuts", "Show every mouse and key shortcut (/)", canvas::toggleHelp);
|
||||
mapControls.add(help);
|
||||
toolbar.add(help);
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
private static JComponent toolbarSeparator() {
|
||||
JToolBar.Separator separator = new JToolBar.Separator();
|
||||
separator.setOrientation(SwingConstants.VERTICAL);
|
||||
return separator;
|
||||
}
|
||||
|
||||
private void showView(MapView view) {
|
||||
if (view == null || view == currentView) {
|
||||
return;
|
||||
}
|
||||
MapView previousView = currentView;
|
||||
currentView = view;
|
||||
viewList.select(view);
|
||||
viewTitle.setText(view.label());
|
||||
viewTitle.setToolTipText(view.description());
|
||||
if (previousView == MapView.WORLD_3D) {
|
||||
worldExplorer.hide();
|
||||
}
|
||||
if (view.isTileMap()) {
|
||||
showMap(previousView, view);
|
||||
} else if (view == MapView.WORLD_3D) {
|
||||
showWorld();
|
||||
} else if (view == MapView.PALETTES) {
|
||||
showPalettes(previousView);
|
||||
} else if (view == MapView.OBJECTS || view == MapView.PROCEDURAL) {
|
||||
showObjectExplorer(view);
|
||||
} else if (view == MapView.IMAGE_MAPS) {
|
||||
showImageMaps();
|
||||
} else {
|
||||
showNoise();
|
||||
}
|
||||
frame.getRootPane().revalidate();
|
||||
frame.getRootPane().repaint();
|
||||
}
|
||||
|
||||
private void showMap(MapView previousView, MapView view) {
|
||||
if (previousView == MapView.PALETTES && paletteExplorer != null) {
|
||||
canvas.centerOn(paletteExplorer.centerX(), paletteExplorer.centerZ());
|
||||
}
|
||||
canvas.showView(view);
|
||||
canvas.setActive(true);
|
||||
showControls(mapControls);
|
||||
inspectorSlot.removeAll();
|
||||
if (view == MapView.REGIONS) {
|
||||
showRegionExplorer(previousView);
|
||||
} else if (view == MapView.BIOMES) {
|
||||
showBiomeExplorer();
|
||||
} else {
|
||||
useSidebar(viewListScroll);
|
||||
inspectorSlot.add(legendPanel, BorderLayout.CENTER);
|
||||
}
|
||||
showCard(MAP_CARD);
|
||||
canvas.requestFocusInWindow();
|
||||
}
|
||||
|
||||
// The region inspector sits above the legend, which then lists the chosen region's biomes on the map.
|
||||
private void showRegionExplorer(MapView previousView) {
|
||||
if (previousView != MapView.REGIONS) {
|
||||
regionExplorer.loadRegions();
|
||||
}
|
||||
useSidebar(viewListAbove(regionExplorer.navigator()));
|
||||
showPickInspector(regionExplorer.inspector());
|
||||
EventQueue.invokeLater(() -> viewList.select(currentView));
|
||||
regionExplorer.measure(canvas.viewArea());
|
||||
}
|
||||
|
||||
private void showBiomeExplorer() {
|
||||
useSidebar(viewListScroll);
|
||||
showPickInspector(biomeExplorer.inspector());
|
||||
biomeExplorer.measure(canvas.viewArea());
|
||||
}
|
||||
|
||||
// The region and biome views share how the map draws its picks, so their switches show the same choice.
|
||||
private void showPickInspector(PickInspectorPanel inspector) {
|
||||
inspector.selectDisplay(canvas.pickDisplay());
|
||||
pickInspectorSplit.setTopComponent(inspector);
|
||||
pickInspectorSplit.setBottomComponent(legendPanel);
|
||||
inspectorSlot.add(pickInspectorSplit, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
private void stylePickInspectorSplit() {
|
||||
pickInspectorSplit.setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
||||
pickInspectorSplit.setResizeWeight(0.7D);
|
||||
pickInspectorSplit.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
pickInspectorSplit.setDividerSize(StudioTheme.scale(EXPLORER_DIVIDER_HEIGHT));
|
||||
pickInspectorSplit.putClientProperty("FlatLaf.style", "style: grip");
|
||||
}
|
||||
|
||||
private void showPalettes(MapView previousView) {
|
||||
boolean firstOpen = paletteExplorer == null;
|
||||
if (firstOpen) {
|
||||
createPaletteExplorer(previousView == null);
|
||||
} else {
|
||||
followMapIntoPalettes();
|
||||
}
|
||||
canvas.setActive(false);
|
||||
showControls(explorerControls);
|
||||
useSidebar(viewListAbove(paletteExplorer.paletteSidebar()));
|
||||
inspectorSlot.removeAll();
|
||||
inspectorSlot.add(paletteExplorer.paletteInspector(), BorderLayout.CENTER);
|
||||
showCard(PALETTES_CARD);
|
||||
EventQueue.invokeLater(() -> viewList.select(currentView));
|
||||
if (firstOpen) {
|
||||
paletteExplorer.openViewer();
|
||||
}
|
||||
}
|
||||
|
||||
private void showObjectExplorer(MapView view) {
|
||||
boolean firstOpen = !objectExplorers.containsKey(view);
|
||||
ObjectExplorer explorer = objectExplorers.computeIfAbsent(view, this::createObjectExplorer);
|
||||
canvas.setActive(false);
|
||||
showControls(explorerControls);
|
||||
useSidebar(viewListAbove(explorer.browser()));
|
||||
inspectorSlot.removeAll();
|
||||
inspectorSlot.add(explorer.overview(), BorderLayout.CENTER);
|
||||
showCard(view.name());
|
||||
EventQueue.invokeLater(() -> viewList.select(currentView));
|
||||
if (firstOpen) {
|
||||
explorer.open();
|
||||
}
|
||||
}
|
||||
|
||||
// The 3D world keeps its camera unless the map was moved since; then it turns around the map's middle.
|
||||
private void showWorld() {
|
||||
boolean firstOpen = worldExplorer == null;
|
||||
if (firstOpen) {
|
||||
createWorldExplorer();
|
||||
}
|
||||
boolean mapMoved = Math.hypot(canvas.centerX() - worldCenterX, canvas.centerZ() - worldCenterZ) >= WORLD_RECENTER_DISTANCE_BLOCKS;
|
||||
worldCenterX = canvas.centerX();
|
||||
worldCenterZ = canvas.centerZ();
|
||||
canvas.setActive(false);
|
||||
showControls(worldControls, explorerControls);
|
||||
useSidebar(viewListScroll);
|
||||
inspectorSlot.removeAll();
|
||||
inspectorSlot.add(worldExplorer.legend(), BorderLayout.CENTER);
|
||||
showCard(WORLD_CARD);
|
||||
worldExplorer.show(worldCenterX, worldCenterZ, firstOpen || mapMoved);
|
||||
}
|
||||
|
||||
private void createWorldExplorer() {
|
||||
worldExplorer = new WorldExplorer(canvas::engine);
|
||||
keepClearOfIsland(worldExplorer::setCoveredLeftWidth);
|
||||
worldControls.addAll(worldExplorer.controls());
|
||||
worldControls.forEach(control -> toolbar.add(control, toolbar.getComponentIndex(resetViewButton)));
|
||||
content.add(worldExplorer.view(), WORLD_CARD);
|
||||
statusBars.add(worldExplorer.statusBar(), WORLD_CARD);
|
||||
}
|
||||
|
||||
private void keepClearOfIsland(IntConsumer setCoveredLeftWidth) {
|
||||
setCoveredLeftWidth.accept(sidebarIsland.getWidth());
|
||||
sidebarIsland.addComponentListener(new ComponentAdapter() {
|
||||
@Override
|
||||
public void componentResized(ComponentEvent event) {
|
||||
setCoveredLeftWidth.accept(sidebarIsland.getWidth());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ObjectExplorer createObjectExplorer(MapView view) {
|
||||
ObjectSource source = view == MapView.PROCEDURAL ? new ProceduralObjectSource() : new PackObjectSource();
|
||||
ObjectExplorer explorer = new ObjectExplorer(start.engine(), start.worldId(), source);
|
||||
keepClearOfIsland(explorer::setCoveredLeftWidth);
|
||||
content.add(explorer.view(), view.name());
|
||||
statusBars.add(explorer.statusBar(), view.name());
|
||||
return explorer;
|
||||
}
|
||||
|
||||
private void showNoise() {
|
||||
boolean firstOpen = noiseExplorer == null;
|
||||
if (firstOpen) {
|
||||
createNoiseExplorer();
|
||||
}
|
||||
canvas.setActive(false);
|
||||
showControls(noiseControls, explorerControls);
|
||||
useSidebar(viewListAbove(noiseSidebar));
|
||||
inspectorSlot.removeAll();
|
||||
showCard(NOISE_CARD);
|
||||
EventQueue.invokeLater(() -> viewList.select(currentView));
|
||||
if (firstOpen) {
|
||||
noiseExplorer.openViewer();
|
||||
}
|
||||
}
|
||||
|
||||
private void showImageMaps() {
|
||||
boolean firstOpen = imageMapExplorer == null;
|
||||
if (firstOpen) {
|
||||
createImageMapExplorer();
|
||||
}
|
||||
canvas.setActive(false);
|
||||
showControls(imageMapControls);
|
||||
useSidebar(viewListScroll);
|
||||
inspectorSlot.removeAll();
|
||||
showCard(IMAGE_MAPS_CARD);
|
||||
if (firstOpen) {
|
||||
imageMapExplorer.open();
|
||||
}
|
||||
}
|
||||
|
||||
private void createImageMapExplorer() {
|
||||
imageMapExplorer = new ImageMapExplorer(start.engine());
|
||||
keepClearOfIsland(imageMapExplorer::setCoveredLeftWidth);
|
||||
imageMapControls.addAll(imageMapExplorer.controls());
|
||||
imageMapControls.forEach(control -> toolbar.add(control, toolbar.getComponentIndex(resetViewButton)));
|
||||
content.add(imageMapExplorer.view(), IMAGE_MAPS_CARD);
|
||||
statusBars.add(imageMapExplorer.statusBar(), IMAGE_MAPS_CARD);
|
||||
}
|
||||
|
||||
// Both explorers share one split, so the divider stays where it was dragged when switching between them.
|
||||
private JSplitPane viewListAbove(JComponent explorerSidebar) {
|
||||
boolean firstUse = explorerSplit.getBottomComponent() == null;
|
||||
explorerSplit.setTopComponent(viewListScroll);
|
||||
explorerSplit.setBottomComponent(explorerSidebar);
|
||||
if (firstUse) {
|
||||
EventQueue.invokeLater(this::fitViewListAboveExplorer);
|
||||
}
|
||||
return explorerSplit;
|
||||
}
|
||||
|
||||
private void styleExplorerSplit() {
|
||||
explorerSplit.setBorder(BorderFactory.createEmptyBorder());
|
||||
explorerSplit.setResizeWeight(0D);
|
||||
explorerSplit.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
explorerSplit.setDividerSize(StudioTheme.scale(EXPLORER_DIVIDER_HEIGHT));
|
||||
explorerSplit.putClientProperty("FlatLaf.style", "style: grip");
|
||||
}
|
||||
|
||||
// Every view stays in sight, so the divider starts below the last one and can only be dragged further down. Measured
|
||||
// once the sidebar is laid out, since the list's rows are taller before they know their width.
|
||||
private void fitViewListAboveExplorer() {
|
||||
int fullHeight = viewList.getPreferredSize().height + VIEW_LIST_SCROLL_INSETS;
|
||||
viewListScroll.setMinimumSize(new Dimension(0, fullHeight));
|
||||
explorerSplit.setDividerLocation(fullHeight);
|
||||
}
|
||||
|
||||
private void createNoiseExplorer() {
|
||||
noiseExplorer = NoiseExplorerGUI.createNoiseExplorer(start.engine());
|
||||
keepClearOfIsland(noiseExplorer::setCoveredLeftWidth);
|
||||
noiseSidebar = noiseExplorer.noiseSidebar();
|
||||
noiseControls.addAll(noiseExplorer.noiseControls());
|
||||
noiseControls.forEach(control -> toolbar.add(control, toolbar.getComponentIndex(resetViewButton)));
|
||||
content.add(noiseExplorer, NOISE_CARD);
|
||||
statusBars.add(noiseExplorer.statusBar(), NOISE_CARD);
|
||||
}
|
||||
|
||||
private void createPaletteExplorer(boolean openedOnPalettes) {
|
||||
PaletteExplorerStart explorerStart = openedOnPalettes
|
||||
? start
|
||||
: new PaletteExplorerStart(start.engine(), start.worldId(), start.playerId(), null, canvas.centerX(), canvas.centerZ());
|
||||
paletteExplorer = NoiseExplorerGUI.createPaletteExplorer(explorerStart);
|
||||
content.add(paletteExplorer, PALETTES_CARD);
|
||||
statusBars.add(paletteExplorer.statusBar(), PALETTES_CARD);
|
||||
rememberPaletteCenter(explorerStart.centerX(), explorerStart.centerZ());
|
||||
if (!openedOnPalettes) {
|
||||
showSurfaceBiomePalettes(explorerStart.centerX(), explorerStart.centerZ());
|
||||
}
|
||||
}
|
||||
|
||||
// Coming back from the map only moves the palettes when the map was moved, so a chosen layer survives a quick look.
|
||||
private void followMapIntoPalettes() {
|
||||
double movedX = canvas.centerX() - paletteCenterX;
|
||||
double movedZ = canvas.centerZ() - paletteCenterZ;
|
||||
if (Math.hypot(movedX, movedZ) < PALETTE_RECENTER_DISTANCE_BLOCKS) {
|
||||
return;
|
||||
}
|
||||
paletteExplorer.centerOn(canvas.centerX(), canvas.centerZ());
|
||||
rememberPaletteCenter(canvas.centerX(), canvas.centerZ());
|
||||
showSurfaceBiomePalettes(canvas.centerX(), canvas.centerZ());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowPalettesAt(double worldX, double worldZ) {
|
||||
canvas.centerOn(worldX, worldZ);
|
||||
showView(MapView.PALETTES);
|
||||
}
|
||||
|
||||
private void showSurfaceBiomePalettes(double worldX, double worldZ) {
|
||||
canvas.findSurfaceBiome(worldX, worldZ, biomeKey -> {
|
||||
if (paletteExplorer != null) {
|
||||
paletteExplorer.showPalettesAt(worldX, worldZ, biomeKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void rememberPaletteCenter(double worldX, double worldZ) {
|
||||
paletteCenterX = worldX;
|
||||
paletteCenterZ = worldZ;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTogglesChanged() {
|
||||
gridToggle.setSelected(canvas.isGridVisible());
|
||||
entitiesToggle.setSelected(canvas.areEntitiesVisible());
|
||||
followToggle.setSelected(canvas.isFollowing());
|
||||
shadingToggle.setSelected(canvas.style().shading());
|
||||
bordersToggle.setSelected(canvas.style().regionBorders());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLegendChanged(String title, List<MapCensus.Share> shares, MapStyle.Highlight highlight) {
|
||||
legendPanel.show(title, shares, highlight);
|
||||
if (currentView == MapView.REGIONS) {
|
||||
regionExplorer.measure(canvas.viewArea());
|
||||
} else if (currentView == MapView.BIOMES) {
|
||||
biomeExplorer.measure(canvas.viewArea());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContentRefreshed() {
|
||||
regionExplorer.refresh();
|
||||
biomeExplorer.refresh();
|
||||
if (worldExplorer != null) {
|
||||
worldExplorer.refresh(worldCenterX, worldCenterZ);
|
||||
}
|
||||
}
|
||||
|
||||
// Only called for the view on screen, whose explorer exists by then.
|
||||
private void resetExplorerView() {
|
||||
switch (currentView) {
|
||||
case WORLD_3D -> worldExplorer.resetCamera();
|
||||
case OBJECTS, PROCEDURAL -> objectExplorers.get(currentView).resetCamera();
|
||||
case NOISE -> noiseExplorer.resetViewport();
|
||||
case PALETTES -> paletteExplorer.resetViewport();
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void useSidebar(JComponent component) {
|
||||
sidebar.removeAll();
|
||||
sidebar.add(component, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
private void showCard(String card) {
|
||||
((CardLayout) content.getLayout()).show(content, card);
|
||||
((CardLayout) statusBars.getLayout()).show(statusBars, card);
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private void showControls(List<JComponent>... visibleControls) {
|
||||
for (List<JComponent> controls : List.of(mapControls, explorerControls, noiseControls, worldControls, imageMapControls)) {
|
||||
controls.forEach(control -> control.setVisible(false));
|
||||
}
|
||||
for (List<JComponent> controls : visibleControls) {
|
||||
controls.forEach(control -> control.setVisible(true));
|
||||
}
|
||||
}
|
||||
|
||||
private void installShortcuts(JRootPane rootPane) {
|
||||
for (MapView view : MapView.values()) {
|
||||
bind(rootPane, KeyStroke.getKeyStroke(view.shortcutKey(), 0), "view-" + view.name(), () -> showView(view));
|
||||
}
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_M, 0), "next-view", this::showNextView);
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_R, 0), "refresh", this::refreshOrReset);
|
||||
bindMap(rootPane, KeyEvent.VK_F, 0, "follow", canvas::toggleFollow);
|
||||
bindMap(rootPane, KeyEvent.VK_G, 0, "grid", canvas::toggleGrid);
|
||||
bindMap(rootPane, KeyEvent.VK_E, 0, "entities", canvas::toggleEntities);
|
||||
bindMap(rootPane, KeyEvent.VK_F3, 0, "debug", canvas::toggleDebug);
|
||||
bindMap(rootPane, KeyEvent.VK_H, 0, "highlight", canvas::highlightUnderCursor);
|
||||
bindMap(rootPane, KeyEvent.VK_ESCAPE, 0, "dismiss", canvas::dismissEmphasis);
|
||||
bindMap(rootPane, KeyEvent.VK_S, 0, "shading", canvas::toggleShading);
|
||||
bindMap(rootPane, KeyEvent.VK_B, 0, "borders", canvas::toggleRegionBorders);
|
||||
bindMap(rootPane, KeyEvent.VK_EQUALS, 0, "zoom-in", canvas::zoomIn);
|
||||
bindMap(rootPane, KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK, "zoom-in-plus", canvas::zoomIn);
|
||||
bindMap(rootPane, KeyEvent.VK_ADD, 0, "zoom-in-keypad", canvas::zoomIn);
|
||||
bindMap(rootPane, KeyEvent.VK_MINUS, 0, "zoom-out", canvas::zoomOut);
|
||||
bindMap(rootPane, KeyEvent.VK_SUBTRACT, 0, "zoom-out-keypad", canvas::zoomOut);
|
||||
bindMap(rootPane, KeyEvent.VK_BACK_SLASH, 0, "reset-zoom", canvas::resetZoom);
|
||||
bindMap(rootPane, KeyEvent.VK_SLASH, 0, "help", canvas::toggleHelp);
|
||||
bindMap(rootPane, KeyEvent.VK_SLASH, KeyEvent.SHIFT_DOWN_MASK, "help-question", canvas::toggleHelp);
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, KeyEvent.SHIFT_DOWN_MASK, false), "details-on",
|
||||
() -> canvas.setDetailedHover(true));
|
||||
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, 0, true), "details-off", () -> canvas.setDetailedHover(false));
|
||||
}
|
||||
|
||||
private void bindMap(JRootPane rootPane, int keyCode, int modifiers, String name, Runnable action) {
|
||||
bind(rootPane, KeyStroke.getKeyStroke(keyCode, modifiers), name, () -> {
|
||||
if (currentView != null && currentView.isTileMap()) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keys typed into a search box belong to that box, not to the window's shortcuts.
|
||||
private static void bind(JRootPane rootPane, KeyStroke keyStroke, String name, Runnable action) {
|
||||
rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(keyStroke, name);
|
||||
rootPane.getActionMap().put(name, new AbstractAction() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent event) {
|
||||
if (!(KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner() instanceof JTextComponent)) {
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showNextView() {
|
||||
MapView[] views = MapView.values();
|
||||
showView(views[(currentView.ordinal() + 1) % views.length]);
|
||||
}
|
||||
|
||||
private void refreshOrReset() {
|
||||
if (currentView.isTileMap()) {
|
||||
canvas.refreshContent();
|
||||
} else {
|
||||
resetExplorerView();
|
||||
}
|
||||
}
|
||||
|
||||
private void close() {
|
||||
resourceUsage.stop();
|
||||
canvas.close();
|
||||
regionExplorer.close();
|
||||
biomeExplorer.close();
|
||||
if (paletteExplorer != null) {
|
||||
paletteExplorer.close();
|
||||
}
|
||||
objectExplorers.values().forEach(ObjectExplorer::close);
|
||||
if (noiseExplorer != null) {
|
||||
noiseExplorer.close();
|
||||
}
|
||||
if (worldExplorer != null) {
|
||||
worldExplorer.close();
|
||||
}
|
||||
if (imageMapExplorer != null) {
|
||||
imageMapExplorer.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import art.arcane.iris.generation.biome.IrisBiomeCustom;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
// Works out biome tint colours the way Minecraft does, from the colour maps and vanilla biome files in the client jar.
|
||||
final class MinecraftBiomeColors {
|
||||
private static final String COLOR_MAP_FOLDER = "assets/minecraft/textures/colormap/";
|
||||
private static final int COLOR_MAP_SIZE = 256;
|
||||
private static final int GRASS_FALLBACK = 0xFF00FF;
|
||||
private static final int FOLIAGE_FALLBACK = 0x48B518;
|
||||
private static final int DRY_FOLIAGE_FALLBACK = 0x5C3C32;
|
||||
private static final float UNKNOWN_BIOME_TEMPERATURE = 0.5F;
|
||||
private static final float UNKNOWN_BIOME_DOWNFALL = 0.5F;
|
||||
private static final int UNKNOWN_BIOME_WATER = 0x3F76E4;
|
||||
private static final int UNPARSEABLE_COLOR = 0x000000;
|
||||
|
||||
private final ZipFile clientJar;
|
||||
private final ColorMap grassMap;
|
||||
private final ColorMap foliageMap;
|
||||
private final ColorMap dryFoliageMap;
|
||||
private static final String DEFAULT_NAMESPACE = "minecraft";
|
||||
|
||||
private final Map<String, BiomeTintColors> vanillaBiomes = new ConcurrentHashMap<>();
|
||||
|
||||
private MinecraftBiomeColors(ZipFile clientJar, ColorMap grassMap, ColorMap foliageMap, ColorMap dryFoliageMap) {
|
||||
this.clientJar = clientJar;
|
||||
this.grassMap = grassMap;
|
||||
this.foliageMap = foliageMap;
|
||||
this.dryFoliageMap = dryFoliageMap;
|
||||
}
|
||||
|
||||
static MinecraftBiomeColors load(ZipFile clientJar) throws IOException {
|
||||
return new MinecraftBiomeColors(
|
||||
clientJar,
|
||||
ColorMap.read(clientJar, "grass.png", GRASS_FALLBACK),
|
||||
ColorMap.read(clientJar, "foliage.png", FOLIAGE_FALLBACK),
|
||||
ColorMap.read(clientJar, "dry_foliage.png", DRY_FOLIAGE_FALLBACK)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param biome a biome key like {@code minecraft:plains}; one without a namespace is taken as Minecraft's
|
||||
*/
|
||||
BiomeTintColors vanilla(String biome) {
|
||||
return vanillaBiomes.computeIfAbsent(biome, this::readVanillaBiome);
|
||||
}
|
||||
|
||||
// Iris registers custom biomes as data pack biomes built from these fields, see IrisBiomeCustom#generateJson.
|
||||
BiomeTintColors custom(IrisBiomeCustom biome) {
|
||||
float temperature = (float) biome.getTemperature();
|
||||
float downfall = (float) biome.getHumidity();
|
||||
return new BiomeTintColors(
|
||||
biome.getGrassColor().isEmpty() ? grassMap.colorAt(temperature, downfall) : parseIrisColor(biome.getGrassColor()),
|
||||
BiomeTintColors.GrassColorModifier.NONE,
|
||||
biome.getFoliageColor().isEmpty() ? foliageMap.colorAt(temperature, downfall) : parseIrisColor(biome.getFoliageColor()),
|
||||
dryFoliageMap.colorAt(temperature, downfall),
|
||||
parseIrisColor(biome.getWaterColor())
|
||||
);
|
||||
}
|
||||
|
||||
private BiomeTintColors readVanillaBiome(String biome) {
|
||||
JsonObject definition = readBiomeDefinition(biome);
|
||||
if (definition == null) {
|
||||
return fromClimate(UNKNOWN_BIOME_TEMPERATURE, UNKNOWN_BIOME_DOWNFALL, new JsonObject());
|
||||
}
|
||||
JsonObject effects = definition.has("effects") ? definition.getAsJsonObject("effects") : new JsonObject();
|
||||
return fromClimate(definition.get("temperature").getAsFloat(), definition.get("downfall").getAsFloat(), effects);
|
||||
}
|
||||
|
||||
private BiomeTintColors fromClimate(float temperature, float downfall, JsonObject effects) {
|
||||
return new BiomeTintColors(
|
||||
effects.has("grass_color") ? parseVanillaColor(effects.get("grass_color")) : grassMap.colorAt(temperature, downfall),
|
||||
effects.has("grass_color_modifier")
|
||||
? BiomeTintColors.GrassColorModifier.named(effects.get("grass_color_modifier").getAsString())
|
||||
: BiomeTintColors.GrassColorModifier.NONE,
|
||||
effects.has("foliage_color") ? parseVanillaColor(effects.get("foliage_color")) : foliageMap.colorAt(temperature, downfall),
|
||||
effects.has("dry_foliage_color") ? parseVanillaColor(effects.get("dry_foliage_color")) : dryFoliageMap.colorAt(temperature, downfall),
|
||||
effects.has("water_color") ? parseVanillaColor(effects.get("water_color")) : UNKNOWN_BIOME_WATER
|
||||
);
|
||||
}
|
||||
|
||||
private JsonObject readBiomeDefinition(String biome) {
|
||||
int separator = biome.indexOf(':');
|
||||
String namespace = separator < 0 ? DEFAULT_NAMESPACE : biome.substring(0, separator);
|
||||
String path = biome.substring(separator + 1);
|
||||
try {
|
||||
return ClientJarFiles.readJson(clientJar, "data/" + namespace + "/worldgen/biome/" + path + ".json");
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Older versions store colours as plain numbers, newer ones as "#rrggbb" strings.
|
||||
private static int parseVanillaColor(JsonElement color) {
|
||||
if (color.getAsJsonPrimitive().isNumber()) {
|
||||
return color.getAsInt() & 0xFFFFFF;
|
||||
}
|
||||
return Integer.parseInt(color.getAsString().substring(1), 16);
|
||||
}
|
||||
|
||||
private static int parseIrisColor(String color) {
|
||||
try {
|
||||
return Color.decode((color.startsWith("#") ? color : "#" + color).trim()).getRGB() & 0xFFFFFF;
|
||||
} catch (RuntimeException error) {
|
||||
return UNPARSEABLE_COLOR;
|
||||
}
|
||||
}
|
||||
|
||||
// Minecraft's ColorMapColorUtil: temperature picks the column and temperature times downfall picks the row.
|
||||
private record ColorMap(int[] pixels, int fallback) {
|
||||
private static ColorMap read(ZipFile clientJar, String name, int fallback) throws IOException {
|
||||
BufferedImage image = ClientJarFiles.readImage(clientJar, COLOR_MAP_FOLDER + name);
|
||||
if (image == null) {
|
||||
throw new IOException("Minecraft colour map " + name + " is missing");
|
||||
}
|
||||
int[] pixels = new int[COLOR_MAP_SIZE * COLOR_MAP_SIZE];
|
||||
for (int y = 0; y < COLOR_MAP_SIZE; y++) {
|
||||
for (int x = 0; x < COLOR_MAP_SIZE; x++) {
|
||||
pixels[y * COLOR_MAP_SIZE + x] = image.getRGB(x, y) & 0xFFFFFF;
|
||||
}
|
||||
}
|
||||
return new ColorMap(pixels, fallback);
|
||||
}
|
||||
|
||||
private int colorAt(float temperature, float downfall) {
|
||||
double clampedTemperature = Math.clamp(temperature, 0F, 1F);
|
||||
double clampedDownfall = Math.clamp(downfall, 0F, 1F) * clampedTemperature;
|
||||
int x = (int) ((1D - clampedTemperature) * 255D);
|
||||
int y = (int) ((1D - clampedDownfall) * 255D);
|
||||
int index = y << 8 | x;
|
||||
return index >= pixels.length ? fallback : pixels[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
// Mirrors Minecraft's BlockColors: which colour each tinted face of a block is multiplied with in the world.
|
||||
final class MinecraftBlockTints {
|
||||
static final int NO_TINT = 0xFFFFFF;
|
||||
private static final Set<String> GRASS_TINTED = Set.of(
|
||||
"grass_block", "short_grass", "fern", "potted_fern", "bush", "tall_grass", "large_fern", "sugar_cane");
|
||||
private static final Set<String> GRASS_TINTED_ON_SECOND_LAYER = Set.of("pink_petals", "wildflowers");
|
||||
private static final Set<String> FOLIAGE_TINTED = Set.of(
|
||||
"oak_leaves", "jungle_leaves", "acacia_leaves", "dark_oak_leaves", "vine", "mangrove_leaves");
|
||||
private static final Set<String> WATER_TINTED_BY_FLUID_RENDERER = Set.of("water", "bubble_column");
|
||||
private static final Set<String> GROWING_STEMS = Set.of("melon_stem", "pumpkin_stem");
|
||||
private static final Map<String, Integer> FIXED_TINTS = Map.of(
|
||||
"spruce_leaves", 0x619961,
|
||||
"birch_leaves", 0x80A755,
|
||||
"attached_melon_stem", 0xE0C71C,
|
||||
"attached_pumpkin_stem", 0xE0C71C,
|
||||
"lily_pad", 0x208030
|
||||
);
|
||||
|
||||
private MinecraftBlockTints() {
|
||||
}
|
||||
|
||||
static Optional<BiomeTint> biomeTint(String blockName, int tintIndex) {
|
||||
if (WATER_TINTED_BY_FLUID_RENDERER.contains(blockName)) {
|
||||
return Optional.of(BiomeTint.WATER);
|
||||
}
|
||||
if (GRASS_TINTED_ON_SECOND_LAYER.contains(blockName)) {
|
||||
return tintIndex == 1 ? Optional.of(BiomeTint.GRASS) : Optional.empty();
|
||||
}
|
||||
if (tintIndex != 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (GRASS_TINTED.contains(blockName)) {
|
||||
return Optional.of(BiomeTint.GRASS);
|
||||
}
|
||||
if (FOLIAGE_TINTED.contains(blockName)) {
|
||||
return Optional.of(BiomeTint.FOLIAGE);
|
||||
}
|
||||
if (blockName.equals("leaf_litter")) {
|
||||
return Optional.of(BiomeTint.DRY_FOLIAGE);
|
||||
}
|
||||
if (blockName.equals("water_cauldron")) {
|
||||
return Optional.of(BiomeTint.WATER);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
static int fixedTint(String blockName, Map<String, String> properties, int tintIndex) {
|
||||
if (tintIndex != 0) {
|
||||
return NO_TINT;
|
||||
}
|
||||
if (GROWING_STEMS.contains(blockName)) {
|
||||
return stemTint(Integer.parseInt(properties.getOrDefault("age", "0")));
|
||||
}
|
||||
return FIXED_TINTS.getOrDefault(blockName, NO_TINT);
|
||||
}
|
||||
|
||||
private static int stemTint(int age) {
|
||||
return (age * 32) << 16 | (255 - age * 8) << 8 | (age * 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
final class MinecraftClientJar {
|
||||
private static final URI VERSION_MANIFEST = URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json");
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final Duration DOWNLOAD_TIMEOUT = Duration.ofMinutes(10);
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.connectTimeout(CONNECT_TIMEOUT)
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
private MinecraftClientJar() {
|
||||
}
|
||||
|
||||
static boolean isCached() {
|
||||
return cachedJar().exists();
|
||||
}
|
||||
|
||||
static CompletableFuture<ZipFile> open() {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
File jar = cachedJar();
|
||||
if (!jar.exists()) {
|
||||
download(IrisPlatforms.get().minecraftVersion(), jar.toPath());
|
||||
}
|
||||
return new ZipFile(jar);
|
||||
} catch (IOException | InterruptedException error) {
|
||||
throw new IllegalStateException(readableReason(error), error);
|
||||
}
|
||||
}, MinecraftClientJar::runInBackground);
|
||||
}
|
||||
|
||||
private static void runInBackground(Runnable task) {
|
||||
Thread thread = new Thread(task, "Iris Minecraft Client Download");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private static File cachedJar() {
|
||||
return IrisPlatforms.get().dataFile("cache", "minecraft-client", IrisPlatforms.get().minecraftVersion() + ".jar");
|
||||
}
|
||||
|
||||
private static void download(String version, Path destination) throws IOException, InterruptedException {
|
||||
JsonObject versionInfo = fetchJson(findVersionUrl(version));
|
||||
JsonObject client = versionInfo.getAsJsonObject("downloads").getAsJsonObject("client");
|
||||
URI clientUrl = URI.create(client.get("url").getAsString());
|
||||
String expectedSha1 = client.get("sha1").getAsString();
|
||||
|
||||
Path partial = destination.resolveSibling(destination.getFileName() + ".part");
|
||||
HttpResponse<InputStream> response = HTTP.send(
|
||||
HttpRequest.newBuilder(clientUrl).timeout(DOWNLOAD_TIMEOUT).build(),
|
||||
HttpResponse.BodyHandlers.ofInputStream()
|
||||
);
|
||||
requireSuccess(response.statusCode(), clientUrl);
|
||||
String actualSha1;
|
||||
try (DigestInputStream body = new DigestInputStream(response.body(), sha1())) {
|
||||
Files.copy(body, partial, StandardCopyOption.REPLACE_EXISTING);
|
||||
actualSha1 = HexFormat.of().formatHex(body.getMessageDigest().digest());
|
||||
}
|
||||
if (!actualSha1.equalsIgnoreCase(expectedSha1)) {
|
||||
Files.deleteIfExists(partial);
|
||||
throw new IOException("the downloaded client jar was damaged (checksum mismatch)");
|
||||
}
|
||||
Files.move(partial, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
}
|
||||
|
||||
private static URI findVersionUrl(String version) throws IOException, InterruptedException {
|
||||
for (var entry : fetchJson(VERSION_MANIFEST).getAsJsonArray("versions")) {
|
||||
JsonObject candidate = entry.getAsJsonObject();
|
||||
if (candidate.get("id").getAsString().equals(version)) {
|
||||
return URI.create(candidate.get("url").getAsString());
|
||||
}
|
||||
}
|
||||
throw new IOException("Mojang doesn't list Minecraft " + version);
|
||||
}
|
||||
|
||||
private static JsonObject fetchJson(URI url) throws IOException, InterruptedException {
|
||||
HttpResponse<String> response = HTTP.send(
|
||||
HttpRequest.newBuilder(url).timeout(CONNECT_TIMEOUT).build(),
|
||||
HttpResponse.BodyHandlers.ofString()
|
||||
);
|
||||
requireSuccess(response.statusCode(), url);
|
||||
return JsonParser.parseString(response.body()).getAsJsonObject();
|
||||
}
|
||||
|
||||
private static void requireSuccess(int statusCode, URI url) throws IOException {
|
||||
if (statusCode / 100 != 2) {
|
||||
throw new IOException(url.getHost() + " answered HTTP " + statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageDigest sha1() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-1");
|
||||
} catch (NoSuchAlgorithmException error) {
|
||||
throw new IllegalStateException(error);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readableReason(Throwable error) {
|
||||
String message = error.getMessage();
|
||||
return message == null || message.isBlank() ? error.getClass().getSimpleName() : message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.awt.Color;
|
||||
|
||||
interface NoiseColorScale {
|
||||
int colorFinite(double value);
|
||||
|
||||
default int colorAt(double value, double worldX, double worldZ) {
|
||||
return colorFinite(value);
|
||||
}
|
||||
|
||||
int colorInvalid();
|
||||
|
||||
default int colorInvalidAt(double worldX, double worldZ) {
|
||||
return colorInvalid();
|
||||
}
|
||||
|
||||
Color displayColorNormalized(double normalized);
|
||||
|
||||
String label();
|
||||
|
||||
double minimum();
|
||||
|
||||
double maximum();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.volmlib.util.function.NoiseProvider;
|
||||
|
||||
import java.util.function.DoubleUnaryOperator;
|
||||
|
||||
/**
|
||||
* Noise as terrain columns: each value raised to a height and coloured by the noise's colour scale.
|
||||
*
|
||||
* @param heightOf turns a noise value into a height in blocks
|
||||
*/
|
||||
record NoiseColumns(NoiseProvider noise, DoubleUnaryOperator heightOf, NoiseColorScale colorScale) implements TerrainColumns {
|
||||
@Override
|
||||
public void sampleRow(TerrainGrid grid, int row, float[] heights, int[] colors) {
|
||||
double worldZ = grid.originZ() + (double) row * grid.step();
|
||||
for (int column = 0; column < grid.columns(); column++) {
|
||||
double worldX = grid.originX() + (double) column * grid.step();
|
||||
double value = noise.noise(worldX, worldZ);
|
||||
int index = row * grid.columns() + column;
|
||||
heights[index] = Double.isFinite(value) ? (float) heightOf.applyAsDouble(value) : Float.NaN;
|
||||
colors[index] = Double.isFinite(value) ? colorScale.colorAt(value, worldX, worldZ) : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+35
-7
@@ -1,8 +1,26 @@
|
||||
package art.arcane.iris.studio.view;
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.awt.Color;
|
||||
|
||||
enum NoisePalette {
|
||||
enum NoisePalette implements NoiseColorScale {
|
||||
TERRAIN("Terrain", 0D, 1D, new int[]{0x071A2F, 0x155E75, 0x2A9D8F, 0xE9C46A, 0xF4F1DE}),
|
||||
SIGNED("Signed", -1D, 1D, new int[]{0x173B66, 0x4F86C6, 0xE7EDF3, 0xE89555, 0x9D3B35}),
|
||||
GRAYSCALE("Grayscale", 0D, 1D, new int[]{0x050505, 0xFFFFFF});
|
||||
@@ -30,7 +48,8 @@ enum NoisePalette {
|
||||
return colorFinite(value);
|
||||
}
|
||||
|
||||
int colorFinite(double value) {
|
||||
@Override
|
||||
public int colorFinite(double value) {
|
||||
double normalized = (value - minimum) / (maximum - minimum);
|
||||
return colorNormalized(normalized);
|
||||
}
|
||||
@@ -40,20 +59,29 @@ enum NoisePalette {
|
||||
return lookup[(int) Math.round(clipped * (lookup.length - 1))];
|
||||
}
|
||||
|
||||
Color displayColorNormalized(double normalized) {
|
||||
@Override
|
||||
public int colorInvalid() {
|
||||
return INVALID_COLOR;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Color displayColorNormalized(double normalized) {
|
||||
double clipped = Math.max(0D, Math.min(1D, normalized));
|
||||
return displayColors[(int) Math.round(clipped * (displayColors.length - 1))];
|
||||
}
|
||||
|
||||
String label() {
|
||||
@Override
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
double minimum() {
|
||||
@Override
|
||||
public double minimum() {
|
||||
return minimum;
|
||||
}
|
||||
|
||||
double maximum() {
|
||||
@Override
|
||||
public double maximum() {
|
||||
return maximum;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import java.awt.Color;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* One noise that picks regions or biomes: the spread of its values in view above the bands that pick each option,
|
||||
* and a table of how much of the noise each option is given and how much it gets in view. A long table shows only
|
||||
* the options with the most ground in view until asked for all; a noise with one option to pick is a single line.
|
||||
*/
|
||||
final class NoisePickChart extends JComponent {
|
||||
private static final long serialVersionUID = 1873365510472853316L;
|
||||
private static final int PREFERRED_WIDTH = 300;
|
||||
private static final int MARGIN = 16;
|
||||
private static final int HISTOGRAM_HEIGHT = 64;
|
||||
private static final int BAND_HEIGHT = 12;
|
||||
private static final int TITLE_GAP = 6;
|
||||
private static final int SECTION_GAP = 12;
|
||||
private static final int BOTTOM_GAP = 14;
|
||||
private static final Color HISTOGRAM_BAR = new Color(0x9D, 0xA0, 0xA8, 150);
|
||||
private static final Color BAND_DIVIDER = new Color(255, 255, 255, 70);
|
||||
private static final String WIDEST_SHARE = "▲ 100.0%";
|
||||
private static final int SHORT_TABLE_ROWS = 6;
|
||||
|
||||
private final NoisePickSpread spread;
|
||||
private final List<NoisePickSpread.Row> rows;
|
||||
private final String optionNoun;
|
||||
private final Consumer<Boolean> onShowAllRowsChanged;
|
||||
private boolean showsAllRows;
|
||||
private Rectangle showAllToggleBounds;
|
||||
|
||||
/**
|
||||
* @param optionNoun what the noise picks, as a table heading: "Regions" or "Biomes"
|
||||
* @param showsAllRows whether a long table starts with all its rows
|
||||
* @param onShowAllRowsChanged receives whether the table shows all its rows after the user asked for more or fewer
|
||||
*/
|
||||
NoisePickChart(NoisePickSpread spread, String optionNoun, boolean showsAllRows, Consumer<Boolean> onShowAllRowsChanged) {
|
||||
this.spread = spread;
|
||||
this.rows = spread.rows();
|
||||
this.optionNoun = optionNoun;
|
||||
this.showsAllRows = showsAllRows;
|
||||
this.onShowAllRowsChanged = onShowAllRowsChanged;
|
||||
setAlignmentX(LEFT_ALIGNMENT);
|
||||
listenForShowAllToggle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
if (hasSingleOption()) {
|
||||
return new Dimension(StudioTheme.scale(PREFERRED_WIDTH), StudioTheme.scale(SECTION_GAP) + tableHeight()
|
||||
+ StudioTheme.scale(BOTTOM_GAP));
|
||||
}
|
||||
int height = StudioTheme.scale(SECTION_GAP) + spreadHeight() + StudioTheme.scale(SECTION_GAP) + tableHeight()
|
||||
+ StudioTheme.scale(BOTTOM_GAP);
|
||||
return new Dimension(StudioTheme.scale(PREFERRED_WIDTH), height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getMaximumSize() {
|
||||
return new Dimension(Integer.MAX_VALUE, getPreferredSize().height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
int margin = StudioTheme.scale(MARGIN);
|
||||
int width = getWidth() - margin * 2;
|
||||
int top = StudioTheme.scale(SECTION_GAP);
|
||||
if (hasSingleOption()) {
|
||||
drawSingleOption(canvas, margin, top, width);
|
||||
return;
|
||||
}
|
||||
drawSpread(canvas, margin, top, width);
|
||||
drawTable(canvas, margin, top + spreadHeight() + StudioTheme.scale(SECTION_GAP), width);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private int spreadHeight() {
|
||||
return sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP) + StudioTheme.scale(HISTOGRAM_HEIGHT)
|
||||
+ StudioTheme.scale(2) + StudioTheme.scale(BAND_HEIGHT) + StudioTheme.scale(3) + smallMetrics().getHeight();
|
||||
}
|
||||
|
||||
private boolean hasSingleOption() {
|
||||
return rows.size() == 1;
|
||||
}
|
||||
|
||||
private boolean isLongTable() {
|
||||
return rows.size() > SHORT_TABLE_ROWS;
|
||||
}
|
||||
|
||||
private int tableHeight() {
|
||||
int toggleRows = isLongTable() ? 1 : 0;
|
||||
return sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP) + rowHeight() * (shownRows().size() + toggleRows);
|
||||
}
|
||||
|
||||
// A short table keeps the options with the most ground in view, still in the order the noise meets them.
|
||||
private List<NoisePickSpread.Row> shownRows() {
|
||||
if (showsAllRows || !isLongTable()) {
|
||||
return rows;
|
||||
}
|
||||
Set<NoisePickSpread.Row> largest = rows.stream()
|
||||
.sorted(Comparator.comparingDouble(NoisePickSpread.Row::actualShare).reversed())
|
||||
.limit(SHORT_TABLE_ROWS)
|
||||
.collect(Collectors.toSet());
|
||||
return rows.stream().filter(largest::contains).toList();
|
||||
}
|
||||
|
||||
private void listenForShowAllToggle() {
|
||||
MouseAdapter toggle = new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent event) {
|
||||
if (showAllToggleBounds == null || !showAllToggleBounds.contains(event.getPoint())) {
|
||||
return;
|
||||
}
|
||||
showsAllRows = !showsAllRows;
|
||||
onShowAllRowsChanged.accept(showsAllRows);
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent event) {
|
||||
boolean overToggle = showAllToggleBounds != null && showAllToggleBounds.contains(event.getPoint());
|
||||
setCursor(overToggle ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : null);
|
||||
}
|
||||
};
|
||||
addMouseListener(toggle);
|
||||
addMouseMotionListener(toggle);
|
||||
}
|
||||
|
||||
private void drawSingleOption(Graphics2D canvas, int x, int top, int width) {
|
||||
drawSectionTitle(canvas, spread.title(), x, top + sectionMetrics().getAscent());
|
||||
int rowTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
|
||||
NoisePickSpread.Row row = rows.getFirst();
|
||||
String only = "picked everywhere";
|
||||
int nameRight = x + width - textMetrics().stringWidth(only) - StudioTheme.scale(10);
|
||||
int baseline = drawOptionName(canvas, row, x, rowTop, nameRight);
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
drawRightAligned(canvas, only, x + width, baseline);
|
||||
}
|
||||
|
||||
private void drawSpread(Graphics2D canvas, int x, int top, int width) {
|
||||
drawSectionTitle(canvas, spread.title(), x, top + sectionMetrics().getAscent());
|
||||
int histogramTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
|
||||
int histogramHeight = StudioTheme.scale(HISTOGRAM_HEIGHT);
|
||||
drawHistogram(canvas, x, histogramTop, width, histogramHeight);
|
||||
int bandTop = histogramTop + histogramHeight + StudioTheme.scale(2);
|
||||
drawBands(canvas, x, width, histogramTop, bandTop);
|
||||
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
int labelY = bandTop + StudioTheme.scale(BAND_HEIGHT) + StudioTheme.scale(3) + metrics.getAscent();
|
||||
canvas.drawString("0", x, labelY);
|
||||
canvas.drawString("0.5", x + (width - metrics.stringWidth("0.5")) / 2, labelY);
|
||||
canvas.drawString("1", x + width - metrics.stringWidth("1"), labelY);
|
||||
}
|
||||
|
||||
private void drawHistogram(Graphics2D canvas, int x, int top, int width, int height) {
|
||||
long[] histogram = spread.histogram();
|
||||
long peak = 1L;
|
||||
for (long count : histogram) {
|
||||
peak = Math.max(peak, count);
|
||||
}
|
||||
canvas.setColor(HISTOGRAM_BAR);
|
||||
for (int bin = 0; bin < histogram.length; bin++) {
|
||||
int barLeft = x + (bin * width) / histogram.length;
|
||||
int barRight = x + ((bin + 1) * width) / histogram.length;
|
||||
int barHeight = (int) Math.round(height * (histogram[bin] / (double) peak));
|
||||
canvas.fillRect(barLeft, top + height - barHeight, Math.max(1, barRight - barLeft - 1), barHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawBands(Graphics2D canvas, int x, int width, int histogramTop, int bandTop) {
|
||||
int bandHeight = StudioTheme.scale(BAND_HEIGHT);
|
||||
for (NoisePickSpread.Band band : spread.bands()) {
|
||||
int bandLeft = x + (int) Math.round(band.fromNoise() * width);
|
||||
int bandRight = x + (int) Math.round(band.toNoise() * width);
|
||||
canvas.setColor(new Color(band.option().color()));
|
||||
canvas.fillRect(bandLeft, bandTop, Math.max(1, bandRight - bandLeft), bandHeight);
|
||||
if (band.toNoise() < 1D) {
|
||||
canvas.setColor(BAND_DIVIDER);
|
||||
canvas.drawLine(bandRight, histogramTop, bandRight, bandTop + bandHeight - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawTable(Graphics2D canvas, int x, int top, int width) {
|
||||
int columnGap = StudioTheme.scale(10);
|
||||
int actualRight = x + width;
|
||||
int weightRight = actualRight - textMetrics().stringWidth(WIDEST_SHARE) - columnGap;
|
||||
int nameRight = weightRight - textMetrics().stringWidth(WIDEST_SHARE) - columnGap;
|
||||
int titleBaseline = top + sectionMetrics().getAscent();
|
||||
drawSectionTitle(canvas, optionNoun, x, titleBaseline);
|
||||
canvas.setFont(StudioTheme.sectionFont());
|
||||
drawRightAligned(canvas, "WEIGHT", weightRight, titleBaseline);
|
||||
drawRightAligned(canvas, "IN VIEW", actualRight, titleBaseline);
|
||||
|
||||
int rowHeight = rowHeight();
|
||||
int rowTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
|
||||
for (NoisePickSpread.Row row : shownRows()) {
|
||||
int baseline = drawOptionName(canvas, row, x, rowTop, nameRight);
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
drawRightAligned(canvas, percent(row.weightShare()), weightRight, baseline);
|
||||
boolean farFromWeight = row.isFarBelowWeight() || row.isFarAboveWeight();
|
||||
canvas.setColor(farFromWeight ? StudioTheme.WARNING : StudioTheme.TEXT);
|
||||
drawRightAligned(canvas, actualText(row), actualRight, baseline);
|
||||
rowTop += rowHeight;
|
||||
}
|
||||
showAllToggleBounds = isLongTable() ? drawShowAllToggle(canvas, x, rowTop) : null;
|
||||
}
|
||||
|
||||
// Returns the row's text baseline.
|
||||
private int drawOptionName(Graphics2D canvas, NoisePickSpread.Row row, int x, int rowTop, int nameRight) {
|
||||
FontMetrics text = textMetrics();
|
||||
int rowHeight = rowHeight();
|
||||
int swatchSize = text.getAscent() - text.getDescent();
|
||||
int baseline = rowTop + (rowHeight + text.getAscent() - text.getDescent()) / 2;
|
||||
int arc = StudioTheme.scale(3);
|
||||
canvas.setColor(new Color(row.option().color()));
|
||||
canvas.fillRoundRect(x, rowTop + (rowHeight - swatchSize) / 2, swatchSize, swatchSize, arc, arc);
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(StudioTheme.TEXT);
|
||||
int nameLeft = x + swatchSize + StudioTheme.scale(7);
|
||||
canvas.drawString(fit(canvas, distinctName(row, rows), nameRight - nameLeft), nameLeft, baseline);
|
||||
return baseline;
|
||||
}
|
||||
|
||||
private Rectangle drawShowAllToggle(Graphics2D canvas, int x, int rowTop) {
|
||||
String text = showsAllRows
|
||||
? "Show fewer"
|
||||
: "Show all " + rows.size() + " " + optionNoun.toLowerCase(Locale.ROOT);
|
||||
FontMetrics metrics = textMetrics();
|
||||
int baseline = rowTop + (rowHeight() + metrics.getAscent() - metrics.getDescent()) / 2;
|
||||
canvas.setFont(StudioTheme.font());
|
||||
canvas.setColor(StudioTheme.LINK);
|
||||
canvas.drawString(text, x, baseline);
|
||||
return new Rectangle(x, rowTop, metrics.stringWidth(text), rowHeight());
|
||||
}
|
||||
|
||||
private int rowHeight() {
|
||||
return textMetrics().getHeight() + StudioTheme.scale(4);
|
||||
}
|
||||
|
||||
private FontMetrics textMetrics() {
|
||||
return getFontMetrics(StudioTheme.font());
|
||||
}
|
||||
|
||||
private FontMetrics smallMetrics() {
|
||||
return getFontMetrics(StudioTheme.smallFont());
|
||||
}
|
||||
|
||||
private FontMetrics sectionMetrics() {
|
||||
return getFontMetrics(StudioTheme.sectionFont());
|
||||
}
|
||||
|
||||
private static void drawSectionTitle(Graphics2D canvas, String title, int x, int y) {
|
||||
canvas.setFont(StudioTheme.sectionFont());
|
||||
canvas.setColor(StudioTheme.MUTED_TEXT);
|
||||
canvas.drawString(title.toUpperCase(Locale.ROOT), x, y);
|
||||
}
|
||||
|
||||
private static void drawRightAligned(Graphics2D canvas, String text, int right, int y) {
|
||||
canvas.drawString(text, right - canvas.getFontMetrics().stringWidth(text), y);
|
||||
}
|
||||
|
||||
// Two biomes in different folders can share a name, so their keys tell them apart.
|
||||
private static String distinctName(NoisePickSpread.Row row, List<NoisePickSpread.Row> rows) {
|
||||
String name = row.option().name();
|
||||
boolean shared = rows.stream().anyMatch(other -> other != row && other.option().name().equals(name));
|
||||
return shared ? name + " (" + row.option().key() + ")" : name;
|
||||
}
|
||||
|
||||
private static String actualText(NoisePickSpread.Row row) {
|
||||
if (row.isFarBelowWeight()) {
|
||||
return "▼ " + percent(row.actualShare());
|
||||
}
|
||||
if (row.isFarAboveWeight()) {
|
||||
return "▲ " + percent(row.actualShare());
|
||||
}
|
||||
return percent(row.actualShare());
|
||||
}
|
||||
|
||||
static String percent(double share) {
|
||||
if (share > 0D && share < 0.001D) {
|
||||
return "<0.1%";
|
||||
}
|
||||
return String.format(Locale.ROOT, "%.1f%%", share * 100D);
|
||||
}
|
||||
|
||||
private static String fit(Graphics2D canvas, String text, int maxWidth) {
|
||||
FontMetrics metrics = canvas.getFontMetrics();
|
||||
if (metrics.stringWidth(text) <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
String shortened = text;
|
||||
while (shortened.length() > 1 && metrics.stringWidth(shortened + "…") > maxWidth) {
|
||||
shortened = shortened.substring(0, shortened.length() - 1);
|
||||
}
|
||||
return shortened + "…";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* How a noise picks one of several regions or biomes, measured against the noise values in view: rarity splits the
|
||||
* noise from 0 to 1 into one band per option, and each spot gets the option whose band its noise value falls in.
|
||||
*/
|
||||
final class NoisePickSpread {
|
||||
static final int HISTOGRAM_BINS = 50;
|
||||
|
||||
private final String title;
|
||||
private final List<Band> bands;
|
||||
private final long[] histogram = new long[HISTOGRAM_BINS];
|
||||
private final long[] samplesByBand;
|
||||
private long sampleCount;
|
||||
|
||||
private NoisePickSpread(String title, List<Band> bands) {
|
||||
this.title = title;
|
||||
this.bands = List.copyOf(bands);
|
||||
this.samplesByBand = new long[bands.size()];
|
||||
}
|
||||
|
||||
static NoisePickSpread of(String title, List<Option> options) {
|
||||
return new NoisePickSpread(title, splitNoise(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* The band of noise each option gets, in the order the noise meets them.
|
||||
*/
|
||||
static List<Band> splitNoise(List<Option> options) {
|
||||
return bands(options);
|
||||
}
|
||||
|
||||
void add(double noise) {
|
||||
if (bands.isEmpty() || !Double.isFinite(noise)) {
|
||||
return;
|
||||
}
|
||||
sampleCount++;
|
||||
histogram[Math.clamp((int) (noise * HISTOGRAM_BINS), 0, HISTOGRAM_BINS - 1)]++;
|
||||
samplesByBand[bandIndexOf(noise)]++;
|
||||
}
|
||||
|
||||
String title() {
|
||||
return title;
|
||||
}
|
||||
|
||||
List<Band> bands() {
|
||||
return bands;
|
||||
}
|
||||
|
||||
long[] histogram() {
|
||||
return histogram;
|
||||
}
|
||||
|
||||
long sampleCount() {
|
||||
return sampleCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every option once, with its bands' widths and samples added up, in the order the noise meets them.
|
||||
*/
|
||||
List<Row> rows() {
|
||||
Map<String, Row> rows = new LinkedHashMap<>();
|
||||
for (int index = 0; index < bands.size(); index++) {
|
||||
Band band = bands.get(index);
|
||||
Row row = new Row(band.option(), band.width(), share(samplesByBand[index]));
|
||||
rows.merge(band.option().key(), row, Row::plus);
|
||||
}
|
||||
return new ArrayList<>(rows.values());
|
||||
}
|
||||
|
||||
private double share(long count) {
|
||||
return sampleCount == 0 ? 0D : count / (double) sampleCount;
|
||||
}
|
||||
|
||||
// The engine gives values below 0 the first option and values above 1 the last, the same as these bands.
|
||||
private int bandIndexOf(double noise) {
|
||||
for (int index = 0; index < bands.size(); index++) {
|
||||
if (noise <= bands.get(index).toNoise()) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return bands.size() - 1;
|
||||
}
|
||||
|
||||
// Mirrors RarityStream: each option gets a share of the noise in proportion to one over its rarity.
|
||||
private static List<Band> bands(List<Option> options) {
|
||||
double total = 0D;
|
||||
for (Option option : options) {
|
||||
total += 1D / option.rarity();
|
||||
}
|
||||
List<Band> bands = new ArrayList<>();
|
||||
double from = 0D;
|
||||
for (Option option : options) {
|
||||
double to = options.size() == 1 ? 1D : from + (1D / option.rarity()) / total;
|
||||
bands.add(new Band(option, from, to));
|
||||
from = to;
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
/**
|
||||
* A region or biome the noise can pick.
|
||||
*/
|
||||
record Option(String key, String name, int color, int rarity) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The noise values from {@code fromNoise} to {@code toNoise} that pick the option.
|
||||
*/
|
||||
record Band(Option option, double fromNoise, double toNoise) {
|
||||
double width() {
|
||||
return toNoise - fromNoise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param weightShare how much of the noise from 0 to 1 picks the option
|
||||
* @param actualShare how much of the noise in view picks the option
|
||||
*/
|
||||
record Row(Option option, double weightShare, double actualShare) {
|
||||
private static final double FAR_FROM_WEIGHT_FACTOR = 2D;
|
||||
|
||||
Row plus(Row other) {
|
||||
return new Row(option, weightShare + other.weightShare, actualShare + other.actualShare);
|
||||
}
|
||||
|
||||
boolean isFarBelowWeight() {
|
||||
return actualShare * FAR_FROM_WEIGHT_FACTOR < weightShare;
|
||||
}
|
||||
|
||||
boolean isFarAboveWeight() {
|
||||
return actualShare > weightShare * FAR_FROM_WEIGHT_FACTOR;
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
-147
@@ -1,14 +1,28 @@
|
||||
package art.arcane.iris.studio.view;
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.generation.runtime.PreservationRegistry;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.volmlib.util.function.NoiseProvider;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
@@ -19,10 +33,11 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
final class NoiseRenderCoordinator implements AutoCloseable {
|
||||
private static final int BANDS_PER_WORKER = 4;
|
||||
|
||||
private final int workerCount;
|
||||
private final Listener listener;
|
||||
private final ThreadFactory workerThreadFactory;
|
||||
private final ThreadFactory coordinatorThreadFactory;
|
||||
private final ThreadPoolExecutor workers;
|
||||
private final AtomicReference<RenderGeneration> latestRequested = new AtomicReference<>();
|
||||
private final AtomicLong latestRevision = new AtomicLong();
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
@@ -35,18 +50,16 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
||||
this.workerCount = Math.max(1, workerCount);
|
||||
this.listener = Objects.requireNonNull(listener, "listener");
|
||||
AtomicInteger threadIds = new AtomicInteger();
|
||||
workerThreadFactory = runnable -> {
|
||||
ThreadFactory workerThreadFactory = runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Noise Renderer " + threadIds.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
};
|
||||
coordinatorThreadFactory = runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Noise Coordinator " + threadIds.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
};
|
||||
workers = new ThreadPoolExecutor(this.workerCount, this.workerCount, 30L, TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(), workerThreadFactory);
|
||||
workers.allowCoreThreadTimeOut(true);
|
||||
MapHost.interruptOnPluginShutdown(workers);
|
||||
}
|
||||
|
||||
void request(Request request) {
|
||||
@@ -55,15 +68,23 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
||||
return;
|
||||
}
|
||||
latestRevision.accumulateAndGet(request.revision(), Math::max);
|
||||
cancelActiveRender();
|
||||
RenderGeneration generation = createGeneration(request);
|
||||
RenderGeneration generation = new RenderGeneration(request, Math.min(workerCount * BANDS_PER_WORKER, outputHeight(request)));
|
||||
latestRequested.set(generation);
|
||||
generation.start();
|
||||
listener.onRenderStarted(request);
|
||||
for (int band = 0; band < generation.bandCount(); band++) {
|
||||
int bandIndex = band;
|
||||
try {
|
||||
workers.execute(() -> renderBand(generation, bandIndex));
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
generation.fail(rejected);
|
||||
finishBand(generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void cancel(long revision) {
|
||||
latestRevision.accumulateAndGet(revision, Math::max);
|
||||
cancelActiveRender();
|
||||
latestRequested.set(null);
|
||||
}
|
||||
|
||||
boolean isClosed() {
|
||||
@@ -120,95 +141,49 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
||||
return;
|
||||
}
|
||||
latestRevision.incrementAndGet();
|
||||
cancelActiveRender();
|
||||
latestRequested.set(null);
|
||||
workers.shutdownNow();
|
||||
}
|
||||
|
||||
private void renderGeneration(RenderGeneration generation) {
|
||||
Request request = generation.request();
|
||||
if (!isCurrent(request)) {
|
||||
generation.close();
|
||||
return;
|
||||
}
|
||||
listener.onRenderStarted(request);
|
||||
// Bands of a frame that was replaced are skipped, so a new frame only waits for the rows already being drawn.
|
||||
private void renderBand(RenderGeneration generation, int bandIndex) {
|
||||
try {
|
||||
Result result = render(generation);
|
||||
if (result != null && isCurrent(request)) {
|
||||
listener.onRenderCompleted(result);
|
||||
if (isCurrent(generation)) {
|
||||
renderRows(generation, bandIndex);
|
||||
}
|
||||
} catch (Throwable error) {
|
||||
if (isCurrent(request)) {
|
||||
listener.onRenderFailed(request, error);
|
||||
}
|
||||
generation.fail(error);
|
||||
} finally {
|
||||
latestRequested.compareAndSet(generation, null);
|
||||
generation.close();
|
||||
finishBand(generation);
|
||||
}
|
||||
}
|
||||
|
||||
private Result render(RenderGeneration generation) throws InterruptedException {
|
||||
private void finishBand(RenderGeneration generation) {
|
||||
if (!generation.completeBand() || !isCurrent(generation)) {
|
||||
return;
|
||||
}
|
||||
Throwable failure = generation.failure();
|
||||
if (failure != null) {
|
||||
listener.onRenderFailed(generation.request(), failure);
|
||||
return;
|
||||
}
|
||||
listener.onRenderCompleted(generation.result());
|
||||
}
|
||||
|
||||
private void renderRows(RenderGeneration generation, int bandIndex) {
|
||||
Request request = generation.request();
|
||||
long started = System.nanoTime();
|
||||
int outputWidth = Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
|
||||
int outputHeight = Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
|
||||
BufferedImage image = new BufferedImage(outputWidth, outputHeight, BufferedImage.TYPE_INT_RGB);
|
||||
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||
int bandCount = Math.min(workerCount, outputHeight);
|
||||
BandStats[] bandStats = new BandStats[bandCount];
|
||||
CountDownLatch completion = new CountDownLatch(bandCount);
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
for (int band = 0; band < bandCount; band++) {
|
||||
bandStats[band] = new BandStats();
|
||||
}
|
||||
for (int band = 0; band < bandCount; band++) {
|
||||
int bandIndex = band;
|
||||
Runnable task = () -> {
|
||||
try {
|
||||
renderBand(request, image, pixels, bandIndex, bandCount, bandStats[bandIndex]);
|
||||
} catch (Throwable error) {
|
||||
failure.compareAndSet(null, error);
|
||||
} finally {
|
||||
completion.countDown();
|
||||
}
|
||||
};
|
||||
try {
|
||||
generation.executor().execute(task);
|
||||
} catch (RejectedExecutionException exception) {
|
||||
failure.compareAndSet(null, exception);
|
||||
completion.countDown();
|
||||
}
|
||||
}
|
||||
completion.await();
|
||||
Throwable renderFailure = failure.get();
|
||||
if (renderFailure != null) {
|
||||
if (renderFailure instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
if (renderFailure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IllegalStateException("Noise rendering failed", renderFailure);
|
||||
}
|
||||
if (!isCurrent(request)) {
|
||||
return null;
|
||||
}
|
||||
RenderStats combined = combine(bandStats);
|
||||
double milliseconds = (System.nanoTime() - started) / 1_000_000D;
|
||||
return new Result(request, image, milliseconds, combined.samples, combined.minimum, combined.maximum,
|
||||
combined.centerValue, combined.underflow, combined.overflow, combined.invalid);
|
||||
}
|
||||
|
||||
private void renderBand(Request request, BufferedImage image, int[] pixels, int bandIndex, int bandCount,
|
||||
BandStats stats) {
|
||||
int outputWidth = image.getWidth();
|
||||
int outputHeight = image.getHeight();
|
||||
int fromY = (outputHeight * bandIndex) / bandCount;
|
||||
int toY = (outputHeight * (bandIndex + 1)) / bandCount;
|
||||
int outputWidth = generation.outputWidth();
|
||||
int outputHeight = generation.outputHeight();
|
||||
int[] pixels = generation.pixels();
|
||||
BandStats stats = generation.bandStats(bandIndex);
|
||||
int fromY = (outputHeight * bandIndex) / generation.bandCount();
|
||||
int toY = (outputHeight * (bandIndex + 1)) / generation.bandCount();
|
||||
double sampleOffset = request.sampleStep() * 0.5D;
|
||||
double startWorldX = request.viewport().worldX(sampleOffset, request.width());
|
||||
double worldStep = request.viewport().blocksPerPixel() * request.sampleStep();
|
||||
NoisePalette palette = request.palette();
|
||||
double paletteMinimum = palette.minimum();
|
||||
double paletteMaximum = palette.maximum();
|
||||
NoiseColorScale colorScale = request.colorScale();
|
||||
double paletteMinimum = colorScale.minimum();
|
||||
double paletteMaximum = colorScale.maximum();
|
||||
for (int y = fromY; y < toY; y++) {
|
||||
double screenY = (y * (double) request.sampleStep()) + sampleOffset;
|
||||
double worldZ = request.viewport().worldZ(screenY, request.height());
|
||||
@@ -216,16 +191,16 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
||||
int pixelIndex = y * outputWidth;
|
||||
boolean centerRow = y == outputHeight / 2;
|
||||
for (int x = 0; x < outputWidth; x++) {
|
||||
if ((x & 31) == 0 && (!isCurrent(request) || Thread.currentThread().isInterrupted())) {
|
||||
if ((x & 31) == 0 && (!isCurrent(generation) || Thread.currentThread().isInterrupted())) {
|
||||
return;
|
||||
}
|
||||
double value = request.sampler().noise(worldX, worldZ);
|
||||
boolean center = centerRow && x == outputWidth / 2;
|
||||
if (Double.isFinite(value)) {
|
||||
pixels[pixelIndex++] = palette.colorFinite(value);
|
||||
pixels[pixelIndex++] = colorScale.colorAt(value, worldX, worldZ);
|
||||
stats.acceptFinite(value, center, paletteMinimum, paletteMaximum);
|
||||
} else {
|
||||
pixels[pixelIndex++] = NoisePalette.INVALID_COLOR;
|
||||
pixels[pixelIndex++] = colorScale.colorInvalidAt(worldX, worldZ);
|
||||
stats.acceptInvalid(value, center);
|
||||
}
|
||||
worldX += worldStep;
|
||||
@@ -233,41 +208,18 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrent(Request request) {
|
||||
private boolean isCurrent(RenderGeneration generation) {
|
||||
return !closed.get()
|
||||
&& latestRequested.get() != null
|
||||
&& latestRequested.get().request() == request
|
||||
&& request.revision() >= latestRevision.get();
|
||||
&& latestRequested.get() == generation
|
||||
&& generation.request().revision() >= latestRevision.get();
|
||||
}
|
||||
|
||||
private void cancelActiveRender() {
|
||||
RenderGeneration generation = latestRequested.getAndSet(null);
|
||||
if (generation != null) {
|
||||
generation.close();
|
||||
}
|
||||
private static int outputWidth(Request request) {
|
||||
return Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
|
||||
}
|
||||
|
||||
private RenderGeneration createGeneration(Request request) {
|
||||
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||
workerCount,
|
||||
workerCount,
|
||||
0L,
|
||||
TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(Math.max(1, workerCount)),
|
||||
workerThreadFactory,
|
||||
new ThreadPoolExecutor.AbortPolicy()
|
||||
);
|
||||
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
|
||||
if (preservation != null) {
|
||||
preservation.register(executor);
|
||||
}
|
||||
RenderGeneration generation = new RenderGeneration(request, executor);
|
||||
Thread actualRunner = coordinatorThreadFactory.newThread(() -> renderGeneration(generation));
|
||||
generation.setRunner(actualRunner);
|
||||
if (preservation != null) {
|
||||
preservation.register(actualRunner);
|
||||
}
|
||||
return generation;
|
||||
private static int outputHeight(Request request) {
|
||||
return Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
|
||||
}
|
||||
|
||||
private static RenderStats combine(BandStats[] bands) {
|
||||
@@ -304,12 +256,12 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
||||
void onRenderFailed(Request request, Throwable error);
|
||||
}
|
||||
|
||||
record Request(long revision, NoiseProvider sampler, NoiseViewport viewport, NoisePalette palette,
|
||||
record Request(long revision, NoiseProvider sampler, NoiseViewport viewport, NoiseColorScale colorScale,
|
||||
int width, int height, int sampleStep) {
|
||||
Request {
|
||||
Objects.requireNonNull(sampler, "sampler");
|
||||
Objects.requireNonNull(viewport, "viewport");
|
||||
Objects.requireNonNull(palette, "palette");
|
||||
Objects.requireNonNull(colorScale, "colorScale");
|
||||
if (revision < 0L || width < 1 || height < 1 || sampleStep < 1) {
|
||||
throw new IllegalArgumentException("Invalid noise render request");
|
||||
}
|
||||
@@ -322,36 +274,69 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
||||
|
||||
private static final class RenderGeneration {
|
||||
private final Request request;
|
||||
private final ThreadPoolExecutor executor;
|
||||
private volatile Thread runner;
|
||||
private final long startedNanos = System.nanoTime();
|
||||
private final int outputWidth;
|
||||
private final int outputHeight;
|
||||
private final int[] pixels;
|
||||
private final BandStats[] bandStats;
|
||||
private final AtomicInteger remainingBands;
|
||||
private final AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
|
||||
private RenderGeneration(Request request, ThreadPoolExecutor executor) {
|
||||
this.request = Objects.requireNonNull(request, "request");
|
||||
this.executor = Objects.requireNonNull(executor, "executor");
|
||||
private RenderGeneration(Request request, int bandCount) {
|
||||
this.request = request;
|
||||
this.outputWidth = NoiseRenderCoordinator.outputWidth(request);
|
||||
this.outputHeight = NoiseRenderCoordinator.outputHeight(request);
|
||||
this.pixels = new int[outputWidth * outputHeight];
|
||||
this.bandStats = new BandStats[Math.max(1, bandCount)];
|
||||
for (int band = 0; band < bandStats.length; band++) {
|
||||
bandStats[band] = new BandStats();
|
||||
}
|
||||
this.remainingBands = new AtomicInteger(bandStats.length);
|
||||
}
|
||||
|
||||
private Request request() {
|
||||
return request;
|
||||
}
|
||||
|
||||
private ThreadPoolExecutor executor() {
|
||||
return executor;
|
||||
private int bandCount() {
|
||||
return bandStats.length;
|
||||
}
|
||||
|
||||
private void setRunner(Thread runner) {
|
||||
this.runner = Objects.requireNonNull(runner, "runner");
|
||||
private int outputWidth() {
|
||||
return outputWidth;
|
||||
}
|
||||
|
||||
private void start() {
|
||||
runner.start();
|
||||
private int outputHeight() {
|
||||
return outputHeight;
|
||||
}
|
||||
|
||||
private void close() {
|
||||
Thread activeRunner = runner;
|
||||
if (activeRunner != null) {
|
||||
activeRunner.interrupt();
|
||||
}
|
||||
executor.shutdownNow();
|
||||
private int[] pixels() {
|
||||
return pixels;
|
||||
}
|
||||
|
||||
private BandStats bandStats(int band) {
|
||||
return bandStats[band];
|
||||
}
|
||||
|
||||
private void fail(Throwable error) {
|
||||
failure.compareAndSet(null, error);
|
||||
}
|
||||
|
||||
private Throwable failure() {
|
||||
return failure.get();
|
||||
}
|
||||
|
||||
// True for the band that finishes last, which is the one that publishes the frame.
|
||||
private boolean completeBand() {
|
||||
return remainingBands.decrementAndGet() == 0;
|
||||
}
|
||||
|
||||
private Result result() {
|
||||
BufferedImage image = MapHost.uncachedImage(pixels, outputWidth, outputHeight);
|
||||
RenderStats combined = combine(bandStats);
|
||||
double milliseconds = (System.nanoTime() - startedNanos) / 1_000_000D;
|
||||
return new Result(request, image, milliseconds, combined.samples, combined.minimum, combined.maximum,
|
||||
combined.centerValue, combined.underflow, combined.overflow, combined.invalid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* A stretch of noise or world as terrain at Minecraft scale: one height in blocks and one colour per column, each column
|
||||
* {@code step} blocks wide and deep. Columns without a valid value are left out.
|
||||
*/
|
||||
record NoiseTerrain(int originX, int originZ, int columns, int rows, int step, float[] heights, int[] colors,
|
||||
double lowest, double highest) {
|
||||
/**
|
||||
* Samples every column of the grid.
|
||||
*/
|
||||
static NoiseTerrain sample(TerrainSampling sampling, TerrainGrid grid) {
|
||||
float[] heights = new float[grid.columns() * grid.rows()];
|
||||
int[] colors = new int[heights.length];
|
||||
for (int row = 0; row < grid.rows(); row++) {
|
||||
sampling.columns().sampleRow(grid, row, heights, colors);
|
||||
}
|
||||
return of(grid, heights, colors);
|
||||
}
|
||||
|
||||
static NoiseTerrain empty(TerrainGrid grid) {
|
||||
float[] heights = new float[grid.columns() * grid.rows()];
|
||||
Arrays.fill(heights, Float.NaN);
|
||||
return of(grid, heights, new int[heights.length]);
|
||||
}
|
||||
|
||||
private static NoiseTerrain of(TerrainGrid grid, float[] heights, int[] colors) {
|
||||
double lowest = Double.NaN;
|
||||
double highest = Double.NaN;
|
||||
for (float height : heights) {
|
||||
if (Float.isNaN(height)) {
|
||||
continue;
|
||||
}
|
||||
lowest = Double.isNaN(lowest) ? height : Math.min(lowest, height);
|
||||
highest = Double.isNaN(highest) ? height : Math.max(highest, height);
|
||||
}
|
||||
return new NoiseTerrain(grid.originX(), grid.originZ(), grid.columns(), grid.rows(), grid.step(), heights, colors,
|
||||
lowest, highest);
|
||||
}
|
||||
|
||||
boolean hasHeight(int column, int row) {
|
||||
return column >= 0 && row >= 0 && column < columns && row < rows && !Float.isNaN(heights[row * columns + column]);
|
||||
}
|
||||
|
||||
float height(int column, int row) {
|
||||
return heights[row * columns + column];
|
||||
}
|
||||
|
||||
int color(int column, int row) {
|
||||
return colors[row * columns + column];
|
||||
}
|
||||
|
||||
// The world position of a column's or row's first block.
|
||||
double worldX(double column) {
|
||||
return originX + column * step;
|
||||
}
|
||||
|
||||
double worldZ(double row) {
|
||||
return originZ + row * step;
|
||||
}
|
||||
|
||||
int sizeX() {
|
||||
return columns * step;
|
||||
}
|
||||
|
||||
int sizeZ() {
|
||||
return rows * step;
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return Double.isNaN(lowest);
|
||||
}
|
||||
|
||||
// A block below the lowest point, where sides on the terrain's edge end.
|
||||
int baseY() {
|
||||
return (int) Math.floor(lowest) - 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
/**
|
||||
* Draws noise terrain without a graphics card. Block columns get Minecraft's face shading and an outline around every
|
||||
* block, until blocks get too small on screen to outline. Noise measured in blocks gets see-through water up to its
|
||||
* level.
|
||||
* <p>
|
||||
* Each frame is split into horizontal bands drawn side by side, every band drawing only the pixels in its rows. Block
|
||||
* faces are found once per terrain; each frame only works out which rows they cover, so bands skip the rest cheaply.
|
||||
*/
|
||||
final class NoiseTerrainRenderer {
|
||||
private static final double TOP_SHADE = 1D;
|
||||
private static final double NORTH_SOUTH_SHADE = 0.8D;
|
||||
private static final double EAST_WEST_SHADE = 0.6D;
|
||||
private static final double LEAST_SIDE_CONTRAST = 0.35D;
|
||||
// The graphics card draws with these too, so both look alike.
|
||||
static final int WATER_COLOR = 0x3F76E4;
|
||||
static final double WATER_OPACITY = 0.55D;
|
||||
static final double OUTLINE_SHADE = 0.72D;
|
||||
static final double OUTLINE_LIGHTENING = 0.25D;
|
||||
static final double DARK_LUMINANCE = 70D;
|
||||
static final double LEAST_OUTLINE_SPACING_PIXELS = 4D;
|
||||
static final double FULL_OUTLINE_SPACING_PIXELS = 12D;
|
||||
static final double OUTLINE_HALF_WIDTH_PIXELS = 0.5D;
|
||||
// A few slices per worker even out slices that take longer, like bands crossing the busiest part of the terrain.
|
||||
private static final int SLICES_PER_WORKER = 2;
|
||||
// Shared edges land exactly on pixel centres now and then; a little overlap keeps them from leaving cracks.
|
||||
private static final double EDGE_TOLERANCE = 1e-6D;
|
||||
private static final double EDGE_ON_DETERMINANT = 1e-9D;
|
||||
|
||||
private final TerrainLayer layer;
|
||||
private final NoiseTerrain terrain;
|
||||
private final OrbitProjection projection;
|
||||
private final int width;
|
||||
private final int[] pixels;
|
||||
private final double[] depths;
|
||||
private final FaceRows faceRows;
|
||||
private final int bandTop;
|
||||
private final int bandBottom;
|
||||
private final int[] runColors;
|
||||
|
||||
private NoiseTerrainRenderer(Frame frame, TerrainLayer layer, FaceRows faceRows, int bandTop, int bandBottom) {
|
||||
this.layer = layer;
|
||||
this.terrain = layer.terrain();
|
||||
this.projection = frame.projection();
|
||||
this.width = frame.width();
|
||||
this.pixels = frame.pixels();
|
||||
this.depths = frame.depths();
|
||||
this.faceRows = faceRows;
|
||||
this.bandTop = bandTop;
|
||||
this.bandBottom = bandBottom;
|
||||
this.runColors = new int[Math.max(terrain.columns(), terrain.rows())];
|
||||
}
|
||||
|
||||
static OrbitPicture render(NoiseTerrainScene scene, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
BufferedImage image = new BufferedImage(Math.max(1, width), Math.max(1, height), BufferedImage.TYPE_INT_RGB);
|
||||
Frame frame = Frame.of(image, scene, camera, coveredLeftWidth);
|
||||
List<FaceRows> faceRows = new ArrayList<>(scene.layers().size());
|
||||
for (HoledLayer layer : scene.layers()) {
|
||||
faceRows.add(FaceRows.of(frame, layer.layer(), layer.holeOfAllFillers(), scene));
|
||||
}
|
||||
inSlices(image.getHeight(), scene, (top, bottom) -> drawBand(frame, scene, faceRows, top, bottom - 1));
|
||||
return OrbitPicture.of(image, frame.depths(), frame.projection());
|
||||
}
|
||||
|
||||
// Every layer draws what it has in the band's rows; the water goes over all of them last.
|
||||
private static void drawBand(Frame frame, NoiseTerrainScene scene, List<FaceRows> faceRows, int bandTop, int bandBottom) {
|
||||
frame.clearRows(bandTop, bandBottom);
|
||||
for (int layer = 0; layer < scene.layers().size(); layer++) {
|
||||
new NoiseTerrainRenderer(frame, scene.layers().get(layer).layer(), faceRows.get(layer), bandTop, bandBottom).drawBlocks();
|
||||
}
|
||||
if (scene.hasWater() && !scene.layers().isEmpty()) {
|
||||
NoiseTerrainScene.Extent extent = scene.extent();
|
||||
new NoiseTerrainRenderer(frame, scene.layers().get(0).layer(), FaceRows.NONE, bandTop, bandBottom)
|
||||
.drawWater(extent.minX(), extent.minZ(), extent.sizeX(), extent.sizeZ(), BlockFaces.blockTop(scene.waterLevel()));
|
||||
}
|
||||
}
|
||||
|
||||
// Splits {@code total} items into a few slices per worker and works on them side by side.
|
||||
private static void inSlices(int total, NoiseTerrainScene scene, SliceWork work) {
|
||||
int sliceCount = Math.min(total, scene.workerCount() * SLICES_PER_WORKER);
|
||||
List<Callable<Void>> slices = new ArrayList<>(sliceCount);
|
||||
for (int slice = 0; slice < sliceCount; slice++) {
|
||||
int from = total * slice / sliceCount;
|
||||
int to = total * (slice + 1) / sliceCount;
|
||||
slices.add(() -> {
|
||||
work.doSlice(from, to);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
for (Future<Void> slice : scene.workers().invokeAll(slices)) {
|
||||
slice.get();
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (ExecutionException error) {
|
||||
throw new IllegalStateException("Drawing the terrain failed", error.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private void drawBlocks() {
|
||||
BlockFaces faces = layer.faces();
|
||||
for (int face = 0; face < faces.count(); face++) {
|
||||
if (faceRows.crosses(face, bandTop, bandBottom)) {
|
||||
drawFace(faces, face);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A face stretching over several columns is still coloured and outlined column by column.
|
||||
private void drawFace(BlockFaces faces, int face) {
|
||||
BlockFaces.Facing facing = faces.facing(face);
|
||||
double shade = faceShadeAt(facing, projection.pixelsPerBlock());
|
||||
int columns = faces.columns(face);
|
||||
for (int along = 0; along < columns; along++) {
|
||||
int column = faces.firstColumn(face) + (facing.runsAlongX() ? along : 0);
|
||||
int row = faces.firstRow(face) + (facing.runsAlongX() ? 0 : along);
|
||||
runColors[along] = shade(terrain.color(column, row), shade);
|
||||
}
|
||||
fillQuad(faces.originX(face), faces.originY(face), faces.originZ(face), faces.runX(face), 0D, faces.runZ(face),
|
||||
0D, faces.reachY(face), faces.reachZ(face), facing.normalX, facing.normalY, facing.normalZ,
|
||||
runColors, columns, faces.height(face));
|
||||
}
|
||||
|
||||
// How much a face's colour is darkened, with blocks drawn {@code pixelsPerBlock} wide.
|
||||
static double faceShadeAt(BlockFaces.Facing facing, double pixelsPerBlock) {
|
||||
return withContrast(faceShade(facing), blockSideContrast(pixelsPerBlock));
|
||||
}
|
||||
|
||||
private static double faceShade(BlockFaces.Facing facing) {
|
||||
return switch (facing) {
|
||||
case TOP -> TOP_SHADE;
|
||||
case NORTH, SOUTH -> NORTH_SOUTH_SHADE;
|
||||
case WEST, EAST -> EAST_WEST_SHADE;
|
||||
};
|
||||
}
|
||||
|
||||
// Blocks a few pixels wide show their sides as one-pixel slivers on every slope; at full contrast those read as noise.
|
||||
private static double blockSideContrast(double pixelsPerBlock) {
|
||||
return LEAST_SIDE_CONTRAST + (1D - LEAST_SIDE_CONTRAST) * outlineStrength(pixelsPerBlock);
|
||||
}
|
||||
|
||||
private static double withContrast(double faceShade, double contrast) {
|
||||
return 1D - (1D - faceShade) * contrast;
|
||||
}
|
||||
|
||||
// Water is drawn last, blended over whatever lies below its surface, without hiding what is behind it.
|
||||
private void drawWater(double x, double z, double sizeX, double sizeZ, double level) {
|
||||
Footprint water = new Footprint(x, level, z, sizeX, 0, 0, 0, 0, sizeZ);
|
||||
if (water.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (int y = water.top; y <= water.bottom; y++) {
|
||||
water.spanRow(y);
|
||||
for (int pixelX = water.rowStart; pixelX <= water.rowEnd; pixelX++) {
|
||||
double alongU = water.alongU(pixelX, y);
|
||||
double alongV = water.alongV(pixelX, y);
|
||||
int index = y * width + pixelX;
|
||||
if (isInside(alongU, alongV) && water.depthAt(alongU, alongV) > depths[index]) {
|
||||
pixels[index] = blend(WATER_COLOR, pixels[index], WATER_OPACITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the parallelogram from {@code origin} along both edges, with lines {@code 1 / units} of the way along
|
||||
* each edge; no lines when {@code units} is 0. Each of the {@code unitsU} blocks along the first edge takes its
|
||||
* colour from {@code colorsAlongU}; with no units, the whole face takes the first.
|
||||
*/
|
||||
private void fillQuad(double originX, double originY, double originZ, double uX, double uY, double uZ,
|
||||
double vX, double vY, double vZ, int normalX, int normalY, int normalZ,
|
||||
int[] colorsAlongU, int unitsU, int unitsV) {
|
||||
if (!projection.facesCamera(normalX, normalY, normalZ)) {
|
||||
return;
|
||||
}
|
||||
Footprint quad = new Footprint(originX, originY, originZ, uX, uY, uZ, vX, vY, vZ);
|
||||
if (quad.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
double pixelsPerUnitU = unitsU == 0 ? 0D : quad.lengthU() / unitsU;
|
||||
double pixelsPerUnitV = unitsV == 0 ? 0D : quad.lengthV() / unitsV;
|
||||
double outlineStrengthU = outlineStrength(pixelsPerUnitU);
|
||||
double outlineStrengthV = outlineStrength(pixelsPerUnitV);
|
||||
int lastUnitU = Math.max(0, unitsU - 1);
|
||||
for (int y = quad.top; y <= quad.bottom; y++) {
|
||||
quad.spanRow(y);
|
||||
for (int x = quad.rowStart; x <= quad.rowEnd; x++) {
|
||||
double alongU = quad.alongU(x, y);
|
||||
double alongV = quad.alongV(x, y);
|
||||
if (!isInside(alongU, alongV)) {
|
||||
continue;
|
||||
}
|
||||
int index = y * width + x;
|
||||
double depth = quad.depthAt(alongU, alongV);
|
||||
if (depth < depths[index]) {
|
||||
continue;
|
||||
}
|
||||
depths[index] = depth;
|
||||
int color = colorsAlongU[Math.max(0, Math.min(lastUnitU, (int) (alongU * unitsU)))];
|
||||
if (isOnLine(alongU, unitsU, pixelsPerUnitU)) {
|
||||
pixels[index] = blend(outlineOf(color), color, outlineStrengthU);
|
||||
} else if (isOnLine(alongV, unitsV, pixelsPerUnitV)) {
|
||||
pixels[index] = blend(outlineOf(color), color, outlineStrengthV);
|
||||
} else {
|
||||
pixels[index] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isInside(double alongU, double alongV) {
|
||||
return alongU >= -EDGE_TOLERANCE && alongU <= 1D + EDGE_TOLERANCE && alongV >= -EDGE_TOLERANCE && alongV <= 1D + EDGE_TOLERANCE;
|
||||
}
|
||||
|
||||
// Lines fade in as blocks grow on screen; dense lines at full strength would only speckle the face.
|
||||
private static double outlineStrength(double pixelsPerUnit) {
|
||||
double fadeIn = (pixelsPerUnit - LEAST_OUTLINE_SPACING_PIXELS) / (FULL_OUTLINE_SPACING_PIXELS - LEAST_OUTLINE_SPACING_PIXELS);
|
||||
return Math.max(0D, Math.min(1D, fadeIn));
|
||||
}
|
||||
|
||||
// Lines closer together than a few pixels would only grey the face out, so they are left out.
|
||||
private static boolean isOnLine(double along, int units, double pixelsPerUnit) {
|
||||
if (pixelsPerUnit < LEAST_OUTLINE_SPACING_PIXELS) {
|
||||
return false;
|
||||
}
|
||||
double unitsAlong = along * units;
|
||||
return Math.abs(unitsAlong - Math.rint(unitsAlong)) * pixelsPerUnit < OUTLINE_HALF_WIDTH_PIXELS;
|
||||
}
|
||||
|
||||
// Dark faces get lighter outlines, so the blocks of a black low still show.
|
||||
private static int outlineOf(int color) {
|
||||
double luminance = 0.3D * ((color >>> 16) & 0xFF) + 0.59D * ((color >>> 8) & 0xFF) + 0.11D * (color & 0xFF);
|
||||
return luminance < DARK_LUMINANCE ? blend(0xFFFFFF, color, OUTLINE_LIGHTENING) : shade(color, OUTLINE_SHADE);
|
||||
}
|
||||
|
||||
private static int shade(int color, double shade) {
|
||||
return BlockPreviewColors.shade(color, shade);
|
||||
}
|
||||
|
||||
private static int blend(int over, int under, double coverage) {
|
||||
return blendChannel(over, under, coverage, 16) << 16 | blendChannel(over, under, coverage, 8) << 8 | blendChannel(over, under, coverage, 0);
|
||||
}
|
||||
|
||||
private static int blendChannel(int over, int under, double coverage, int shift) {
|
||||
return (int) Math.round(((over >>> shift) & 0xFF) * coverage + ((under >>> shift) & 0xFF) * (1D - coverage));
|
||||
}
|
||||
|
||||
/**
|
||||
* What every band of one frame shares: the image's pixels and depths, and where the terrain lands on it.
|
||||
*/
|
||||
private record Frame(OrbitProjection projection, int width, int[] pixels, double[] depths) {
|
||||
static Frame of(BufferedImage image, NoiseTerrainScene scene, OrbitCamera camera, int coveredLeftWidth) {
|
||||
OrbitProjection projection = scene.projection(camera, image.getWidth(), image.getHeight(), coveredLeftWidth);
|
||||
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||
return new Frame(projection, image.getWidth(), pixels, new double[pixels.length]);
|
||||
}
|
||||
|
||||
void clearRows(int top, int bottom) {
|
||||
int from = top * width;
|
||||
int to = (bottom + 1) * width;
|
||||
Arrays.fill(pixels, from, to, StudioTheme.EDITOR_BACKGROUND.getRGB() & 0xFFFFFF);
|
||||
Arrays.fill(depths, from, to, Double.NEGATIVE_INFINITY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen rows each block face of a layer spans this frame, worked out once so every band skips the faces it
|
||||
* doesn't cross without projecting them again.
|
||||
*/
|
||||
private record FaceRows(int[] tops, int[] bottoms) {
|
||||
static final FaceRows NONE = new FaceRows(new int[0], new int[0]);
|
||||
|
||||
static FaceRows of(Frame frame, TerrainLayer layer, TerrainHole hole, NoiseTerrainScene scene) {
|
||||
BlockFaces faces = layer.faces();
|
||||
FaceRows rows = new FaceRows(new int[faces.count()], new int[faces.count()]);
|
||||
inSlices(faces.count(), scene, (from, to) -> rows.measure(faces, hole, frame.projection(), from, to));
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Faces looking away from the camera, or not showing with the hole, are never drawn, so they span no rows.
|
||||
private void measure(BlockFaces faces, TerrainHole hole, OrbitProjection projection, int fromFace, int toFace) {
|
||||
for (int face = fromFace; face < toFace; face++) {
|
||||
BlockFaces.Facing facing = faces.facing(face);
|
||||
if (!projection.facesCamera(facing.normalX, facing.normalY, facing.normalZ) || !hole.shows(faces, face)) {
|
||||
tops[face] = Integer.MAX_VALUE;
|
||||
bottoms[face] = Integer.MIN_VALUE;
|
||||
continue;
|
||||
}
|
||||
double originY = projection.screenY(faces.originX(face), faces.originY(face), faces.originZ(face));
|
||||
double runY = projection.screenStepY(faces.runX(face), 0D, faces.runZ(face));
|
||||
double reachY = projection.screenStepY(0D, faces.reachY(face), faces.reachZ(face));
|
||||
tops[face] = (int) Math.floor(originY + Math.min(0D, runY) + Math.min(0D, reachY));
|
||||
bottoms[face] = (int) Math.ceil(originY + Math.max(0D, runY) + Math.max(0D, reachY));
|
||||
}
|
||||
}
|
||||
|
||||
boolean crosses(int face, int top, int bottom) {
|
||||
return tops[face] <= bottom && bottoms[face] >= top;
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface SliceWork {
|
||||
void doSlice(int from, int to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a parallelogram, from an origin along two edges, lands in this band: the pixels around it, and how far
|
||||
* along each edge, and how near, any pixel inside it is.
|
||||
*/
|
||||
private final class Footprint {
|
||||
private final double originX;
|
||||
private final double originY;
|
||||
private final double originDepth;
|
||||
private final double edgeUX;
|
||||
private final double edgeUY;
|
||||
private final double edgeUDepth;
|
||||
private final double edgeVX;
|
||||
private final double edgeVY;
|
||||
private final double edgeVDepth;
|
||||
private final double determinant;
|
||||
private final int left;
|
||||
private final int right;
|
||||
private final int top;
|
||||
private final int bottom;
|
||||
private int rowStart;
|
||||
private int rowEnd;
|
||||
|
||||
private Footprint(double x, double y, double z, double uX, double uY, double uZ, double vX, double vY, double vZ) {
|
||||
originX = projection.screenX(x, y, z);
|
||||
originY = projection.screenY(x, y, z);
|
||||
originDepth = projection.depth(x, y, z);
|
||||
edgeUX = projection.screenStepX(uX, uZ);
|
||||
edgeUY = projection.screenStepY(uX, uY, uZ);
|
||||
edgeUDepth = projection.depthStep(uX, uY, uZ);
|
||||
edgeVX = projection.screenStepX(vX, vZ);
|
||||
edgeVY = projection.screenStepY(vX, vY, vZ);
|
||||
edgeVDepth = projection.depthStep(vX, vY, vZ);
|
||||
determinant = edgeUX * edgeVY - edgeUY * edgeVX;
|
||||
left = Math.max(0, (int) Math.floor(min(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
|
||||
right = Math.min(width - 1, (int) Math.ceil(max(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
|
||||
top = Math.max(bandTop, (int) Math.floor(min(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
|
||||
bottom = Math.min(bandBottom, (int) Math.ceil(max(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
|
||||
}
|
||||
|
||||
// Edge-on faces cover no pixels, and faces outside the band are another band's work.
|
||||
private boolean isEmpty() {
|
||||
return Math.abs(determinant) < EDGE_ON_DETERMINANT || top > bottom || left > right;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows {@link #rowStart} to {@link #rowEnd} to the pixels of row {@code y} the footprint can cover, so long
|
||||
* thin faces lying across the screen don't cost their whole bounding box. A pixel or so either side is kept;
|
||||
* the caller still tests every pixel.
|
||||
*/
|
||||
private void spanRow(int y) {
|
||||
double fromOriginY = y + 0.5D - originY;
|
||||
double slopeU = edgeVY / determinant;
|
||||
double startU = -fromOriginY * edgeVX / determinant;
|
||||
double slopeV = -edgeUY / determinant;
|
||||
double startV = fromOriginY * edgeUX / determinant;
|
||||
double[] range = {Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY};
|
||||
keepWhere(range, slopeU, startU, 0D, 1D);
|
||||
keepWhere(range, slopeV, startV, 0D, 1D);
|
||||
rowStart = Math.max(left, (int) Math.floor(range[0] + originX - 0.5D));
|
||||
rowEnd = Math.min(right, (int) Math.ceil(range[1] + originX - 0.5D));
|
||||
}
|
||||
|
||||
// Narrows the range of offsets from the origin to where {@code start + slope * offset} stays within the limits.
|
||||
private static void keepWhere(double[] range, double slope, double start, double lowest, double highest) {
|
||||
if (Math.abs(slope) < EDGE_ON_DETERMINANT) {
|
||||
if (start < lowest - EDGE_TOLERANCE || start > highest + EDGE_TOLERANCE) {
|
||||
range[0] = Double.POSITIVE_INFINITY;
|
||||
}
|
||||
return;
|
||||
}
|
||||
double atLowest = (lowest - EDGE_TOLERANCE - start) / slope;
|
||||
double atHighest = (highest + EDGE_TOLERANCE - start) / slope;
|
||||
range[0] = Math.max(range[0], Math.min(atLowest, atHighest));
|
||||
range[1] = Math.min(range[1], Math.max(atLowest, atHighest));
|
||||
}
|
||||
|
||||
private double alongU(int x, int y) {
|
||||
return ((x + 0.5D - originX) * edgeVY - (y + 0.5D - originY) * edgeVX) / determinant;
|
||||
}
|
||||
|
||||
private double alongV(int x, int y) {
|
||||
return ((y + 0.5D - originY) * edgeUX - (x + 0.5D - originX) * edgeUY) / determinant;
|
||||
}
|
||||
|
||||
private double depthAt(double alongU, double alongV) {
|
||||
return originDepth + alongU * edgeUDepth + alongV * edgeVDepth;
|
||||
}
|
||||
|
||||
private double lengthU() {
|
||||
return Math.hypot(edgeUX, edgeUY);
|
||||
}
|
||||
|
||||
private double lengthV() {
|
||||
return Math.hypot(edgeVX, edgeVY);
|
||||
}
|
||||
|
||||
private static double min(double a, double b, double c, double d) {
|
||||
return Math.min(Math.min(a, b), Math.min(c, d));
|
||||
}
|
||||
|
||||
private static double max(double a, double b, double c, double d) {
|
||||
return Math.max(Math.max(a, b), Math.max(c, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
/**
|
||||
* Endless noise terrain as the orbit viewport shows it: the tiles in sight as block columns, drawn by
|
||||
* {@code workerCount} of {@code workers} at once on machines without a graphics card. The camera's pivot is given
|
||||
* from {@code anchor}, and zoom sets pixels per block directly, so tiles coming and going never move the view.
|
||||
*
|
||||
* @param floorY where sides on the edge of the sampled ground end
|
||||
* @param waterLevel the height water fills up to, or NaN when the noise has no water
|
||||
*/
|
||||
record NoiseTerrainScene(List<HoledLayer> layers, Vec3 anchor, int floorY, int topY, double waterLevel,
|
||||
ExecutorService workers, int workerCount) implements OrbitScene {
|
||||
// Looks further down than the object viewer, so a landscape's shape reads before its sides.
|
||||
static final OrbitCamera START = new OrbitCamera(0.76D, 0.62D, 1D, Vec3.ZERO);
|
||||
private static final double VIEW_HEIGHT_BLOCKS_AT_ZOOM_ONE = 384D;
|
||||
// Looking level would see endless ground, and looking up only its underside.
|
||||
private static final double LOWEST_PITCH = 0.1D;
|
||||
private static final double HIGHEST_PITCH = 1.3D;
|
||||
private static final double TALLEST_VIEW_BLOCKS = 100_000D;
|
||||
private static final double SHORTEST_VIEW_BLOCKS = 24D;
|
||||
private static final OrbitLimits LIMITS = new OrbitLimits(LOWEST_PITCH, HIGHEST_PITCH,
|
||||
VIEW_HEIGHT_BLOCKS_AT_ZOOM_ONE / TALLEST_VIEW_BLOCKS, VIEW_HEIGHT_BLOCKS_AT_ZOOM_ONE / SHORTEST_VIEW_BLOCKS);
|
||||
|
||||
static OrbitProjection projectionFrom(Vec3 anchor, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
int covered = Math.max(0, Math.min(width - 1, coveredLeftWidth));
|
||||
return OrbitProjection.atScale(anchor, camera, height / VIEW_HEIGHT_BLOCKS_AT_ZOOM_ONE, width, height, covered);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return layers.stream().allMatch(layer -> layer.terrain().isEmpty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public OrbitCamera startingCamera() {
|
||||
return START;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OrbitLimits limits() {
|
||||
return LIMITS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OrbitPicture render(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
return renderOnCard(camera, width, height, coveredLeftWidth)
|
||||
.orElseGet(() -> NoiseTerrainRenderer.render(this, camera, width, height, coveredLeftWidth));
|
||||
}
|
||||
|
||||
private Optional<OrbitPicture> renderOnCard(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
if (!GpuLibrary.isLoaded()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return GpuTerrainRenderer.render(this, camera, width, height, coveredLeftWidth);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canRenderOnScreen() {
|
||||
return GpuLibrary.isLoaded() && GpuFrames.canDrawOnScreen();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<OrbitPicture> renderOnScreen(ScreenTarget screen, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
return GpuTerrainRenderer.renderOnScreen(this, screen, camera, width, height, coveredLeftWidth);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OrbitProjection projection(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
return projectionFrom(anchor, camera, width, height, coveredLeftWidth);
|
||||
}
|
||||
|
||||
boolean hasWater() {
|
||||
return Double.isFinite(waterLevel) && waterLevel > floorY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the layers lie, from the floor to the highest ground, so water can spread under all of them.
|
||||
*/
|
||||
Extent extent() {
|
||||
double minX = Double.POSITIVE_INFINITY;
|
||||
double minZ = Double.POSITIVE_INFINITY;
|
||||
double maxX = Double.NEGATIVE_INFINITY;
|
||||
double maxZ = Double.NEGATIVE_INFINITY;
|
||||
double lowest = floorY;
|
||||
double highest = topY;
|
||||
for (HoledLayer layer : layers) {
|
||||
NoiseTerrain terrain = layer.terrain();
|
||||
minX = Math.min(minX, terrain.originX());
|
||||
minZ = Math.min(minZ, terrain.originZ());
|
||||
maxX = Math.max(maxX, terrain.originX() + (double) terrain.sizeX());
|
||||
maxZ = Math.max(maxZ, terrain.originZ() + (double) terrain.sizeZ());
|
||||
if (!terrain.isEmpty()) {
|
||||
lowest = Math.min(lowest, terrain.baseY());
|
||||
highest = Math.max(highest, BlockFaces.blockTop(terrain.highest()));
|
||||
}
|
||||
}
|
||||
return new Extent(minX, lowest, minZ, maxX, highest, maxZ);
|
||||
}
|
||||
|
||||
record Extent(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
|
||||
double sizeX() {
|
||||
return maxX - minX;
|
||||
}
|
||||
|
||||
double sizeZ() {
|
||||
return maxZ - minZ;
|
||||
}
|
||||
|
||||
// The farthest any of it lies from a point, which the depth buffer has to reach.
|
||||
double farthestFrom(Vec3 point) {
|
||||
double x = Math.max(Math.abs(minX - point.x()), Math.abs(maxX - point.x()));
|
||||
double y = Math.max(Math.abs(minY - point.y()), Math.abs(maxY - point.y()));
|
||||
double z = Math.max(Math.abs(minZ - point.z()), Math.abs(maxZ - point.z()));
|
||||
return Math.sqrt(x * x + y * y + z * z);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* What the 3D noise view shows: endless terrain, sampled tile by tile wherever the camera looks. Whenever the camera
|
||||
* moves or another tile is sampled, what to draw is worked out again off the Swing thread.
|
||||
*/
|
||||
final class NoiseTerrainView {
|
||||
private final OrbitViewport viewport;
|
||||
private final ExecutorService renderWorkers;
|
||||
private final ExecutorService samplers;
|
||||
private final int workerCount;
|
||||
private final ExecutorService coverer = Executors.newSingleThreadExecutor(task -> {
|
||||
Thread thread = new Thread(task, "Iris Noise Terrain Cover");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
private final AtomicBoolean coverQueued = new AtomicBoolean();
|
||||
private volatile TerrainTiles tiles;
|
||||
private volatile Vec3 anchor;
|
||||
private volatile ViewState view;
|
||||
private volatile boolean hidden = true;
|
||||
private double centerX;
|
||||
private double centerZ;
|
||||
private boolean recentered = true;
|
||||
private TerrainCover.Result shown;
|
||||
private Runnable onProgress = () -> {
|
||||
};
|
||||
|
||||
NoiseTerrainView(OrbitViewport viewport, ExecutorService renderWorkers, ExecutorService samplers, int workerCount) {
|
||||
this.viewport = viewport;
|
||||
this.renderWorkers = renderWorkers;
|
||||
this.samplers = samplers;
|
||||
this.workerCount = workerCount;
|
||||
viewport.onViewChange(this::viewChanged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the ground around this point next, turning around it.
|
||||
*/
|
||||
void centerOn(double worldX, double worldZ) {
|
||||
centerX = worldX;
|
||||
centerZ = worldZ;
|
||||
recentered = true;
|
||||
if (viewport.isShowingScene()) {
|
||||
viewport.centerPivot();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples the terrain anew, as the camera looks at it, keeping the camera if the view already shows some. The ground
|
||||
* shown so far stays until its tiles are sampled anew.
|
||||
*/
|
||||
void show(TerrainSampling sampling) {
|
||||
if (recentered || anchor == null) {
|
||||
anchor = new Vec3(centerX, (sampling.floorY() + sampling.topY()) / 2D, centerZ);
|
||||
recentered = false;
|
||||
}
|
||||
TerrainTiles previous = tiles;
|
||||
Map<TerrainTile, TerrainLayer> shownBefore = previous == null ? Map.of() : previous.layersInUse();
|
||||
if (previous != null) {
|
||||
previous.close();
|
||||
}
|
||||
tiles = new TerrainTiles(sampling, samplers, workerCount, this::requestCover, shownBefore);
|
||||
hidden = false;
|
||||
viewChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes on sampling what the camera looks at after {@link #hide}, with the tiles sampled before.
|
||||
*/
|
||||
void resume() {
|
||||
if (tiles == null) {
|
||||
return;
|
||||
}
|
||||
hidden = false;
|
||||
viewChanged();
|
||||
}
|
||||
|
||||
// Nothing is sampled for a view no one sees.
|
||||
void hide() {
|
||||
hidden = true;
|
||||
TerrainTiles current = tiles;
|
||||
if (current != null) {
|
||||
current.sampleOnly(List.of(), Set.of());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param listener runs on the Swing thread whenever the detail wanted or the tiles still to sample change
|
||||
*/
|
||||
void onProgress(Runnable listener) {
|
||||
onProgress = listener;
|
||||
}
|
||||
|
||||
// How many blocks wide the columns are that the view wants, or 0 before anything was worked out.
|
||||
int detailStep() {
|
||||
return shown == null ? 0 : shown.detailStep();
|
||||
}
|
||||
|
||||
int pendingTiles() {
|
||||
return shown == null ? 0 : shown.pendingTiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* How the view looks at the ground for its newest camera, or null before it has any ground to look at.
|
||||
*/
|
||||
GroundSight groundSight() {
|
||||
TerrainTiles current = tiles;
|
||||
ViewState state = view;
|
||||
Vec3 at = anchor;
|
||||
if (current == null || state == null || at == null || state.width() < 1 || state.height() < 1) {
|
||||
return null;
|
||||
}
|
||||
OrbitProjection projection = NoiseTerrainScene.projectionFrom(at, state.camera(), state.width(), state.height(), state.coveredLeftWidth());
|
||||
TerrainSampling sampling = current.sampling();
|
||||
int left = Math.max(0, Math.min(state.width() - 1, state.coveredLeftWidth()));
|
||||
return new GroundSight(projection, left, state.width(), state.height(), sampling.floorY(), sampling.topY());
|
||||
}
|
||||
|
||||
void close() {
|
||||
coverer.shutdownNow();
|
||||
TerrainTiles current = tiles;
|
||||
if (current != null) {
|
||||
current.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Before the first scene, the viewport still holds whatever camera it had; the scene will start it afresh.
|
||||
private void viewChanged() {
|
||||
OrbitCamera camera = viewport.isShowingScene() ? viewport.camera() : NoiseTerrainScene.START;
|
||||
view = new ViewState(camera, viewport.getWidth(), viewport.getHeight(), viewport.coveredLeftWidth());
|
||||
requestCover();
|
||||
}
|
||||
|
||||
// Requests while one waits to start are one and the same: it will see the newest view and tiles anyway.
|
||||
private void requestCover() {
|
||||
if (coverQueued.getAndSet(true)) {
|
||||
return;
|
||||
}
|
||||
coverer.execute(() -> {
|
||||
coverQueued.set(false);
|
||||
coverSafely();
|
||||
});
|
||||
}
|
||||
|
||||
private void coverSafely() {
|
||||
try {
|
||||
cover();
|
||||
} catch (RuntimeException error) {
|
||||
IrisLogging.reportError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private void cover() {
|
||||
TerrainTiles current = tiles;
|
||||
ViewState state = view;
|
||||
Vec3 at = anchor;
|
||||
if (hidden || current == null || state == null || at == null || state.width() < 1 || state.height() < 1) {
|
||||
return;
|
||||
}
|
||||
OrbitProjection projection = NoiseTerrainScene.projectionFrom(at, state.camera(), state.width(), state.height(), state.coveredLeftWidth());
|
||||
TerrainCover.Result result = TerrainCover.of(current, projection, state.width(), state.height(), state.coveredLeftWidth());
|
||||
TerrainSampling sampling = current.sampling();
|
||||
NoiseTerrainScene scene = new NoiseTerrainScene(result.layers(), at, sampling.floorY(), sampling.topY(),
|
||||
sampling.waterLevel(), renderWorkers, workerCount);
|
||||
EventQueue.invokeLater(() -> showCovered(current, scene, result));
|
||||
}
|
||||
|
||||
// Until any ground is sampled, the view keeps what it showed before, or its loading animation.
|
||||
private void showCovered(TerrainTiles coveredTiles, NoiseTerrainScene scene, TerrainCover.Result result) {
|
||||
if (coveredTiles != tiles) {
|
||||
return;
|
||||
}
|
||||
shown = result;
|
||||
onProgress.run();
|
||||
if (scene.isEmpty() && result.pendingTiles() > 0) {
|
||||
return;
|
||||
}
|
||||
if (viewport.isShowingScene()) {
|
||||
viewport.replaceScene(scene);
|
||||
} else {
|
||||
viewport.show(scene);
|
||||
}
|
||||
}
|
||||
|
||||
private record ViewState(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -1,4 +1,22 @@
|
||||
package art.arcane.iris.studio.view;
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
record NoiseViewport(double centerX, double centerZ, double blocksPerPixel) {
|
||||
static final double MIN_BLOCKS_PER_PIXEL = 0.0001D;
|
||||
@@ -21,6 +39,14 @@ record NoiseViewport(double centerX, double centerZ, double blocksPerPixel) {
|
||||
return centerZ + ((screenZ - (height / 2D)) * blocksPerPixel);
|
||||
}
|
||||
|
||||
double screenX(double worldX, int width) {
|
||||
return ((worldX - centerX) / blocksPerPixel) + (width / 2D);
|
||||
}
|
||||
|
||||
double screenZ(double worldZ, int height) {
|
||||
return ((worldZ - centerZ) / blocksPerPixel) + (height / 2D);
|
||||
}
|
||||
|
||||
NoiseViewport panPixels(double deltaX, double deltaZ) {
|
||||
return new NoiseViewport(
|
||||
centerX - (deltaX * blocksPerPixel),
|
||||
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.ToolTipManager;
|
||||
import javax.swing.event.DocumentEvent;
|
||||
import javax.swing.event.DocumentListener;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
import javax.swing.tree.DefaultTreeCellRenderer;
|
||||
import javax.swing.tree.DefaultTreeModel;
|
||||
import javax.swing.tree.TreePath;
|
||||
import javax.swing.tree.TreeSelectionModel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Every object in the pack as a folder tree, with a search that matches every word typed anywhere in an object's path.
|
||||
*/
|
||||
final class ObjectBrowser extends JPanel {
|
||||
private static final long serialVersionUID = 6795434561028735214L;
|
||||
private static final int ROW_HEIGHT = 24;
|
||||
|
||||
private final Consumer<String> onObjectChosen;
|
||||
private final JTextField searchField = new JTextField();
|
||||
private final DefaultTreeModel treeModel = new DefaultTreeModel(new DefaultMutableTreeNode());
|
||||
private final JTree tree = new JTree(treeModel);
|
||||
private final JLabel countLabel = new JLabel();
|
||||
private final String noun;
|
||||
private List<String> objectKeys = List.of();
|
||||
private String chosenKey;
|
||||
private boolean updatingSelection;
|
||||
|
||||
/**
|
||||
* @param noun what one entry is called in the browser's texts, such as "object"
|
||||
*/
|
||||
ObjectBrowser(String noun, Consumer<String> onObjectChosen) {
|
||||
super(new BorderLayout());
|
||||
this.noun = noun;
|
||||
this.onObjectChosen = onObjectChosen;
|
||||
countLabel.setText("Loading " + noun + "s...");
|
||||
StudioToolbar.makeSearchField(searchField, "Search " + noun + "s...");
|
||||
searchField.getDocument().addDocumentListener(new DocumentListener() {
|
||||
@Override
|
||||
public void insertUpdate(DocumentEvent event) {
|
||||
rebuildTree();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUpdate(DocumentEvent event) {
|
||||
rebuildTree();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changedUpdate(DocumentEvent event) {
|
||||
rebuildTree();
|
||||
}
|
||||
});
|
||||
tree.setRootVisible(false);
|
||||
tree.setShowsRootHandles(true);
|
||||
tree.setRowHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
|
||||
tree.setCellRenderer(new EntryRenderer());
|
||||
ToolTipManager.sharedInstance().registerComponent(tree);
|
||||
tree.addTreeSelectionListener(event -> {
|
||||
if (!updatingSelection && nodeAt(event.getPath()) instanceof Entry entry && entry.isObject()) {
|
||||
chosenKey = entry.key();
|
||||
onObjectChosen.accept(entry.key());
|
||||
}
|
||||
});
|
||||
countLabel.setFont(StudioTheme.smallFont());
|
||||
countLabel.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
|
||||
add(StickyFolderHeaders.around(tree, StudioToolbar.scroll(tree)), BorderLayout.CENTER);
|
||||
add(StudioToolbar.padded(countLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
void showObjects(List<String> keys) {
|
||||
objectKeys = keys.stream().sorted(Comparator.comparing(key -> key.toLowerCase(Locale.ROOT))).toList();
|
||||
countLabel.setText(objectKeys.size() == 1 ? "1 " + noun : objectKeys.size() + " " + noun + "s");
|
||||
rebuildTree();
|
||||
}
|
||||
|
||||
void showError(String text) {
|
||||
countLabel.setText(text);
|
||||
}
|
||||
|
||||
String firstObject() {
|
||||
return objectKeys.isEmpty() ? null : objectKeys.get(0);
|
||||
}
|
||||
|
||||
private void rebuildTree() {
|
||||
String[] words = searchField.getText().trim().toLowerCase(Locale.ROOT).split("\\s+");
|
||||
boolean searching = !searchField.getText().isBlank();
|
||||
DefaultMutableTreeNode root = searching ? searchResults(words) : folderTree();
|
||||
treeModel.setRoot(root);
|
||||
if (searching) {
|
||||
expandAll();
|
||||
}
|
||||
selectChosen();
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode searchResults(String[] words) {
|
||||
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
||||
for (String key : objectKeys) {
|
||||
if (matchesEveryWord(key, words)) {
|
||||
root.add(new DefaultMutableTreeNode(Entry.object(key, key)));
|
||||
}
|
||||
}
|
||||
if (root.getChildCount() == 0) {
|
||||
root.add(new DefaultMutableTreeNode(Entry.note("No matching " + noun + "s")));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private static boolean matchesEveryWord(String key, String[] words) {
|
||||
String lowerKey = key.toLowerCase(Locale.ROOT);
|
||||
for (String word : words) {
|
||||
if (!lowerKey.contains(word)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Folders come before the objects beside them, like a file browser.
|
||||
private DefaultMutableTreeNode folderTree() {
|
||||
Folder top = new Folder();
|
||||
for (String key : objectKeys) {
|
||||
Folder folder = top;
|
||||
String[] parts = key.split("/");
|
||||
for (int index = 0; index < parts.length - 1; index++) {
|
||||
folder = folder.subfolders.computeIfAbsent(parts[index], name -> new Folder());
|
||||
}
|
||||
folder.objects.add(key);
|
||||
}
|
||||
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
||||
addFolderContents(root, top, "");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static void addFolderContents(DefaultMutableTreeNode node, Folder folder, String path) {
|
||||
for (Map.Entry<String, Folder> subfolder : folder.subfolders.entrySet()) {
|
||||
String subfolderPath = path + subfolder.getKey() + "/";
|
||||
DefaultMutableTreeNode child = new DefaultMutableTreeNode(Entry.folder(subfolder.getKey(), subfolderPath));
|
||||
addFolderContents(child, subfolder.getValue(), subfolderPath);
|
||||
node.add(child);
|
||||
}
|
||||
for (String key : folder.objects) {
|
||||
node.add(new DefaultMutableTreeNode(Entry.object(key.substring(key.lastIndexOf('/') + 1), key)));
|
||||
}
|
||||
}
|
||||
|
||||
void select(String key) {
|
||||
chosenKey = key;
|
||||
selectChosen();
|
||||
}
|
||||
|
||||
private void selectChosen() {
|
||||
DefaultMutableTreeNode node = chosenKey == null ? null : findObjectNode((DefaultMutableTreeNode) treeModel.getRoot());
|
||||
updatingSelection = true;
|
||||
try {
|
||||
if (node == null) {
|
||||
tree.clearSelection();
|
||||
return;
|
||||
}
|
||||
TreePath path = new TreePath(node.getPath());
|
||||
tree.setSelectionPath(path);
|
||||
tree.scrollPathToVisible(path);
|
||||
} finally {
|
||||
updatingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode findObjectNode(DefaultMutableTreeNode node) {
|
||||
if (node.getUserObject() instanceof Entry entry && entry.isObject() && entry.key().equals(chosenKey)) {
|
||||
return node;
|
||||
}
|
||||
for (int index = 0; index < node.getChildCount(); index++) {
|
||||
DefaultMutableTreeNode found = findObjectNode((DefaultMutableTreeNode) node.getChildAt(index));
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void expandAll() {
|
||||
for (int row = 0; row < tree.getRowCount(); row++) {
|
||||
tree.expandRow(row);
|
||||
}
|
||||
}
|
||||
|
||||
private static Object nodeAt(TreePath path) {
|
||||
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
}
|
||||
|
||||
private static final class Folder {
|
||||
private final Map<String, Folder> subfolders = new LinkedHashMap<>();
|
||||
private final List<String> objects = new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the tree: an object with its key, a folder with its path, or a note without either.
|
||||
*/
|
||||
private record Entry(String label, String key, String folderPath) {
|
||||
static Entry object(String label, String key) {
|
||||
return new Entry(label, key, null);
|
||||
}
|
||||
|
||||
static Entry folder(String label, String path) {
|
||||
return new Entry(label, null, path);
|
||||
}
|
||||
|
||||
static Entry note(String text) {
|
||||
return new Entry(text, null, null);
|
||||
}
|
||||
|
||||
boolean isObject() {
|
||||
return key != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class EntryRenderer extends DefaultTreeCellRenderer {
|
||||
private static final long serialVersionUID = -4316652201793418740L;
|
||||
|
||||
private EntryRenderer() {
|
||||
setLeafIcon(null);
|
||||
setOpenIcon(null);
|
||||
setClosedIcon(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded,
|
||||
boolean leaf, int row, boolean focus) {
|
||||
Object userObject = ((DefaultMutableTreeNode) value).getUserObject();
|
||||
super.getTreeCellRendererComponent(tree, userObject == null ? "" : userObject.toString(), selected, expanded, leaf, row, false);
|
||||
boolean isNote = userObject instanceof Entry entry && !entry.isObject() && entry.folderPath() == null;
|
||||
setFont(StudioTheme.font());
|
||||
setForeground(isNote ? StudioTheme.MUTED_TEXT : StudioTheme.TEXT);
|
||||
setToolTipText(userObject instanceof Entry entry ? entry.isObject() ? entry.key() : entry.folderPath() : null);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.BlockTextures.BlockTexture;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.structure.object.IrisObject;
|
||||
import art.arcane.iris.generation.geometry.IrisBlockVector;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JPanel;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* The map window's Objects and Procedural views: everything its source offers in a folder tree, the chosen one in 3D,
|
||||
* and what it is made of. A procedural object's variants can also be compared side by side, baked from any seed.
|
||||
* Inspired by the Procedural Objects workbench.
|
||||
*/
|
||||
final class ObjectExplorer {
|
||||
private static final String TINT_BIOME = "minecraft:plains";
|
||||
private static final int UNKNOWN_BLOCK_COLOR = 0x8A8A8A;
|
||||
private static final String CAMERA_HINT = "Drag to orbit · Right-drag to pan · Scroll to zoom · Page Up/Down cuts layers · R resets";
|
||||
private static final String ONE_VARIANT_CARD = "one variant";
|
||||
private static final String ALL_VARIANTS_CARD = "all variants";
|
||||
|
||||
private final Engine startEngine;
|
||||
private final UUID worldId;
|
||||
private final ObjectSource source;
|
||||
private final ObjectBrowser browser;
|
||||
private final OrbitViewport viewport = new OrbitViewport("Iris Object Viewer", "Choose an object");
|
||||
private final ObjectLayerCut layerCut = new ObjectLayerCut(shownLayers -> showLayerCut());
|
||||
private final ObjectVariantPicker variantPicker = new ObjectVariantPicker(this::showVariant, this::showSeed, this::toggleComparing);
|
||||
private final ObjectVariantGallery gallery = new ObjectVariantGallery(this::openVariant, this::redrawThumbnails);
|
||||
private final CardLayout viewCards = new CardLayout();
|
||||
private final JPanel views = new JPanel(viewCards);
|
||||
private final JComponent viewWithOverlays;
|
||||
private final ObjectOverviewPanel overview;
|
||||
private final StudioStatusBar statusBar = new StudioStatusBar();
|
||||
private final LatestMeasurement<BakeTiming> bakeTimer = new LatestMeasurement<>("Iris Bake Timer");
|
||||
private final ExecutorService loader = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Object Loader");
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
});
|
||||
private final ExecutorService thumbnailPainter = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Variant Thumbnails");
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
});
|
||||
private final AtomicLong objectRevision = new AtomicLong();
|
||||
private final AtomicLong variantListRevision = new AtomicLong();
|
||||
private final AtomicLong meshRevision = new AtomicLong();
|
||||
private final Map<String, BlockModel> models = new ConcurrentHashMap<>();
|
||||
private final Runnable hotloadHook = this::reloadPack;
|
||||
private volatile BlockTextures textures;
|
||||
private volatile BiomeTintColors tintColors;
|
||||
private String shownKey;
|
||||
private List<IrisObject> shownVariants = List.of();
|
||||
private int shownVariantIndex;
|
||||
private OptionalLong packSeed = OptionalLong.empty();
|
||||
private OptionalLong shownSeed = OptionalLong.empty();
|
||||
private boolean comparingVariants;
|
||||
private long thumbnailsDrawnForRevision = -1;
|
||||
private LoadedObject shown;
|
||||
private ObjectMesh wholeMesh;
|
||||
private Set<String> seeThroughTypes = Set.of();
|
||||
|
||||
ObjectExplorer(Engine engine, UUID worldId, ObjectSource source) {
|
||||
this.startEngine = engine;
|
||||
this.worldId = worldId;
|
||||
this.source = source;
|
||||
this.browser = new ObjectBrowser(source.noun(), this::showObject);
|
||||
this.overview = new ObjectOverviewPanel(this::showSeeThrough, source.isBaked());
|
||||
views.add(layerCut.floatingOver(viewport), ONE_VARIANT_CARD);
|
||||
views.add(gallery, ALL_VARIANTS_CARD);
|
||||
this.viewWithOverlays = variantPicker.floatingOver(views);
|
||||
MapHost.interruptOnPluginShutdown(loader);
|
||||
MapHost.interruptOnPluginShutdown(thumbnailPainter);
|
||||
MapHost.registerHotloadHook(hotloadHook);
|
||||
statusBar.showPosition(CAMERA_HINT);
|
||||
viewport.bindKey(KeyEvent.VK_PAGE_UP, "show-layer-above", () -> layerCut.stepBy(1));
|
||||
viewport.bindKey(KeyEvent.VK_PAGE_DOWN, "hide-top-layer", () -> layerCut.stepBy(-1));
|
||||
viewport.bindKey(KeyEvent.VK_OPEN_BRACKET, "previous-variant", () -> variantPicker.stepBy(-1));
|
||||
viewport.bindKey(KeyEvent.VK_CLOSE_BRACKET, "next-variant", () -> variantPicker.stepBy(1));
|
||||
viewport.bindKey(KeyEvent.VK_G, "compare-variants", this::toggleComparing);
|
||||
gallery.bindKey(KeyEvent.VK_OPEN_BRACKET, "previous-variant", () -> variantPicker.stepBy(-1));
|
||||
gallery.bindKey(KeyEvent.VK_CLOSE_BRACKET, "next-variant", () -> variantPicker.stepBy(1));
|
||||
gallery.bindKey(KeyEvent.VK_G, "show-one-variant", this::toggleComparing);
|
||||
gallery.bindKey(KeyEvent.VK_ESCAPE, "close-comparison", this::toggleComparing);
|
||||
}
|
||||
|
||||
JComponent browser() {
|
||||
return browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 3D view with the slider that cuts away its top layers, or every variant side by side, under the bar that
|
||||
* steps through variants and seeds.
|
||||
*/
|
||||
JComponent view() {
|
||||
return viewWithOverlays;
|
||||
}
|
||||
|
||||
void setCoveredLeftWidth(int width) {
|
||||
viewport.setCoveredLeftWidth(width);
|
||||
gallery.setCoveredLeftWidth(width);
|
||||
variantPicker.setCoveredLeftWidth(width);
|
||||
}
|
||||
|
||||
JComponent overview() {
|
||||
return overview;
|
||||
}
|
||||
|
||||
StudioStatusBar statusBar() {
|
||||
return statusBar;
|
||||
}
|
||||
|
||||
void resetCamera() {
|
||||
viewport.resetCamera();
|
||||
}
|
||||
|
||||
// Textures load first, since the first object would otherwise be drawn in flat colours.
|
||||
void open() {
|
||||
viewport.showLoading("Loading Minecraft textures");
|
||||
loader.execute(() -> {
|
||||
loadTextures();
|
||||
List<String> keys = objectKeys();
|
||||
EventQueue.invokeLater(() -> showCatalog(keys));
|
||||
});
|
||||
}
|
||||
|
||||
void close() {
|
||||
MapHost.unregisterHotloadHook(hotloadHook);
|
||||
objectRevision.incrementAndGet();
|
||||
loader.shutdownNow();
|
||||
thumbnailPainter.shutdownNow();
|
||||
bakeTimer.close();
|
||||
viewport.close();
|
||||
}
|
||||
|
||||
private void loadTextures() {
|
||||
try {
|
||||
BlockTextures loaded = BlockTextures.shared().join();
|
||||
tintColors = loaded.biomeColors().vanilla(TINT_BIOME);
|
||||
textures = loaded;
|
||||
} catch (RuntimeException unavailable) {
|
||||
EventQueue.invokeLater(() -> statusBar.showError("Minecraft textures are unavailable, objects use plain colours"));
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> objectKeys() {
|
||||
return source.keys(engine().getData());
|
||||
}
|
||||
|
||||
private void showCatalog(List<String> keys) {
|
||||
statusBar.showActivity("");
|
||||
browser.showObjects(keys);
|
||||
String key = shownKey != null && keys.contains(shownKey) ? shownKey : browser.firstObject();
|
||||
if (key == null) {
|
||||
viewport.showEmptyState(source.emptyState());
|
||||
bakeTimer.discardPending();
|
||||
overview.clear();
|
||||
layerCut.showHeight(1);
|
||||
if (comparingVariants) {
|
||||
toggleComparing();
|
||||
}
|
||||
showVariantList(List.of(), OptionalLong.empty(), OptionalLong.empty(), 0);
|
||||
return;
|
||||
}
|
||||
browser.select(key);
|
||||
showObject(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the first variant of the entry, baked from the pack's seed. The pack reloading the shown entry keeps the
|
||||
* variant and the seed being looked at, unless the pack itself now sets another seed.
|
||||
*/
|
||||
private void showObject(String key) {
|
||||
boolean reloadingShownObject = key.equals(shownKey);
|
||||
OptionalLong previousPackSeed = packSeed;
|
||||
OptionalLong exploredSeed = shownSeed;
|
||||
int variantIndex = reloadingShownObject ? shownVariantIndex : 0;
|
||||
shownKey = key;
|
||||
if (!reloadingShownObject) {
|
||||
variantPicker.forgetTriedSeeds();
|
||||
showVariantList(List.of(), OptionalLong.empty(), OptionalLong.empty(), 0);
|
||||
}
|
||||
long revision = startBaking(key);
|
||||
loader.execute(() -> {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
OptionalLong pack = source.seed(key);
|
||||
OptionalLong seed = reloadingShownObject && pack.equals(previousPackSeed) ? exploredSeed : pack;
|
||||
loadVariants(revision, key, pack, seed, variantIndex);
|
||||
});
|
||||
}
|
||||
|
||||
// The seed shows at once, so stepping again continues from it even while this one bakes or fails.
|
||||
private void showSeed(long seed) {
|
||||
String key = shownKey;
|
||||
OptionalLong pack = packSeed;
|
||||
int variantIndex = shownVariantIndex;
|
||||
shownSeed = OptionalLong.of(seed);
|
||||
variantPicker.showSeed(shownSeed, pack);
|
||||
long revision = startBaking(key + " with seed " + seed);
|
||||
loader.execute(() -> {
|
||||
if (revision == objectRevision.get()) {
|
||||
loadVariants(revision, key, pack, OptionalLong.of(seed), variantIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// On the loader thread.
|
||||
private void loadVariants(long revision, String key, OptionalLong pack, OptionalLong seed, int preferredIndex) {
|
||||
try {
|
||||
IrisData data = engine().getData();
|
||||
List<IrisObject> variants = seed.isPresent() ? source.variants(data, key, seed.getAsLong()) : source.variants(data, key);
|
||||
if (variants.isEmpty()) {
|
||||
throw new IllegalStateException("Nothing could be loaded for " + key);
|
||||
}
|
||||
int index = preferredIndex < variants.size() ? preferredIndex : 0;
|
||||
LoadedObject loaded = load(variants.get(index));
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
showVariantList(variants, pack, seed, index);
|
||||
showLoaded(revision, loaded);
|
||||
timeBake(data, key, seed);
|
||||
});
|
||||
} catch (RuntimeException error) {
|
||||
EventQueue.invokeLater(() -> showFailure(revision, key, error));
|
||||
}
|
||||
}
|
||||
|
||||
// Off the loader thread, so stepping through variants and seeds never waits on the timing.
|
||||
private void timeBake(IrisData data, String key, OptionalLong seed) {
|
||||
if (!source.isBaked() || seed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long bakedSeed = seed.getAsLong();
|
||||
overview.showTimingBake();
|
||||
bakeTimer.measureUntilSuperseded(stillWanted -> BakeTiming.measure(() -> source.timeBake(data, key, bakedSeed), stillWanted),
|
||||
overview::showBakeTiming);
|
||||
}
|
||||
|
||||
private void showVariantList(List<IrisObject> variants, OptionalLong pack, OptionalLong seed, int index) {
|
||||
shownVariants = variants;
|
||||
shownVariantIndex = index;
|
||||
packSeed = pack;
|
||||
shownSeed = seed;
|
||||
variantListRevision.incrementAndGet();
|
||||
variantPicker.showVariants(variants.size(), index);
|
||||
variantPicker.showSeed(seed, pack);
|
||||
if (!comparingVariants) {
|
||||
return;
|
||||
}
|
||||
if (variants.isEmpty()) {
|
||||
gallery.showLoading();
|
||||
} else if (variants.size() == 1) {
|
||||
toggleComparing();
|
||||
} else {
|
||||
drawThumbnails();
|
||||
}
|
||||
}
|
||||
|
||||
private void showVariant(int index) {
|
||||
if (index >= shownVariants.size()) {
|
||||
return;
|
||||
}
|
||||
shownVariantIndex = index;
|
||||
gallery.select(index);
|
||||
IrisObject variant = shownVariants.get(index);
|
||||
long revision = startLoading(variant.getLoadKey());
|
||||
loader.execute(() -> {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
LoadedObject loaded = load(variant);
|
||||
EventQueue.invokeLater(() -> showLoaded(revision, loaded));
|
||||
});
|
||||
}
|
||||
|
||||
private void openVariant(int index) {
|
||||
variantPicker.showVariants(shownVariants.size(), index);
|
||||
showVariant(index);
|
||||
toggleComparing();
|
||||
}
|
||||
|
||||
private void toggleComparing() {
|
||||
if (!comparingVariants && shownVariants.size() < 2) {
|
||||
return;
|
||||
}
|
||||
comparingVariants = !comparingVariants;
|
||||
variantPicker.showComparing(comparingVariants);
|
||||
viewCards.show(views, comparingVariants ? ALL_VARIANTS_CARD : ONE_VARIANT_CARD);
|
||||
if (comparingVariants) {
|
||||
drawThumbnails();
|
||||
gallery.requestFocusInWindow();
|
||||
} else {
|
||||
viewport.requestFocusInWindow();
|
||||
}
|
||||
}
|
||||
|
||||
private void redrawThumbnails() {
|
||||
thumbnailsDrawnForRevision = -1;
|
||||
if (comparingVariants) {
|
||||
drawThumbnails();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the side-by-side view with every shown variant, all drawn at the scale that fits the largest, so their
|
||||
* sizes compare. Thumbnails arrive one at a time on their own thread, leaving the loader free for the 3D view.
|
||||
*/
|
||||
private void drawThumbnails() {
|
||||
long revision = variantListRevision.get();
|
||||
if (revision == thumbnailsDrawnForRevision) {
|
||||
return;
|
||||
}
|
||||
thumbnailsDrawnForRevision = revision;
|
||||
List<IrisObject> variants = shownVariants;
|
||||
gallery.showVariants(galleryHeading(), variants.size(), shownVariantIndex);
|
||||
int size = gallery.thumbnailSize();
|
||||
thumbnailPainter.execute(() -> {
|
||||
List<ObjectMesh> meshes = new ArrayList<>();
|
||||
for (IrisObject variant : variants) {
|
||||
if (revision != variantListRevision.get()) {
|
||||
return;
|
||||
}
|
||||
meshes.add(ObjectMesh.build(visibleBlocks(variant), this::modelOf));
|
||||
}
|
||||
ObjectMesh.Bounds sizeToFit = largestSize(meshes);
|
||||
for (int index = 0; index < meshes.size() && revision == variantListRevision.get(); index++) {
|
||||
BufferedImage thumbnail = ObjectRenderer.renderThumbnail(meshes.get(index), sizeToFit, size, size);
|
||||
int shownIndex = index;
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (revision == variantListRevision.get()) {
|
||||
gallery.showThumbnail(shownIndex, thumbnail);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String galleryHeading() {
|
||||
return String.format(Locale.ROOT, "%s · %d variants%s", shownKey, shownVariants.size(), seedNote());
|
||||
}
|
||||
|
||||
private static ObjectMesh.Bounds largestSize(List<ObjectMesh> meshes) {
|
||||
int sizeX = 1;
|
||||
int sizeY = 1;
|
||||
int sizeZ = 1;
|
||||
for (ObjectMesh mesh : meshes) {
|
||||
sizeX = Math.max(sizeX, mesh.bounds().sizeX());
|
||||
sizeY = Math.max(sizeY, mesh.bounds().sizeY());
|
||||
sizeZ = Math.max(sizeZ, mesh.bounds().sizeZ());
|
||||
}
|
||||
return new ObjectMesh.Bounds(0, 0, 0, sizeX, sizeY, sizeZ);
|
||||
}
|
||||
|
||||
// A new list of variants is on its way, so every thumbnail is outdated, unlike when stepping to another variant.
|
||||
private long startBaking(String key) {
|
||||
if (comparingVariants) {
|
||||
gallery.showLoading();
|
||||
}
|
||||
return startLoading(key);
|
||||
}
|
||||
|
||||
private long startLoading(String key) {
|
||||
shown = null;
|
||||
wholeMesh = null;
|
||||
meshRevision.incrementAndGet();
|
||||
viewport.showLoading("Loading " + key);
|
||||
return objectRevision.incrementAndGet();
|
||||
}
|
||||
|
||||
private LoadedObject load(IrisObject object) {
|
||||
Map<IrisBlockVector, NativeBlockState> blocks = visibleBlocks(object);
|
||||
ObjectMesh.Bounds extent = ObjectMesh.Bounds.of(blocks.keySet());
|
||||
ObjectMesh.Bounds bounds = source.groundY(object).stream().mapToObj(extent::standingOn).findFirst().orElse(extent);
|
||||
ObjectMesh mesh = ObjectMesh.build(blocks, this::modelOf, bounds, block -> false);
|
||||
ObjectSummary summary = ObjectSummary.of(object.getLoadKey(), mesh.bounds(), blocks.values(), this::iconOf);
|
||||
return new LoadedObject(blocks, mesh, summary);
|
||||
}
|
||||
|
||||
private static Map<IrisBlockVector, NativeBlockState> visibleBlocks(IrisObject object) {
|
||||
Map<IrisBlockVector, NativeBlockState> blocks = new HashMap<>();
|
||||
object.getBlocks().forEach((position, block) -> {
|
||||
if (!block.isAir()) {
|
||||
blocks.put(position, block);
|
||||
}
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
|
||||
private void showLoaded(long revision, LoadedObject loaded) {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
shown = loaded;
|
||||
wholeMesh = loaded.mesh();
|
||||
seeThroughTypes = Set.of();
|
||||
statusBar.showActivity("");
|
||||
viewport.show(loaded.mesh());
|
||||
overview.show(loaded.summary());
|
||||
layerCut.showHeight(loaded.mesh().bounds().sizeY());
|
||||
showMeshSummary(loaded.mesh());
|
||||
}
|
||||
|
||||
private void showMeshSummary(ObjectMesh mesh) {
|
||||
ObjectSummary summary = shown.summary();
|
||||
String cut = layerCut.isCutting()
|
||||
? String.format(Locale.ROOT, " · showing layers 1-%d of %d", layerCut.shownLayerCount(), layerCut.layerCount())
|
||||
: "";
|
||||
statusBar.showSummary(String.format(Locale.ROOT, "%s%s · %,d blocks · %,d faces drawn%s",
|
||||
summary.key(), seedNote(), summary.blockCount(), mesh.faceCount(), cut));
|
||||
}
|
||||
|
||||
// A seed being tried is not in the pack until its "seed" field says so.
|
||||
private String seedNote() {
|
||||
if (shownSeed.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String note = " · seed " + shownSeed.getAsLong();
|
||||
return shownSeed.equals(packSeed) ? note : note + " (the pack has " + packSeed.getAsLong() + ")";
|
||||
}
|
||||
|
||||
private void showSeeThrough(Set<String> blockTypes) {
|
||||
seeThroughTypes = blockTypes;
|
||||
rebuildWholeMesh();
|
||||
}
|
||||
|
||||
// Blocks behind see-through ones get their hidden faces back, so the mesh is built again.
|
||||
private void rebuildWholeMesh() {
|
||||
LoadedObject object = shown;
|
||||
if (object == null) {
|
||||
return;
|
||||
}
|
||||
long revision = meshRevision.incrementAndGet();
|
||||
Set<String> seeThrough = seeThroughTypes;
|
||||
loader.execute(() -> {
|
||||
if (revision != meshRevision.get()) {
|
||||
return;
|
||||
}
|
||||
ObjectMesh mesh = seeThrough.isEmpty()
|
||||
? object.mesh()
|
||||
: ObjectMesh.build(object.blocks(), this::modelOf, object.mesh().bounds(),
|
||||
block -> seeThrough.contains(BlockNames.typeOf(block)));
|
||||
EventQueue.invokeLater(() -> {
|
||||
if (revision == meshRevision.get() && shown == object) {
|
||||
wholeMesh = mesh;
|
||||
showLayerCut();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void showLayerCut() {
|
||||
if (wholeMesh == null) {
|
||||
return;
|
||||
}
|
||||
int highestShownY = wholeMesh.bounds().minY() + layerCut.shownLayerCount() - 1;
|
||||
ObjectMesh mesh = layerCut.isCutting() ? wholeMesh.cutAbove(highestShownY) : wholeMesh;
|
||||
viewport.replaceScene(mesh);
|
||||
showMeshSummary(mesh);
|
||||
}
|
||||
|
||||
private void showFailure(long revision, String key, Throwable error) {
|
||||
if (revision != objectRevision.get()) {
|
||||
return;
|
||||
}
|
||||
statusBar.showActivity("");
|
||||
viewport.showMessage("Could not load " + key);
|
||||
gallery.showMessage("Could not load " + key);
|
||||
thumbnailsDrawnForRevision = -1;
|
||||
bakeTimer.discardPending();
|
||||
overview.clear();
|
||||
layerCut.showHeight(1);
|
||||
statusBar.showError(error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage());
|
||||
}
|
||||
|
||||
// The pack's files may have changed, so the list and the shown object are read again.
|
||||
private void reloadPack() {
|
||||
loader.execute(() -> {
|
||||
List<String> keys = objectKeys();
|
||||
EventQueue.invokeLater(() -> showCatalog(keys));
|
||||
});
|
||||
}
|
||||
|
||||
private Engine engine() {
|
||||
Engine current = MapHost.findEngine(worldId);
|
||||
return current != null && !current.isClosed() ? current : startEngine;
|
||||
}
|
||||
|
||||
private BlockModel modelOf(NativeBlockState block) {
|
||||
return models.computeIfAbsent(block.key(), state -> {
|
||||
BlockTextures available = textures;
|
||||
if (available == null) {
|
||||
return BlockModel.solidColor(UNKNOWN_BLOCK_COLOR);
|
||||
}
|
||||
return available.model(block).map(this::withBiomeTint).orElseGet(() -> BlockModel.solidColor(UNKNOWN_BLOCK_COLOR));
|
||||
});
|
||||
}
|
||||
|
||||
private BufferedImage iconOf(NativeBlockState block) {
|
||||
BlockTextures available = textures;
|
||||
if (available == null) {
|
||||
return null;
|
||||
}
|
||||
return available.iconTexture(block).map(this::withBiomeTint).map(BlockTexture::toImage).orElse(null);
|
||||
}
|
||||
|
||||
// Objects have no biome of their own, so grass and leaves get plains' colours.
|
||||
private BlockModel withBiomeTint(BlockModel model) {
|
||||
return model.withTextures(this::withBiomeTint);
|
||||
}
|
||||
|
||||
private BlockTexture withBiomeTint(BlockTexture texture) {
|
||||
BiomeTintColors colors = tintColors;
|
||||
if (texture.biomeTint() == null || colors == null) {
|
||||
return texture;
|
||||
}
|
||||
return texture.tinted(colors.colorAt(texture.biomeTint(), 0, 0));
|
||||
}
|
||||
|
||||
private record LoadedObject(Map<IrisBlockVector, NativeBlockState> blocks, ObjectMesh mesh, ObjectSummary summary) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
/**
|
||||
* A slider floating on the right of the 3D view that hides an object's top layers, to look inside buildings floor by
|
||||
* floor. Most objects never need it, so it stays faint until the mouse comes near or a cut is active.
|
||||
*/
|
||||
final class ObjectLayerCut {
|
||||
private static final String TOOLTIP = "Hide the top layers to look inside · Page Up / Page Down";
|
||||
|
||||
private final FloatingSlider slider = new FloatingSlider(TOOLTIP, this::showCut);
|
||||
private final IntConsumer onLayersChanged;
|
||||
|
||||
/**
|
||||
* @param onLayersChanged told how many layers from the bottom stay visible whenever the user moves the slider
|
||||
*/
|
||||
ObjectLayerCut(IntConsumer onLayersChanged) {
|
||||
this.onLayersChanged = onLayersChanged;
|
||||
showHeight(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The view with this slider floating over its right edge, halfway down.
|
||||
*/
|
||||
JComponent floatingOver(JComponent view) {
|
||||
return slider.floatingOver(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows every layer of a newly chosen object without telling anyone, since nothing is hidden yet.
|
||||
*/
|
||||
void showHeight(int layers) {
|
||||
int layerCount = Math.max(1, layers);
|
||||
slider.showQuietly(1, layerCount, layerCount);
|
||||
showCutState();
|
||||
slider.setVisible(layers > 1);
|
||||
}
|
||||
|
||||
void stepBy(int layers) {
|
||||
if (slider.isVisible()) {
|
||||
slider.moveBy(layers);
|
||||
}
|
||||
}
|
||||
|
||||
boolean isCutting() {
|
||||
return slider.value() < slider.maximum();
|
||||
}
|
||||
|
||||
int shownLayerCount() {
|
||||
return slider.value();
|
||||
}
|
||||
|
||||
int layerCount() {
|
||||
return slider.maximum();
|
||||
}
|
||||
|
||||
private void showCut(int shownLayers) {
|
||||
showCutState();
|
||||
onLayersChanged.accept(shownLayers);
|
||||
}
|
||||
|
||||
private void showCutState() {
|
||||
slider.showLabel(isCutting() ? Integer.toString(slider.value()) : "");
|
||||
slider.setStandingOut(isCutting());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.generation.decoration.IrisProceduralBlocks;
|
||||
import art.arcane.iris.generation.geometry.IrisBlockVector;
|
||||
import art.arcane.iris.studio.map.BlockModel.Face;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* The faces of an object's blocks that can be seen from outside. A face is left out when the neighbour on its side
|
||||
* covers that side, which is most of them in a large object. See-through faces, like water and stained glass, are
|
||||
* kept apart because they are drawn last, blended over everything behind them. Top faces covered only by the block
|
||||
* above are kept aside too, so the top layers can be cut away without building the mesh again; what is cut away stays
|
||||
* as a faint ghost of the object's shape.
|
||||
*/
|
||||
record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, List<Quad> cutAwayQuads, List<Quad> topsUnderBlockAbove,
|
||||
Bounds bounds) implements OrbitScene {
|
||||
private static final double SEE_THROUGH_OPACITY = 0.22D;
|
||||
private static final double FULL_OPACITY = 1D;
|
||||
private static final double CUT_AWAY_OPACITY = 0.1D;
|
||||
|
||||
static ObjectMesh build(Map<IrisBlockVector, NativeBlockState> blocks, Function<NativeBlockState, BlockModel> modelOf) {
|
||||
return build(blocks, modelOf, Bounds.of(blocks.keySet()), block -> false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bounds the whole object's extent, kept when only part of it is drawn so the view does not jump
|
||||
* @param isSeeThrough blocks drawn faintly, which hide nothing behind them, to look past them into the object
|
||||
*/
|
||||
static ObjectMesh build(Map<IrisBlockVector, NativeBlockState> blocks, Function<NativeBlockState, BlockModel> modelOf, Bounds bounds,
|
||||
Predicate<NativeBlockState> isSeeThrough) {
|
||||
List<Quad> solid = new ArrayList<>();
|
||||
List<Quad> translucent = new ArrayList<>();
|
||||
List<Quad> topsUnderBlockAbove = new ArrayList<>();
|
||||
BlockGrid grid = new BlockGrid(blocks, modelOf, bounds, isSeeThrough);
|
||||
for (IrisBlockVector position : blocks.keySet()) {
|
||||
int x = position.getBlockX();
|
||||
int y = position.getBlockY();
|
||||
int z = position.getBlockZ();
|
||||
int cell = grid.cellAt(x, y, z);
|
||||
for (Face face : grid.modelIn(cell).faces()) {
|
||||
Quad quad = new Quad(face, x, y, z, grid.isSeeThroughIn(cell) ? SEE_THROUGH_OPACITY : FULL_OPACITY);
|
||||
if (!grid.isHiddenByNeighbour(x, y, z, face)) {
|
||||
(quad.isBlended() ? translucent : solid).add(quad);
|
||||
} else if (face.cullFace() == CubeFace.UP) {
|
||||
topsUnderBlockAbove.add(quad);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ObjectMesh(List.copyOf(solid), List.copyOf(translucent), List.of(), List.copyOf(topsUnderBlockAbove), bounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* This mesh with the blocks above the given layer faded to a ghost of the object's shape, and the tops of that
|
||||
* layer's blocks showing again. Quick enough to follow a slider as it moves.
|
||||
*/
|
||||
ObjectMesh cutAbove(int highestShownY) {
|
||||
List<Quad> solid = new ArrayList<>();
|
||||
List<Quad> translucent = new ArrayList<>();
|
||||
List<Quad> cutAway = new ArrayList<>();
|
||||
for (Quad quad : solidQuads) {
|
||||
keepOrCutAway(quad, highestShownY, solid, cutAway);
|
||||
}
|
||||
for (Quad quad : translucentQuads) {
|
||||
keepOrCutAway(quad, highestShownY, translucent, cutAway);
|
||||
}
|
||||
for (Quad quad : topsUnderBlockAbove) {
|
||||
if (quad.y() == highestShownY) {
|
||||
(quad.isBlended() ? translucent : solid).add(quad);
|
||||
}
|
||||
}
|
||||
return new ObjectMesh(solid, translucent, cutAway, List.of(), bounds);
|
||||
}
|
||||
|
||||
private static void keepOrCutAway(Quad quad, int highestShownY, List<Quad> kept, List<Quad> cutAway) {
|
||||
if (quad.y() <= highestShownY) {
|
||||
kept.add(quad);
|
||||
} else {
|
||||
cutAway.add(quad.faded(CUT_AWAY_OPACITY));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return solidQuads.isEmpty() && translucentQuads.isEmpty() && cutAwayQuads.isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public OrbitPicture render(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
return renderOnCard(camera, width, height, coveredLeftWidth)
|
||||
.orElseGet(() -> ObjectRenderer.render(this, camera, width, height, coveredLeftWidth));
|
||||
}
|
||||
|
||||
private Optional<OrbitPicture> renderOnCard(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
if (!GpuLibrary.isLoaded()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return GpuObjectRenderer.render(this, camera, width, height, coveredLeftWidth);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canRenderOnScreen() {
|
||||
return GpuLibrary.isLoaded() && GpuFrames.canDrawOnScreen();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<OrbitPicture> renderOnScreen(ScreenTarget screen, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
return GpuObjectRenderer.renderOnScreen(this, screen, camera, width, height, coveredLeftWidth);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OrbitProjection projection(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
return ObjectRenderer.projection(this, camera, width, height, coveredLeftWidth);
|
||||
}
|
||||
|
||||
int faceCount() {
|
||||
return solidQuads.size() + translucentQuads.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* The object's blocks laid out in an array over its bounds, so finding a block's neighbours is quick. Looking them
|
||||
* up by {@link BlockVector} in a map is slow, since its hash codes collide a lot.
|
||||
*/
|
||||
private static final class BlockGrid {
|
||||
private final Bounds bounds;
|
||||
private final NativeBlockState[] blocks;
|
||||
private final BlockModel[] models;
|
||||
private final boolean[] seeThrough;
|
||||
|
||||
BlockGrid(Map<IrisBlockVector, NativeBlockState> placed, Function<NativeBlockState, BlockModel> modelOf, Bounds bounds,
|
||||
Predicate<NativeBlockState> isSeeThrough) {
|
||||
this.bounds = bounds;
|
||||
int cells = bounds.sizeX() * bounds.sizeY() * bounds.sizeZ();
|
||||
this.blocks = new NativeBlockState[cells];
|
||||
this.models = new BlockModel[cells];
|
||||
this.seeThrough = new boolean[cells];
|
||||
placed.forEach((position, block) -> {
|
||||
int cell = cellAt(position.getBlockX(), position.getBlockY(), position.getBlockZ());
|
||||
blocks[cell] = block;
|
||||
models[cell] = modelOf.apply(block);
|
||||
seeThrough[cell] = isSeeThrough.test(block);
|
||||
});
|
||||
}
|
||||
|
||||
int cellAt(int x, int y, int z) {
|
||||
return ((y - bounds.minY()) * bounds.sizeZ() + (z - bounds.minZ())) * bounds.sizeX() + (x - bounds.minX());
|
||||
}
|
||||
|
||||
BlockModel modelIn(int cell) {
|
||||
return models[cell];
|
||||
}
|
||||
|
||||
boolean isSeeThroughIn(int cell) {
|
||||
return seeThrough[cell];
|
||||
}
|
||||
|
||||
// Glass next to glass and water next to water hide the side between them too, as they do in the game. A
|
||||
// see-through block only hides sides of other see-through blocks, so whatever it covers shows through it.
|
||||
boolean isHiddenByNeighbour(int x, int y, int z, Face face) {
|
||||
CubeFace side = face.cullFace();
|
||||
if (side == null || !contains(x + side.stepX(), y + side.stepY(), z + side.stepZ())) {
|
||||
return false;
|
||||
}
|
||||
int cell = cellAt(x, y, z);
|
||||
int neighbourCell = cellAt(x + side.stepX(), y + side.stepY(), z + side.stepZ());
|
||||
NativeBlockState neighbour = blocks[neighbourCell];
|
||||
if (neighbour == null || (seeThrough[neighbourCell] && !seeThrough[cell])) {
|
||||
return false;
|
||||
}
|
||||
BlockModel neighbourModel = models[neighbourCell];
|
||||
if (neighbourModel.coveredSides().contains(side.opposite())) {
|
||||
return true;
|
||||
}
|
||||
return isSameBlockType(neighbour, blocks[cell]) && models[cell].hidesSameBlockSides()
|
||||
&& neighbourModel.hidesSameBlockSides();
|
||||
}
|
||||
|
||||
private static boolean isSameBlockType(NativeBlockState first, NativeBlockState second) {
|
||||
return IrisProceduralBlocks.materialKey(first).equals(IrisProceduralBlocks.materialKey(second));
|
||||
}
|
||||
|
||||
private boolean contains(int x, int y, int z) {
|
||||
return x >= bounds.minX() && x < bounds.maxX() && y >= bounds.minY() && y < bounds.maxY()
|
||||
&& z >= bounds.minZ() && z < bounds.maxZ();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A model face placed at a block in the object, drawn at the given opacity on top of its texture's own.
|
||||
*/
|
||||
record Quad(Face face, int x, int y, int z, double opacity) {
|
||||
boolean isBlended() {
|
||||
return opacity < FULL_OPACITY || face.texture().isTranslucent();
|
||||
}
|
||||
|
||||
Quad faded(double factor) {
|
||||
return new Quad(face, x, y, z, opacity * factor);
|
||||
}
|
||||
|
||||
double originX() {
|
||||
return x + face.origin().x();
|
||||
}
|
||||
|
||||
double originY() {
|
||||
return y + face.origin().y();
|
||||
}
|
||||
|
||||
double originZ() {
|
||||
return z + face.origin().z();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocks' extent, with the maximum one past the last block so a single block spans one unit, and the height the
|
||||
* terrain surface meets the object at.
|
||||
*/
|
||||
record Bounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ, int groundY) {
|
||||
private static final Bounds EMPTY = new Bounds(0, 0, 0, 1, 1, 1);
|
||||
|
||||
/** An extent standing on its lowest block. */
|
||||
Bounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
|
||||
this(minX, minY, minZ, maxX, maxY, maxZ, minY);
|
||||
}
|
||||
|
||||
/** This extent with the terrain surface at {@code groundY}, so anything below it shows as buried. */
|
||||
Bounds standingOn(int groundY) {
|
||||
return new Bounds(minX, minY, minZ, maxX, maxY, maxZ, groundY);
|
||||
}
|
||||
|
||||
static Bounds of(Iterable<IrisBlockVector> positions) {
|
||||
int minX = Integer.MAX_VALUE;
|
||||
int minY = Integer.MAX_VALUE;
|
||||
int minZ = Integer.MAX_VALUE;
|
||||
int maxX = Integer.MIN_VALUE;
|
||||
int maxY = Integer.MIN_VALUE;
|
||||
int maxZ = Integer.MIN_VALUE;
|
||||
for (IrisBlockVector position : positions) {
|
||||
minX = Math.min(minX, position.getBlockX());
|
||||
minY = Math.min(minY, position.getBlockY());
|
||||
minZ = Math.min(minZ, position.getBlockZ());
|
||||
maxX = Math.max(maxX, position.getBlockX() + 1);
|
||||
maxY = Math.max(maxY, position.getBlockY() + 1);
|
||||
maxZ = Math.max(maxZ, position.getBlockZ() + 1);
|
||||
}
|
||||
return minX == Integer.MAX_VALUE ? EMPTY : new Bounds(minX, minY, minZ, maxX, maxY, maxZ);
|
||||
}
|
||||
|
||||
int sizeX() {
|
||||
return maxX - minX;
|
||||
}
|
||||
|
||||
int sizeY() {
|
||||
return maxY - minY;
|
||||
}
|
||||
|
||||
int sizeZ() {
|
||||
return maxZ - minZ;
|
||||
}
|
||||
|
||||
int largestSize() {
|
||||
return Math.max(sizeX(), Math.max(sizeY(), sizeZ()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.event.DocumentEvent;
|
||||
import javax.swing.event.DocumentListener;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Composite;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Insets;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The chosen object's size and the blocks it is made of, most used first, like the overview in Procedural Objects.
|
||||
* Clicking a block makes it see-through in the 3D view, to look past walls into the object.
|
||||
*/
|
||||
final class ObjectOverviewPanel extends JPanel {
|
||||
private static final long serialVersionUID = -8811407613645718290L;
|
||||
private static final int PANEL_WIDTH = 280;
|
||||
private static final int ROW_HEIGHT = 26;
|
||||
private static final int ICON_SIZE = 16;
|
||||
private static final int ICON_GAP = 8;
|
||||
private static final int ROW_PADDING = 10;
|
||||
private static final int SHARE_WIDTH = 92;
|
||||
private static final int SHARE_BAR_HEIGHT = 4;
|
||||
private static final float SEE_THROUGH_ICON_OPACITY = 0.3F;
|
||||
private static final String SEE_THROUGH_HINT = "Click a block to see through it";
|
||||
|
||||
private final JLabel title = new JLabel(" ");
|
||||
private final JLabel path = new JLabel(" ");
|
||||
private final JLabel size = statValue();
|
||||
private final JLabel blockCount = statValue();
|
||||
private final JLabel blockTypes = statValue();
|
||||
private final JLabel filled = statValue();
|
||||
private final BakeMeter bakeMeter = new BakeMeter();
|
||||
private final JTextField filterField = new JTextField();
|
||||
private final DefaultListModel<ObjectSummary.BlockUse> rows = new DefaultListModel<>();
|
||||
private final JList<ObjectSummary.BlockUse> list = new JList<>(rows) {
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
private final JLabel seeThroughHint = new JLabel(SEE_THROUGH_HINT);
|
||||
private final JButton showAllSolid = new JButton("Show all solid");
|
||||
private final Set<String> seeThroughTypes = new LinkedHashSet<>();
|
||||
private final Consumer<Set<String>> onSeeThroughChanged;
|
||||
private ObjectSummary summary;
|
||||
|
||||
/**
|
||||
* @param onSeeThroughChanged told which block types to draw see-through whenever the user changes them
|
||||
* @param showsBakeTime whether the objects are baked, so the time their bake takes is shown
|
||||
*/
|
||||
ObjectOverviewPanel(Consumer<Set<String>> onSeeThroughChanged, boolean showsBakeTime) {
|
||||
super(new BorderLayout());
|
||||
this.onSeeThroughChanged = onSeeThroughChanged;
|
||||
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
|
||||
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
|
||||
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
add(buildHeader(showsBakeTime), BorderLayout.NORTH);
|
||||
add(buildBlockList(), BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
void show(ObjectSummary nextSummary) {
|
||||
summary = nextSummary;
|
||||
title.setText(nextSummary.name());
|
||||
path.setText(nextSummary.key());
|
||||
path.setToolTipText(nextSummary.key());
|
||||
size.setText(nextSummary.sizeX() + " × " + nextSummary.sizeY() + " × " + nextSummary.sizeZ());
|
||||
blockCount.setText(formatCount(nextSummary.blockCount()));
|
||||
blockTypes.setText(formatCount(nextSummary.blockUses().size()));
|
||||
filled.setText(String.format(Locale.ROOT, "%.1f%%", nextSummary.filledFraction() * 100D));
|
||||
seeThroughTypes.clear();
|
||||
showSeeThroughState();
|
||||
refilter();
|
||||
}
|
||||
|
||||
void showTimingBake() {
|
||||
bakeMeter.showTiming();
|
||||
}
|
||||
|
||||
void showBakeTiming(BakeTiming timing) {
|
||||
bakeMeter.show(timing);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
summary = null;
|
||||
title.setText(" ");
|
||||
path.setText(" ");
|
||||
for (JLabel value : List.of(size, blockCount, blockTypes, filled)) {
|
||||
value.setText("—");
|
||||
}
|
||||
bakeMeter.clear();
|
||||
rows.clear();
|
||||
seeThroughTypes.clear();
|
||||
showSeeThroughState();
|
||||
}
|
||||
|
||||
private JPanel buildHeader(boolean showsBakeTime) {
|
||||
JPanel header = new JPanel();
|
||||
header.setLayout(new BoxLayout(header, BoxLayout.Y_AXIS));
|
||||
header.setOpaque(false);
|
||||
header.setBorder(StudioTheme.emptyBorder(12, 16, 8, 16));
|
||||
title.setFont(StudioTheme.titleFont());
|
||||
path.setFont(StudioTheme.smallFont());
|
||||
path.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
header.add(leftAligned(title));
|
||||
header.add(leftAligned(path));
|
||||
|
||||
JPanel stats = new JPanel(new GridLayout(2, 2, StudioTheme.scale(12), StudioTheme.scale(8)));
|
||||
stats.setOpaque(false);
|
||||
stats.setBorder(StudioTheme.emptyBorder(12, 0, 4, 0));
|
||||
stats.add(stat("Size · X × Y × Z", size));
|
||||
stats.add(stat("Blocks", blockCount));
|
||||
stats.add(stat("Block types", blockTypes));
|
||||
stats.add(stat("Filled volume", filled));
|
||||
filled.setToolTipText("How much of the object's bounding box its blocks fill");
|
||||
header.add(leftAligned(stats));
|
||||
if (showsBakeTime) {
|
||||
JPanel bakeTime = stat("Bake time", bakeMeter);
|
||||
bakeTime.setBorder(StudioTheme.emptyBorder(4, 0, 4, 0));
|
||||
header.add(leftAligned(bakeTime));
|
||||
}
|
||||
clear();
|
||||
return header;
|
||||
}
|
||||
|
||||
private JPanel buildBlockList() {
|
||||
JPanel blocks = new JPanel(new BorderLayout());
|
||||
blocks.setOpaque(false);
|
||||
StudioToolbar.makeSearchField(filterField, "Find a block...");
|
||||
filterField.getDocument().addDocumentListener(new DocumentListener() {
|
||||
@Override
|
||||
public void insertUpdate(DocumentEvent event) {
|
||||
refilter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUpdate(DocumentEvent event) {
|
||||
refilter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changedUpdate(DocumentEvent event) {
|
||||
refilter();
|
||||
}
|
||||
});
|
||||
list.setFocusable(false);
|
||||
list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
|
||||
list.setCellRenderer(new BlockUseRenderer());
|
||||
list.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
|
||||
list.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
list.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent event) {
|
||||
int row = list.locationToIndex(event.getPoint());
|
||||
if (SwingUtilities.isLeftMouseButton(event) && row >= 0 && list.getCellBounds(row, row).contains(event.getPoint())) {
|
||||
toggleSeeThrough(rows.get(row).blockType());
|
||||
}
|
||||
}
|
||||
});
|
||||
JPanel top = new JPanel();
|
||||
top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS));
|
||||
top.setOpaque(false);
|
||||
top.add(leftAligned(StudioToolbar.padded(filterField, 4, 12, 4, 12)));
|
||||
top.add(leftAligned(StudioToolbar.padded(seeThroughRow(), 0, 14, 6, 12)));
|
||||
blocks.add(top, BorderLayout.NORTH);
|
||||
blocks.add(StudioToolbar.scroll(list), BorderLayout.CENTER);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private JPanel seeThroughRow() {
|
||||
seeThroughHint.setFont(StudioTheme.smallFont());
|
||||
seeThroughHint.setForeground(StudioTheme.MUTED_TEXT);
|
||||
showAllSolid.setFont(StudioTheme.smallFont());
|
||||
showAllSolid.setForeground(StudioTheme.LINK);
|
||||
showAllSolid.setBorderPainted(false);
|
||||
showAllSolid.setContentAreaFilled(false);
|
||||
showAllSolid.setFocusable(false);
|
||||
showAllSolid.setMargin(new Insets(0, 0, 0, 0));
|
||||
showAllSolid.setBorder(BorderFactory.createEmptyBorder());
|
||||
showAllSolid.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
showAllSolid.addActionListener(event -> {
|
||||
seeThroughTypes.clear();
|
||||
seeThroughChanged();
|
||||
});
|
||||
JPanel row = new JPanel(new BorderLayout());
|
||||
row.setOpaque(false);
|
||||
row.add(seeThroughHint, BorderLayout.CENTER);
|
||||
row.add(showAllSolid, BorderLayout.EAST);
|
||||
showSeeThroughState();
|
||||
return row;
|
||||
}
|
||||
|
||||
private void toggleSeeThrough(String blockType) {
|
||||
if (!seeThroughTypes.remove(blockType)) {
|
||||
seeThroughTypes.add(blockType);
|
||||
}
|
||||
seeThroughChanged();
|
||||
}
|
||||
|
||||
private void seeThroughChanged() {
|
||||
showSeeThroughState();
|
||||
list.repaint();
|
||||
onSeeThroughChanged.accept(Set.copyOf(seeThroughTypes));
|
||||
}
|
||||
|
||||
private void showSeeThroughState() {
|
||||
int count = seeThroughTypes.size();
|
||||
seeThroughHint.setText(count == 0 ? SEE_THROUGH_HINT : count == 1 ? "1 block see-through" : count + " blocks see-through");
|
||||
showAllSolid.setVisible(count > 0);
|
||||
}
|
||||
|
||||
private void refilter() {
|
||||
rows.clear();
|
||||
if (summary == null) {
|
||||
return;
|
||||
}
|
||||
String filter = filterField.getText().trim().toLowerCase(Locale.ROOT).replace(' ', '_');
|
||||
for (ObjectSummary.BlockUse use : summary.blockUses()) {
|
||||
if (use.blockType().contains(filter)) {
|
||||
rows.addElement(use);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JPanel stat(String label, JComponent value) {
|
||||
JPanel stat = new JPanel(new BorderLayout());
|
||||
stat.setOpaque(false);
|
||||
JLabel name = new JLabel(label);
|
||||
name.setFont(StudioTheme.smallFont());
|
||||
name.setForeground(StudioTheme.MUTED_TEXT);
|
||||
stat.add(name, BorderLayout.NORTH);
|
||||
stat.add(value, BorderLayout.CENTER);
|
||||
return stat;
|
||||
}
|
||||
|
||||
private static JLabel statValue() {
|
||||
JLabel value = new JLabel("—");
|
||||
value.setFont(StudioTheme.boldFont());
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Component leftAligned(JPanel panel) {
|
||||
panel.setAlignmentX(LEFT_ALIGNMENT);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static Component leftAligned(JLabel label) {
|
||||
label.setAlignmentX(LEFT_ALIGNMENT);
|
||||
return label;
|
||||
}
|
||||
|
||||
private static String formatCount(int count) {
|
||||
return String.format(Locale.ROOT, "%,d", count);
|
||||
}
|
||||
|
||||
private final class BlockUseRenderer extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = 2785337045812613941L;
|
||||
private transient BufferedImage icon;
|
||||
private boolean seeThrough;
|
||||
private double share;
|
||||
private String percent = "";
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
|
||||
ObjectSummary.BlockUse use = (ObjectSummary.BlockUse) value;
|
||||
super.getListCellRendererComponent(owner, use.blockType(), index, false, false);
|
||||
seeThrough = seeThroughTypes.contains(use.blockType());
|
||||
setFont(StudioTheme.font());
|
||||
setForeground(seeThrough ? StudioTheme.MUTED_TEXT : StudioTheme.TEXT);
|
||||
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + ICON_SIZE + ICON_GAP, 0, ROW_PADDING + SHARE_WIDTH));
|
||||
setToolTipText(use.blockType() + " · " + formatCount(use.count()) + " blocks · click to "
|
||||
+ (seeThrough ? "make solid again" : "see through it"));
|
||||
icon = use.icon();
|
||||
share = summary == null || summary.blockCount() == 0 ? 0D : use.count() / (double) summary.blockCount();
|
||||
percent = share > 0D && share < 0.001D ? "<0.1%" : String.format(Locale.ROOT, "%.1f%%", share * 100D);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics graphics) {
|
||||
super.paintComponent(graphics);
|
||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||
try {
|
||||
int iconSize = StudioTheme.scale(ICON_SIZE);
|
||||
int iconX = StudioTheme.scale(ROW_PADDING);
|
||||
int iconY = (getHeight() - iconSize) / 2;
|
||||
if (icon != null) {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
|
||||
Composite solid = canvas.getComposite();
|
||||
if (seeThrough) {
|
||||
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, SEE_THROUGH_ICON_OPACITY));
|
||||
}
|
||||
canvas.drawImage(icon, iconX, iconY, iconSize, iconSize, null);
|
||||
canvas.setComposite(solid);
|
||||
} else {
|
||||
canvas.setColor(StudioTheme.CONTROL_BORDER);
|
||||
canvas.drawRect(iconX, iconY, iconSize - 1, iconSize - 1);
|
||||
}
|
||||
drawShare(canvas);
|
||||
} finally {
|
||||
canvas.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void drawShare(Graphics2D canvas) {
|
||||
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
canvas.setFont(StudioTheme.smallFont());
|
||||
int padding = StudioTheme.scale(ROW_PADDING);
|
||||
int percentWidth = canvas.getFontMetrics().stringWidth("100.0%");
|
||||
int barWidth = StudioTheme.scale(SHARE_WIDTH) - percentWidth - StudioTheme.scale(6);
|
||||
int barX = getWidth() - padding - StudioTheme.scale(SHARE_WIDTH);
|
||||
int barHeight = StudioTheme.scale(SHARE_BAR_HEIGHT);
|
||||
int barY = (getHeight() - barHeight) / 2;
|
||||
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
|
||||
canvas.fillRoundRect(barX, barY, barWidth, barHeight, barHeight, barHeight);
|
||||
canvas.setColor(seeThrough ? StudioTheme.CONTROL_BORDER : StudioTheme.ACCENT);
|
||||
canvas.fillRoundRect(barX, barY, Math.max(barHeight, (int) Math.round(barWidth * share)), barHeight, barHeight, barHeight);
|
||||
canvas.setColor(StudioTheme.SECONDARY_TEXT);
|
||||
int textWidth = canvas.getFontMetrics().stringWidth(percent);
|
||||
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
|
||||
canvas.drawString(percent, getWidth() - padding - textWidth, baseline);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.studio.map.BlockModel.Face;
|
||||
import art.arcane.iris.studio.map.BlockModel.TextureMapping;
|
||||
import art.arcane.iris.studio.map.BlockTextures.BlockTexture;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Draws an object mesh with an orthographic camera into a picture, without a graphics card: each quad is filled pixel
|
||||
* by pixel, and a depth buffer keeps the nearest one. A cut-away part's faint ghost only fills pixels the object
|
||||
* leaves empty, so it never hazes over what is shown. See-through quads come last, farthest first, each blended over
|
||||
* what is already drawn.
|
||||
*/
|
||||
final class ObjectRenderer {
|
||||
private static final Color GRID = new ThemeColor(0xE3E4E8, 0x34373C);
|
||||
private static final int GRID_LINES_PER_SIDE = 25;
|
||||
private static final double GRID_REACH = 0.7D;
|
||||
private static final double GRID_BELOW_OBJECT = 0.035D;
|
||||
// Shared quad edges land exactly on pixel centres now and then; a little overlap keeps them from leaving cracks.
|
||||
private static final double EDGE_TOLERANCE = 1e-6D;
|
||||
private static final double EDGE_ON_DETERMINANT = 1e-9D;
|
||||
// A face's own texels end a hair inside its UV rectangle, so rounding never picks the texel next to it.
|
||||
private static final double TEXEL_EDGE_MARGIN = 1e-6D;
|
||||
private static final double FULL_ALPHA = 255D;
|
||||
// Zoom 2 fills the picture with the object's bounding box; thumbnails keep a hair of margin around it.
|
||||
private static final OrbitCamera THUMBNAIL_CAMERA = new OrbitCamera(OrbitCamera.START.yaw(), OrbitCamera.START.pitch(), 1.9D, Vec3.ZERO);
|
||||
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final int[] pixels;
|
||||
private final double[] depths;
|
||||
private final boolean[] showsObject;
|
||||
private final OrbitProjection projection;
|
||||
private final int gridColor = GRID.getRGB() & 0xFFFFFF;
|
||||
|
||||
private ObjectRenderer(BufferedImage image, OrbitProjection projection) {
|
||||
this.width = image.getWidth();
|
||||
this.height = image.getHeight();
|
||||
this.pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||
this.depths = new double[pixels.length];
|
||||
this.showsObject = new boolean[pixels.length];
|
||||
this.projection = projection;
|
||||
Arrays.fill(pixels, StudioTheme.EDITOR_BACKGROUND.getRGB() & 0xFFFFFF);
|
||||
Arrays.fill(depths, Double.NEGATIVE_INFINITY);
|
||||
}
|
||||
|
||||
static OrbitPicture render(ObjectMesh mesh, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
BufferedImage image = blankImage(width, height);
|
||||
ObjectRenderer renderer = new ObjectRenderer(image, projection(mesh, camera, image.getWidth(), image.getHeight(), coveredLeftWidth));
|
||||
renderer.drawGrid(mesh.bounds());
|
||||
renderer.drawMesh(mesh);
|
||||
return OrbitPicture.of(image, renderer.depths, renderer.projection);
|
||||
}
|
||||
|
||||
static OrbitProjection projection(ObjectMesh mesh, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
|
||||
int covered = Math.max(0, Math.min(Math.max(1, width) - 1, coveredLeftWidth));
|
||||
return projection(mesh.bounds(), mesh.bounds(), camera, Math.max(1, width), Math.max(1, height), covered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws {@code mesh} from the starting angle, without the floor grid, at the scale that fits an object of
|
||||
* {@code sizeToFit}. Objects drawn with the same size compare at their true sizes, standing on the same ground.
|
||||
*/
|
||||
static BufferedImage renderThumbnail(ObjectMesh mesh, ObjectMesh.Bounds sizeToFit, int width, int height) {
|
||||
BufferedImage image = blankImage(width, height);
|
||||
new ObjectRenderer(image, projection(mesh.bounds(), sizeToFit, THUMBNAIL_CAMERA, image.getWidth(), image.getHeight(), 0)).drawMesh(mesh);
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param shown the object drawn, centred across and standing where an object of {@code scaledAs} would
|
||||
* @param scaledAs the size that is fitted to the view
|
||||
*/
|
||||
private static OrbitProjection projection(ObjectMesh.Bounds shown, ObjectMesh.Bounds scaledAs, OrbitCamera camera,
|
||||
int width, int height, int coveredLeftWidth) {
|
||||
Vec3 center = new Vec3((shown.minX() + shown.maxX()) / 2D, shown.minY() + scaledAs.sizeY() / 2D, (shown.minZ() + shown.maxZ()) / 2D);
|
||||
Vec3 fittedSize = new Vec3(scaledAs.sizeX(), scaledAs.sizeY(), scaledAs.sizeZ());
|
||||
return new OrbitProjection(center, fittedSize, camera, OrbitCamera.START, width, height, coveredLeftWidth);
|
||||
}
|
||||
|
||||
private static BufferedImage blankImage(int width, int height) {
|
||||
return new BufferedImage(Math.max(1, width), Math.max(1, height), BufferedImage.TYPE_INT_RGB);
|
||||
}
|
||||
|
||||
private void drawMesh(ObjectMesh mesh) {
|
||||
for (ObjectMesh.Quad quad : mesh.solidQuads()) {
|
||||
drawQuad(quad, Pass.SOLID);
|
||||
}
|
||||
for (ObjectMesh.Quad quad : mesh.cutAwayQuads()) {
|
||||
drawQuad(quad, Pass.CUT_AWAY_GHOST);
|
||||
}
|
||||
for (ObjectMesh.Quad quad : farthestFirst(mesh.translucentQuads())) {
|
||||
drawQuad(quad, Pass.BLENDED);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ObjectMesh.Quad> farthestFirst(List<ObjectMesh.Quad> quads) {
|
||||
List<ObjectMesh.Quad> sorted = new ArrayList<>(quads);
|
||||
sorted.sort(Comparator.comparingDouble(this::centerDepth));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private double centerDepth(ObjectMesh.Quad quad) {
|
||||
Face face = quad.face();
|
||||
return projection.depth(
|
||||
quad.originX() + (face.edgeU().x() + face.edgeV().x()) / 2D,
|
||||
quad.originY() + (face.edgeU().y() + face.edgeV().y()) / 2D,
|
||||
quad.originZ() + (face.edgeU().z() + face.edgeV().z()) / 2D);
|
||||
}
|
||||
|
||||
private void drawGrid(ObjectMesh.Bounds bounds) {
|
||||
FloorGrid grid = FloorGrid.under(bounds);
|
||||
int reach = grid.reach();
|
||||
for (int offset = -reach; offset <= reach; offset += grid.step()) {
|
||||
drawLine(grid.centerX() + offset, grid.floorY(), grid.centerZ() - reach, grid.centerX() + offset, grid.floorY(), grid.centerZ() + reach);
|
||||
drawLine(grid.centerX() - reach, grid.floorY(), grid.centerZ() + offset, grid.centerX() + reach, grid.floorY(), grid.centerZ() + offset);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawLine(double fromX, double fromY, double fromZ, double toX, double toY, double toZ) {
|
||||
double startX = projection.screenX(fromX, fromY, fromZ);
|
||||
double startY = projection.screenY(fromX, fromY, fromZ);
|
||||
double startDepth = projection.depth(fromX, fromY, fromZ);
|
||||
double endX = projection.screenX(toX, toY, toZ);
|
||||
double endY = projection.screenY(toX, toY, toZ);
|
||||
double endDepth = projection.depth(toX, toY, toZ);
|
||||
int steps = (int) Math.ceil(Math.max(Math.abs(endX - startX), Math.abs(endY - startY)));
|
||||
for (int step = 0; step <= steps; step++) {
|
||||
double progress = steps == 0 ? 0D : step / (double) steps;
|
||||
int x = (int) (startX + (endX - startX) * progress);
|
||||
int y = (int) (startY + (endY - startY) * progress);
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) {
|
||||
continue;
|
||||
}
|
||||
int index = y * width + x;
|
||||
double depth = startDepth + (endDepth - startDepth) * progress;
|
||||
if (depth > depths[index]) {
|
||||
depths[index] = depth;
|
||||
pixels[index] = gridColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Later faces win ties, so an overlay drawn on the same box, like grass on a grass block's side, shows on top.
|
||||
private void drawQuad(ObjectMesh.Quad quad, Pass pass) {
|
||||
Face face = quad.face();
|
||||
if (!projection.facesCamera(face.normal().x(), face.normal().y(), face.normal().z())) {
|
||||
return;
|
||||
}
|
||||
Vec3 u = face.edgeU();
|
||||
Vec3 v = face.edgeV();
|
||||
double originX = projection.screenX(quad.originX(), quad.originY(), quad.originZ());
|
||||
double originY = projection.screenY(quad.originX(), quad.originY(), quad.originZ());
|
||||
double originDepth = projection.depth(quad.originX(), quad.originY(), quad.originZ());
|
||||
double edgeUX = projection.screenStepX(u.x(), u.z());
|
||||
double edgeUY = projection.screenStepY(u.x(), u.y(), u.z());
|
||||
double edgeUDepth = projection.depthStep(u.x(), u.y(), u.z());
|
||||
double edgeVX = projection.screenStepX(v.x(), v.z());
|
||||
double edgeVY = projection.screenStepY(v.x(), v.y(), v.z());
|
||||
double edgeVDepth = projection.depthStep(v.x(), v.y(), v.z());
|
||||
double determinant = edgeUX * edgeVY - edgeUY * edgeVX;
|
||||
if (Math.abs(determinant) < EDGE_ON_DETERMINANT) {
|
||||
return;
|
||||
}
|
||||
|
||||
int left = Math.max(0, (int) Math.floor(min4(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
|
||||
int right = Math.min(width - 1, (int) Math.ceil(max4(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
|
||||
int top = Math.max(0, (int) Math.floor(min4(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
|
||||
int bottom = Math.min(height - 1, (int) Math.ceil(max4(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
|
||||
if (left > right || top > bottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
BlockTexture texture = face.texture();
|
||||
TextureMapping mapping = face.mapping();
|
||||
TexelRange columns = TexelRange.of(mapping.u(), mapping.uAlongU(), mapping.uAlongV(), texture.width());
|
||||
TexelRange rows = TexelRange.of(mapping.v(), mapping.vAlongU(), mapping.vAlongV(), texture.height());
|
||||
int[] texels = texture.pixels();
|
||||
boolean[] opaque = texture.opaque();
|
||||
int[] alphas = texture.alphas();
|
||||
double shade = face.shade();
|
||||
double alongUPerPixelX = edgeVY / determinant;
|
||||
double alongVPerPixelX = -edgeUY / determinant;
|
||||
for (int y = top; y <= bottom; y++) {
|
||||
double offsetX = left + 0.5D - originX;
|
||||
double offsetY = y + 0.5D - originY;
|
||||
double alongU = (offsetX * edgeVY - offsetY * edgeVX) / determinant;
|
||||
double alongV = (offsetY * edgeUX - offsetX * edgeUY) / determinant;
|
||||
int rowStart = y * width;
|
||||
for (int x = left; x <= right; x++, alongU += alongUPerPixelX, alongV += alongVPerPixelX) {
|
||||
if (alongU < -EDGE_TOLERANCE || alongU > 1D + EDGE_TOLERANCE || alongV < -EDGE_TOLERANCE || alongV > 1D + EDGE_TOLERANCE) {
|
||||
continue;
|
||||
}
|
||||
int index = rowStart + x;
|
||||
double depth = originDepth + alongU * edgeUDepth + alongV * edgeVDepth;
|
||||
if (depth < depths[index] || (pass == Pass.CUT_AWAY_GHOST && showsObject[index])) {
|
||||
continue;
|
||||
}
|
||||
int texelX = columns.texelAt(mapping.u() + alongU * mapping.uAlongU() + alongV * mapping.uAlongV());
|
||||
int texelY = rows.texelAt(mapping.v() + alongU * mapping.vAlongU() + alongV * mapping.vAlongV());
|
||||
int texel = texelY * texture.width() + texelX;
|
||||
if (!opaque[texel]) {
|
||||
continue;
|
||||
}
|
||||
int color = BlockPreviewColors.shade(texels[texel], shade);
|
||||
if (pass != Pass.SOLID) {
|
||||
pixels[index] = blend(color, pixels[index], (alphas == null ? 1D : alphas[texel] / FULL_ALPHA) * quad.opacity());
|
||||
continue;
|
||||
}
|
||||
depths[index] = depth;
|
||||
pixels[index] = color;
|
||||
showsObject[index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int blend(int over, int under, double coverage) {
|
||||
return blendChannel(over, under, coverage, 16) << 16 | blendChannel(over, under, coverage, 8) << 8 | blendChannel(over, under, coverage, 0);
|
||||
}
|
||||
|
||||
private static int blendChannel(int over, int under, double coverage, int shift) {
|
||||
return (int) Math.round(((over >>> shift) & 0xFF) * coverage + ((under >>> shift) & 0xFF) * (1D - coverage));
|
||||
}
|
||||
|
||||
private static double min4(double a, double b, double c, double d) {
|
||||
return Math.min(Math.min(a, b), Math.min(c, d));
|
||||
}
|
||||
|
||||
private static double max4(double a, double b, double c, double d) {
|
||||
return Math.max(Math.max(a, b), Math.max(c, d));
|
||||
}
|
||||
|
||||
/**
|
||||
* The texels a face covers along one texture axis, so a pixel on the face's edge never takes its neighbour's.
|
||||
*/
|
||||
record TexelRange(int first, int last, int size) {
|
||||
static TexelRange of(double start, double alongU, double alongV, int size) {
|
||||
double low = start + Math.min(0D, alongU) + Math.min(0D, alongV);
|
||||
double high = start + Math.max(0D, alongU) + Math.max(0D, alongV);
|
||||
int first = clamp((int) Math.floor(low * size + TEXEL_EDGE_MARGIN), size);
|
||||
int last = Math.max(first, clamp((int) Math.ceil(high * size - TEXEL_EDGE_MARGIN) - 1, size));
|
||||
return new TexelRange(first, last, size);
|
||||
}
|
||||
|
||||
int texelAt(double fraction) {
|
||||
int texel = (int) Math.floor(fraction * size);
|
||||
return texel < first ? first : Math.min(texel, last);
|
||||
}
|
||||
|
||||
private static int clamp(int texel, int size) {
|
||||
return Math.max(0, Math.min(size - 1, texel));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid on the ground under an object, a square of lines reaching past it on every side.
|
||||
*/
|
||||
record FloorGrid(int centerX, double floorY, int centerZ, int reach, int step) {
|
||||
static FloorGrid under(ObjectMesh.Bounds bounds) {
|
||||
int reach = (int) Math.ceil(bounds.largestSize() * GRID_REACH);
|
||||
int step = Math.max(1, (int) Math.ceil(reach / (double) GRID_LINES_PER_SIDE));
|
||||
return new FloorGrid(Math.floorDiv(bounds.minX() + bounds.maxX(), 2), bounds.groundY() - GRID_BELOW_OBJECT,
|
||||
Math.floorDiv(bounds.minZ() + bounds.maxZ(), 2), reach, step);
|
||||
}
|
||||
|
||||
int linesPerDirection() {
|
||||
return 2 * reach / step + 1;
|
||||
}
|
||||
|
||||
int color() {
|
||||
return GRID.getRGB();
|
||||
}
|
||||
}
|
||||
|
||||
private enum Pass {
|
||||
SOLID,
|
||||
CUT_AWAY_GHOST,
|
||||
BLENDED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import javax.swing.DefaultComboBoxModel;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.UIManager;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.EventQueue;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.LongConsumer;
|
||||
|
||||
/**
|
||||
* Where the seed of a procedural object's variants is chosen: typed in, picked again from the seeds tried on this
|
||||
* object with the pack's own first, or rerolled at random. It keeps its size whatever it shows, so the bar it sits in
|
||||
* never shifts under the mouse.
|
||||
*/
|
||||
final class ObjectSeedChooser extends JPanel {
|
||||
private static final long serialVersionUID = 3127745038254406118L;
|
||||
private static final int FIELD_WIDTH = 120;
|
||||
private static final int TRIED_SEEDS_KEPT = 12;
|
||||
// Small enough to read back and type, and far more seeds than anyone tries.
|
||||
private static final int RANDOM_SEED_BOUND = 1_000_000;
|
||||
|
||||
private final JComboBox<Long> seeds = new JComboBox<>();
|
||||
private final LongConsumer onSeedChosen;
|
||||
private final List<Long> triedSeedsNewestFirst = new ArrayList<>();
|
||||
private OptionalLong packSeed = OptionalLong.empty();
|
||||
private long shownSeed;
|
||||
private boolean showingSeeds;
|
||||
|
||||
/**
|
||||
* @param onSeedChosen told the seed to bake the variants from whenever the user chooses another one
|
||||
*/
|
||||
ObjectSeedChooser(LongConsumer onSeedChosen) {
|
||||
super(new BorderLayout(StudioTheme.scale(4), 0));
|
||||
this.onSeedChosen = onSeedChosen;
|
||||
setOpaque(false);
|
||||
JLabel label = new JLabel("Seed");
|
||||
label.setFont(StudioTheme.smallFont());
|
||||
label.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||
seeds.setEditable(true);
|
||||
seeds.setFont(StudioTheme.smallFont());
|
||||
seeds.setToolTipText("Type a seed and press Enter, or pick one tried before");
|
||||
seeds.setRenderer(new PackSeedMarker());
|
||||
seeds.setPreferredSize(new Dimension(StudioTheme.scale(FIELD_WIDTH), seeds.getPreferredSize().height));
|
||||
seeds.addActionListener(event -> {
|
||||
if (!showingSeeds) {
|
||||
EventQueue.invokeLater(this::chooseEnteredSeed);
|
||||
}
|
||||
});
|
||||
add(label, BorderLayout.WEST);
|
||||
add(seeds, BorderLayout.CENTER);
|
||||
add(StudioToolbar.button("Reroll", "Bake the variants from a random seed", this::reroll), BorderLayout.EAST);
|
||||
}
|
||||
|
||||
/** Starts a new object's list of tried seeds. */
|
||||
void forgetTriedSeeds() {
|
||||
triedSeedsNewestFirst.clear();
|
||||
}
|
||||
|
||||
void showSeed(long shown, OptionalLong pack) {
|
||||
shownSeed = shown;
|
||||
packSeed = pack;
|
||||
if (!isPackSeed(shown)) {
|
||||
rememberTried(shown);
|
||||
}
|
||||
DefaultComboBoxModel<Long> choices = new DefaultComboBoxModel<>();
|
||||
pack.ifPresent(choices::addElement);
|
||||
triedSeedsNewestFirst.forEach(choices::addElement);
|
||||
showingSeeds = true;
|
||||
try {
|
||||
seeds.setModel(choices);
|
||||
seeds.setSelectedItem(shown);
|
||||
} finally {
|
||||
showingSeeds = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void rememberTried(long seed) {
|
||||
triedSeedsNewestFirst.remove(seed);
|
||||
triedSeedsNewestFirst.add(0, seed);
|
||||
while (triedSeedsNewestFirst.size() > TRIED_SEEDS_KEPT) {
|
||||
triedSeedsNewestFirst.remove(triedSeedsNewestFirst.size() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// A pick from the list arrives as a seed, anything typed as text.
|
||||
private void chooseEnteredSeed() {
|
||||
OptionalLong entered = parse(seeds.getSelectedItem());
|
||||
if (entered.isEmpty()) {
|
||||
UIManager.getLookAndFeel().provideErrorFeedback(seeds);
|
||||
showSeed(shownSeed, packSeed);
|
||||
return;
|
||||
}
|
||||
if (entered.getAsLong() != shownSeed) {
|
||||
onSeedChosen.accept(entered.getAsLong());
|
||||
}
|
||||
}
|
||||
|
||||
private static OptionalLong parse(Object entered) {
|
||||
if (entered instanceof Long seed) {
|
||||
return OptionalLong.of(seed);
|
||||
}
|
||||
try {
|
||||
return OptionalLong.of(Long.parseLong(String.valueOf(entered).trim()));
|
||||
} catch (NumberFormatException notASeed) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private void reroll() {
|
||||
long seed;
|
||||
do {
|
||||
seed = ThreadLocalRandom.current().nextInt(RANDOM_SEED_BOUND);
|
||||
} while (seed == shownSeed);
|
||||
onSeedChosen.accept(seed);
|
||||
}
|
||||
|
||||
private boolean isPackSeed(Object seed) {
|
||||
return packSeed.isPresent() && seed instanceof Long value && value == packSeed.getAsLong();
|
||||
}
|
||||
|
||||
private final class PackSeedMarker extends DefaultListCellRenderer {
|
||||
private static final long serialVersionUID = -6061513307766393251L;
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean selected, boolean focused) {
|
||||
super.getListCellRendererComponent(list, value, index, selected, focused);
|
||||
if (index >= 0 && isPackSeed(value)) {
|
||||
setText(value + " (pack)");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.structure.object.IrisObject;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* Where an object explorer gets what it shows: the pack's object files, or the objects Iris generates from settings.
|
||||
* Both are called on the explorer's loader thread only.
|
||||
*/
|
||||
interface ObjectSource {
|
||||
/** What one entry is called, such as "object", for the browser's texts. */
|
||||
String noun();
|
||||
|
||||
/** What the view shows when the pack has no entries. */
|
||||
EmptyState emptyState();
|
||||
|
||||
/** The key of every entry, where a slash separates folders. */
|
||||
List<String> keys(IrisData data);
|
||||
|
||||
/** The objects of the entry, one for an object file and one per variant for a procedural object; empty when none load. */
|
||||
List<IrisObject> variants(IrisData data, String key);
|
||||
|
||||
/** The height, in {@code object}'s own blocks, that the terrain surface meets; empty to stand it on its lowest block. */
|
||||
default OptionalInt groundY(IrisObject object) {
|
||||
return OptionalInt.empty();
|
||||
}
|
||||
|
||||
/** The seed the pack bakes the entry's variants from; empty for anything not generated from a seed. */
|
||||
default OptionalLong seed(String key) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
|
||||
/** The entry's variants as {@code seed} would bake them; only asked of entries that have a seed. */
|
||||
default List<IrisObject> variants(IrisData data, String key, long seed) {
|
||||
throw new UnsupportedOperationException(key + " is not generated from a seed");
|
||||
}
|
||||
|
||||
/** Whether its entries are baked from settings, which costs world generation time worth measuring. */
|
||||
default boolean isBaked() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bakes the entry's variants from {@code seed} once more and times it; null when the entry is gone. Only asked of
|
||||
* baked sources, on a thread of its own.
|
||||
*/
|
||||
default BakeRun timeBake(IrisData data, String key, long seed) {
|
||||
throw new UnsupportedOperationException(key + " is not baked");
|
||||
}
|
||||
}
|
||||
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user