mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-26 22:10:18 +00:00
Integrate Iris Studio
- Merge the unified Studio interface and command entry points. - Adapt water views and provisional sampling to basin hydrology. - Restore diagnostic estimates and the biome water overlay. - Fix render cancellation, preview closure, and platform position callbacks. - Fall back to software rendering when native bindings are unavailable. - Cover renderer, lifecycle, and scheduler regressions.
This commit is contained in:
commit
9f49d29e2b
225 files changed
+29666
-4395
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.iris.platform.bukkit.nms.INMS;
|
||||||
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
|
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
|
||||||
import art.arcane.iris.studio.view.BukkitGuiHost;
|
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.studio.view.PregeneratorJob;
|
||||||
import art.arcane.iris.platform.bukkit.BoardSVC;
|
import art.arcane.iris.platform.bukkit.BoardSVC;
|
||||||
import art.arcane.iris.command.CommandSVC;
|
import art.arcane.iris.command.CommandSVC;
|
||||||
@@ -988,6 +989,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
GuiHost.shutdown();
|
||||||
preservePendingBlockDrops();
|
preservePendingBlockDrops();
|
||||||
papiLifecycleActive = false;
|
papiLifecycleActive = false;
|
||||||
if (updateNotifier != null) {
|
if (updateNotifier != null) {
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ package art.arcane.iris.command;
|
|||||||
import art.arcane.iris.Iris;
|
import art.arcane.iris.Iris;
|
||||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||||
import art.arcane.iris.configuration.IrisSettings;
|
import art.arcane.iris.configuration.IrisSettings;
|
||||||
|
import art.arcane.iris.studio.map.MapWindow;
|
||||||
|
import art.arcane.iris.studio.map.PaletteExplorerStart;
|
||||||
import art.arcane.iris.studio.view.GuiHost;
|
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.loading.IrisData;
|
||||||
import art.arcane.iris.studio.workspace.IrisProject;
|
import art.arcane.iris.studio.workspace.IrisProject;
|
||||||
import art.arcane.iris.studio.workspace.IrisCodeWorkspace;
|
import art.arcane.iris.studio.workspace.IrisCodeWorkspace;
|
||||||
@@ -98,7 +97,9 @@ import java.util.Objects;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
import art.arcane.iris.localization.IrisLanguage;
|
import art.arcane.iris.localization.IrisLanguage;
|
||||||
import art.arcane.iris.localization.BukkitCommandMessages;
|
import art.arcane.iris.localization.BukkitCommandMessages;
|
||||||
@@ -255,15 +256,10 @@ public class CommandStudio implements DirectorExecutor {
|
|||||||
long seed
|
long seed
|
||||||
) {
|
) {
|
||||||
if (noGUI()) return;
|
if (noGUI()) return;
|
||||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
|
VolmitSender commandSender = sender();
|
||||||
|
UUID openerId = commandSender.isPlayer() ? commandSender.player().getUniqueId() : null;
|
||||||
if (generator == null) {
|
MapWindow.NoiseSelection selection = new MapWindow.NoiseSelection(generator == null ? null : generator.getLoadKey(), generator, seed);
|
||||||
NoiseExplorerGUI.launch();
|
withPlayerContext(opener -> openNoiseAtPlayer(commandSender, openerId, opener, selection));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String generatorKey = generator.getLoadKey();
|
|
||||||
NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"})
|
@Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"})
|
||||||
@@ -279,8 +275,7 @@ public class CommandStudio implements DirectorExecutor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Engine activeEngine = IrisToolbelt.access(world).getEngine();
|
requestMapStart(world, MapWindow::openImageMaps);
|
||||||
ImageMapStudioGUI.launch(activeEngine);
|
|
||||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO));
|
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,11 +415,61 @@ public class CommandStudio implements DirectorExecutor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
|
requestMapStart(world, MapWindow::openMap);
|
||||||
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId);
|
|
||||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
|
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, Player opener, UUID openerId) {
|
||||||
|
Location center = opener != null && opener.getWorld().equals(world) ? opener.getLocation() : null;
|
||||||
|
return new PaletteExplorerStart(
|
||||||
|
IrisToolbelt.access(world).getEngine(),
|
||||||
|
world.getUID(),
|
||||||
|
openerId,
|
||||||
|
null,
|
||||||
|
center == null ? 0D : center.getX(),
|
||||||
|
center == null ? 0D : center.getZ()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestMapStart(World world, Consumer<PaletteExplorerStart> launch) {
|
||||||
|
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
|
||||||
|
withPlayerContext(opener -> launch.accept(mapStartIn(world, opener, openerId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openNoiseAtPlayer(VolmitSender commandSender, UUID openerId, Player opener, MapWindow.NoiseSelection selection) {
|
||||||
|
World world = opener == null ? null : opener.getWorld();
|
||||||
|
PlatformChunkGenerator worldGenerator = world == null ? null : IrisToolbelt.access(world);
|
||||||
|
Engine engine = worldGenerator == null ? GuiHost.get().findActiveEngine() : worldGenerator.getEngine();
|
||||||
|
if (engine == null) {
|
||||||
|
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PaletteExplorerStart start = worldGenerator == null
|
||||||
|
? new PaletteExplorerStart(engine, null, openerId, null, 0D, 0D)
|
||||||
|
: mapStartIn(world, opener, openerId);
|
||||||
|
MapWindow.openNoise(start, selection);
|
||||||
|
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void withPlayerContext(Consumer<Player> action) {
|
||||||
|
Player opener = sender().isPlayer() ? player() : null;
|
||||||
|
if (opener == null) {
|
||||||
|
action.accept(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AtomicBoolean completed = new AtomicBoolean();
|
||||||
|
Consumer<Player> complete = player -> {
|
||||||
|
if (completed.compareAndSet(false, true)) {
|
||||||
|
action.accept(player);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Runnable retired = () -> complete.accept(null);
|
||||||
|
if (!J.runEntity(opener, () -> complete.accept(opener.isOnline() ? opener : null), 0, retired)) {
|
||||||
|
retired.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER)
|
@Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER)
|
||||||
public void profile(
|
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)
|
@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.world.IrisToolbelt;
|
||||||
import art.arcane.iris.generation.runtime.Engine;
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
|
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.Bukkit;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
import org.bukkit.event.EventHandler;
|
import org.bukkit.event.EventHandler;
|
||||||
@@ -40,16 +42,21 @@ public final class BukkitGuiHost implements GuiHost.Provider {
|
|||||||
GuiHost.set(new BukkitGuiHost());
|
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
|
@Override
|
||||||
public Engine findActiveEngine() {
|
public Engine findActiveEngine() {
|
||||||
|
Engine studioEngine = findStudioEngine();
|
||||||
|
if (studioEngine != null) {
|
||||||
|
return studioEngine;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
|
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
|
||||||
try {
|
Engine engine = engineOf(world);
|
||||||
PlatformChunkGenerator access = IrisToolbelt.access(world);
|
if (engine != null) {
|
||||||
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
|
return engine;
|
||||||
return access.getEngine();
|
|
||||||
}
|
|
||||||
} catch (Throwable ignored) {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Throwable ignored) {
|
} catch (Throwable ignored) {
|
||||||
@@ -57,6 +64,35 @@ public final class BukkitGuiHost implements GuiHost.Provider {
|
|||||||
return null;
|
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
|
@Override
|
||||||
public void registerHotloadHook(Runnable onHotload) {
|
public void registerHotloadHook(Runnable onHotload) {
|
||||||
Listener listener = new HotloadListener(onHotload);
|
Listener listener = new HotloadListener(onHotload);
|
||||||
|
|||||||
+83
-40
@@ -18,23 +18,19 @@
|
|||||||
|
|
||||||
package art.arcane.iris.studio.view;
|
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.generation.runtime.Engine;
|
||||||
import art.arcane.iris.studio.render.RenderType;
|
|
||||||
import art.arcane.iris.world.IrisWorld;
|
import art.arcane.iris.world.IrisWorld;
|
||||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||||
import art.arcane.iris.spi.IrisLogging;
|
import art.arcane.iris.spi.IrisLogging;
|
||||||
import art.arcane.iris.world.task.J;
|
import art.arcane.iris.world.task.J;
|
||||||
import art.arcane.volmlib.util.format.Form;
|
import art.arcane.volmlib.util.format.Form;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
import org.bukkit.entity.LivingEntity;
|
import org.bukkit.entity.LivingEntity;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -56,7 +52,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
|||||||
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
|
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
|
||||||
private final AtomicLong teleportSequence = new AtomicLong();
|
private final AtomicLong teleportSequence = new AtomicLong();
|
||||||
private final AtomicReference<VisionTeleportRequest> latestTeleport = new AtomicReference<>();
|
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) {
|
public BukkitVisionOverlay(Engine engine, UUID openerId) {
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
@@ -68,7 +64,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
|||||||
* built by a server thread.
|
* built by a server thread.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public List<WorldMarker> players() {
|
public List<GuiMarker> players() {
|
||||||
queuePlayerRefresh();
|
queuePlayerRefresh();
|
||||||
return playerMarkers;
|
return playerMarkers;
|
||||||
}
|
}
|
||||||
@@ -78,26 +74,48 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean scheduled = J.runGlobal(() -> {
|
boolean scheduled = J.runGlobal(this::refreshPlayers);
|
||||||
try {
|
|
||||||
List<WorldMarker> markers = new ArrayList<>();
|
|
||||||
for (Player player : BukkitWorldBinding.players(engine.getWorld())) {
|
|
||||||
Location at = player.getLocation();
|
|
||||||
markers.add(WorldMarker.player(player.getName(), at.getX(), at.getZ()));
|
|
||||||
}
|
|
||||||
playerMarkers = List.copyOf(markers);
|
|
||||||
} finally {
|
|
||||||
playerRefreshQueued.set(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!scheduled) {
|
if (!scheduled) {
|
||||||
playerRefreshQueued.set(false);
|
playerRefreshQueued.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void refreshPlayers() {
|
||||||
|
World world = BukkitWorldBinding.world(engine.getWorld());
|
||||||
|
List<Player> players = BukkitWorldBinding.players(engine.getWorld());
|
||||||
|
if (players.isEmpty() || world == null) {
|
||||||
|
playerMarkers = List.of();
|
||||||
|
playerRefreshQueued.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<GuiMarker> markers = Collections.synchronizedList(new ArrayList<>(players.size()));
|
||||||
|
AtomicInteger pending = new AtomicInteger(players.size());
|
||||||
|
for (Player player : players) {
|
||||||
|
AtomicBoolean completed = new AtomicBoolean();
|
||||||
|
Runnable complete = () -> {
|
||||||
|
if (completed.compareAndSet(false, true) && pending.decrementAndGet() == 0) {
|
||||||
|
playerMarkers = List.copyOf(markers);
|
||||||
|
playerRefreshQueued.set(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!J.runEntity(player, () -> {
|
||||||
|
try {
|
||||||
|
if (player.isOnline() && player.getWorld() == world) {
|
||||||
|
Location at = player.getLocation();
|
||||||
|
markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ(), at.getYaw()));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
complete.run();
|
||||||
|
}
|
||||||
|
}, 0, complete)) {
|
||||||
|
complete.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void requestEntities(Consumer<List<WorldMarker>> sink) {
|
public void requestEntities(Consumer<List<GuiMarker>> sink) {
|
||||||
J.runGlobal(() -> {
|
J.runGlobal(() -> {
|
||||||
IrisWorld target = engine.getWorld();
|
IrisWorld target = engine.getWorld();
|
||||||
World world = BukkitWorldBinding.world(target);
|
World world = BukkitWorldBinding.world(target);
|
||||||
@@ -118,7 +136,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
|||||||
return;
|
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());
|
AtomicInteger pending = new AtomicInteger(living.size());
|
||||||
Runnable complete = () -> {
|
Runnable complete = () -> {
|
||||||
if (pending.decrementAndGet() == 0) {
|
if (pending.decrementAndGet() == 0) {
|
||||||
@@ -144,22 +162,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", " "));
|
String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " "));
|
||||||
double maxHealth = 0;
|
double maxHealth = 0;
|
||||||
try {
|
try {
|
||||||
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
|
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
|
||||||
} catch (Throwable ignored) {
|
} 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
|
@Override
|
||||||
public void teleport(double worldX, double worldZ) {
|
public void teleport(double worldX, double worldZ) {
|
||||||
VisionTeleportRequest request = new VisionTeleportRequest(
|
VisionTeleportRequest request = new VisionTeleportRequest(
|
||||||
teleportSequence.incrementAndGet(),
|
teleportSequence.incrementAndGet(),
|
||||||
VisionGUI.floorWorldCoordinate(worldX),
|
(int) StrictMath.floor(worldX),
|
||||||
VisionGUI.floorWorldCoordinate(worldZ));
|
(int) StrictMath.floor(worldZ));
|
||||||
latestTeleport.set(request);
|
latestTeleport.set(request);
|
||||||
startTeleport(request);
|
startTeleport(request);
|
||||||
}
|
}
|
||||||
@@ -212,6 +230,46 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
|
||||||
|
AtomicBoolean completed = new AtomicBoolean();
|
||||||
|
Consumer<OpenerPosition> complete = position -> {
|
||||||
|
if (completed.compareAndSet(false, true)) {
|
||||||
|
sink.accept(position);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (openerId == null) {
|
||||||
|
complete.accept(OpenerPosition.OFFLINE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean scheduled = J.runGlobal(() -> {
|
||||||
|
Player player = Bukkit.getPlayer(openerId);
|
||||||
|
if (player == null) {
|
||||||
|
complete.accept(OpenerPosition.OFFLINE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Runnable retired = () -> complete.accept(OpenerPosition.OFFLINE);
|
||||||
|
if (!J.runEntity(player, () -> complete.accept(positionOf(player,
|
||||||
|
BukkitWorldBinding.world(engine.getWorld()))), 0, retired)) {
|
||||||
|
retired.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!scheduled) {
|
||||||
|
complete.accept(OpenerPosition.OFFLINE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OpenerPosition positionOf(Player player, World world) {
|
||||||
|
if (!player.isOnline()) {
|
||||||
|
return OpenerPosition.OFFLINE;
|
||||||
|
}
|
||||||
|
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) {
|
private Player selectPlayer(List<Player> players) {
|
||||||
if (openerId == null) {
|
if (openerId == null) {
|
||||||
return players.isEmpty() ? null : players.get(0);
|
return players.isEmpty() ? null : players.get(0);
|
||||||
@@ -316,21 +374,6 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
|||||||
+ blockX + "," + blockZ + ".", failure);
|
+ 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 static final class VisionTeleportRequest {
|
||||||
private final long sequence;
|
private final long sequence;
|
||||||
private final int blockX;
|
private final int blockX;
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package art.arcane.iris.command;
|
||||||
|
|
||||||
|
import art.arcane.iris.configuration.IrisSettings;
|
||||||
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
|
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
|
||||||
|
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
|
||||||
|
import art.arcane.iris.studio.map.MapWindow;
|
||||||
|
import art.arcane.iris.studio.map.PaletteExplorerStart;
|
||||||
|
import art.arcane.iris.studio.view.GuiHost;
|
||||||
|
import art.arcane.iris.world.IrisToolbelt;
|
||||||
|
import art.arcane.iris.world.task.J;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.same;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
public class StudioMapPositionTest {
|
||||||
|
@Test
|
||||||
|
public void mapCapturesItsStartingPositionOnThePlayerScheduler() {
|
||||||
|
IrisSettings previous = IrisSettings.settings;
|
||||||
|
IrisSettings.settings = new IrisSettings();
|
||||||
|
IrisSettings.settings.getGui().setUseServerLaunchedGuis(true);
|
||||||
|
World world = mock(World.class);
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
VolmitSender sender = mock(VolmitSender.class);
|
||||||
|
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
|
||||||
|
Engine engine = mock(Engine.class);
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
AtomicBoolean owned = new AtomicBoolean();
|
||||||
|
AtomicReference<Runnable> task = new AtomicReference<>();
|
||||||
|
AtomicReference<PaletteExplorerStart> start = new AtomicReference<>();
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.isOnline()).thenReturn(true);
|
||||||
|
when(player.getWorld()).thenAnswer(invocation -> {
|
||||||
|
assertTrue("world read must use the player scheduler", owned.get());
|
||||||
|
return world;
|
||||||
|
});
|
||||||
|
when(player.getLocation()).thenAnswer(invocation -> {
|
||||||
|
assertTrue("location read must use the player scheduler", owned.get());
|
||||||
|
return new Location(world, 33.25D, 91D, -72.5D);
|
||||||
|
});
|
||||||
|
when(sender.isPlayer()).thenReturn(true);
|
||||||
|
when(sender.player()).thenReturn(player);
|
||||||
|
when(generator.getEngine()).thenReturn(engine);
|
||||||
|
CommandStudio command = new CommandStudio() {
|
||||||
|
@Override
|
||||||
|
public VolmitSender sender() {
|
||||||
|
return sender;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try (MockedStatic<GuiHost> host = mockStatic(GuiHost.class);
|
||||||
|
MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class);
|
||||||
|
MockedStatic<J> scheduling = mockStatic(J.class);
|
||||||
|
MockedStatic<MapWindow> windows = mockStatic(MapWindow.class)) {
|
||||||
|
host.when(GuiHost::isAvailable).thenReturn(true);
|
||||||
|
toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true);
|
||||||
|
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
|
||||||
|
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
task.set(invocation.getArgument(1));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
windows.when(() -> MapWindow.openMap(any(PaletteExplorerStart.class))).thenAnswer(invocation -> {
|
||||||
|
start.set(invocation.getArgument(0));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
command.map(world);
|
||||||
|
assertNull(start.get());
|
||||||
|
owned.set(true);
|
||||||
|
task.get().run();
|
||||||
|
|
||||||
|
assertEquals(engine, start.get().engine());
|
||||||
|
assertEquals(playerId, start.get().playerId());
|
||||||
|
assertEquals(worldId, start.get().worldId());
|
||||||
|
assertEquals(33.25D, start.get().centerX(), 0D);
|
||||||
|
assertEquals(-72.5D, start.get().centerZ(), 0D);
|
||||||
|
} finally {
|
||||||
|
IrisSettings.settings = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
package art.arcane.iris.studio.view;
|
||||||
|
|
||||||
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
|
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||||
|
import art.arcane.iris.world.IrisWorld;
|
||||||
|
import art.arcane.iris.world.task.J;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.same;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
public class BukkitOpenerPositionTest {
|
||||||
|
@Test
|
||||||
|
public void playerMarkersAreCapturedOnTheirEntitySchedulers() {
|
||||||
|
try (Harness harness = new Harness()) {
|
||||||
|
harness.binding.when(() -> BukkitWorldBinding.players(harness.target)).thenReturn(List.of(harness.player));
|
||||||
|
when(harness.player.getName()).thenReturn("Explorer");
|
||||||
|
assertTrue(harness.overlay.players().isEmpty());
|
||||||
|
|
||||||
|
harness.runEntity();
|
||||||
|
|
||||||
|
List<GuiMarker> markers = harness.overlay.players();
|
||||||
|
assertEquals(1, markers.size());
|
||||||
|
assertEquals("Explorer", markers.getFirst().label());
|
||||||
|
assertEquals(18.5D, markers.getFirst().worldX(), 0D);
|
||||||
|
assertEquals(-4.25D, markers.getFirst().worldZ(), 0D);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void positionIsCapturedOnlyOnTheOwningEntityTask() {
|
||||||
|
try (Harness harness = new Harness()) {
|
||||||
|
harness.overlay.requestOpenerPosition(harness.results::add);
|
||||||
|
assertTrue(harness.results.isEmpty());
|
||||||
|
|
||||||
|
harness.runEntity();
|
||||||
|
|
||||||
|
assertEquals(List.of(OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void retiredEntityCompletesTheRequestOnce() {
|
||||||
|
try (Harness harness = new Harness()) {
|
||||||
|
harness.overlay.requestOpenerPosition(harness.results::add);
|
||||||
|
assertTrue("retirement callback must be registered", harness.retired != null);
|
||||||
|
|
||||||
|
harness.retired.run();
|
||||||
|
harness.retired.run();
|
||||||
|
|
||||||
|
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void disconnectBeforeCaptureCompletesAsOfflineAndReconnectCanRefresh() {
|
||||||
|
try (Harness harness = new Harness()) {
|
||||||
|
harness.overlay.requestOpenerPosition(harness.results::add);
|
||||||
|
when(harness.player.isOnline()).thenReturn(false);
|
||||||
|
harness.runEntity();
|
||||||
|
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
|
||||||
|
|
||||||
|
when(harness.player.isOnline()).thenReturn(true);
|
||||||
|
harness.overlay.requestOpenerPosition(harness.results::add);
|
||||||
|
harness.runEntity();
|
||||||
|
assertEquals(List.of(OpenerPosition.OFFLINE, OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void rejectedEntityTaskCompletesOnceEvenIfRetirementAlreadyRan() {
|
||||||
|
try (Harness harness = new Harness()) {
|
||||||
|
harness.reject = true;
|
||||||
|
harness.overlay.requestOpenerPosition(harness.results::add);
|
||||||
|
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void changingWorldBeforeCaptureReportsOtherWorld() {
|
||||||
|
try (Harness harness = new Harness()) {
|
||||||
|
harness.overlay.requestOpenerPosition(harness.results::add);
|
||||||
|
when(harness.player.getWorld()).thenReturn(mock(World.class));
|
||||||
|
harness.runEntity();
|
||||||
|
assertEquals(List.of(OpenerPosition.IN_OTHER_WORLD), harness.results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Harness implements AutoCloseable {
|
||||||
|
private final Player player = mock(Player.class);
|
||||||
|
private final World world = mock(World.class);
|
||||||
|
private final Engine engine = mock(Engine.class);
|
||||||
|
private final IrisWorld target = mock(IrisWorld.class);
|
||||||
|
private final UUID opener = UUID.randomUUID();
|
||||||
|
private final List<OpenerPosition> results = new ArrayList<>();
|
||||||
|
private final MockedStatic<J> scheduling = mockStatic(J.class);
|
||||||
|
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||||
|
private final MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class);
|
||||||
|
private final BukkitVisionOverlay overlay = new BukkitVisionOverlay(engine, opener);
|
||||||
|
private Runnable task;
|
||||||
|
private Runnable retired;
|
||||||
|
private boolean entityOwned;
|
||||||
|
private boolean reject;
|
||||||
|
|
||||||
|
private Harness() {
|
||||||
|
when(engine.getWorld()).thenReturn(target);
|
||||||
|
when(player.isOnline()).thenReturn(true);
|
||||||
|
when(player.getWorld()).thenReturn(world);
|
||||||
|
when(player.getLocation()).thenAnswer(invocation -> {
|
||||||
|
assertTrue("location read outside owning entity task", entityOwned);
|
||||||
|
return new Location(world, 18.5D, 72D, -4.25D);
|
||||||
|
});
|
||||||
|
bukkit.when(() -> Bukkit.getPlayer(opener)).thenReturn(player);
|
||||||
|
binding.when(() -> BukkitWorldBinding.world(target)).thenReturn(world);
|
||||||
|
scheduling.when(() -> J.runGlobal(any(Runnable.class))).thenAnswer(invocation -> {
|
||||||
|
invocation.getArgument(0, Runnable.class).run();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenAnswer(invocation -> {
|
||||||
|
task = invocation.getArgument(1);
|
||||||
|
return !reject;
|
||||||
|
});
|
||||||
|
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
task = invocation.getArgument(1);
|
||||||
|
retired = invocation.getArgument(3);
|
||||||
|
if (reject) {
|
||||||
|
retired.run();
|
||||||
|
}
|
||||||
|
return !reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runEntity() {
|
||||||
|
entityOwned = true;
|
||||||
|
try {
|
||||||
|
task.run();
|
||||||
|
} finally {
|
||||||
|
entityOwned = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
binding.close();
|
||||||
|
bukkit.close();
|
||||||
|
scheduling.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,8 @@ configurations.named('bundle').configure {
|
|||||||
exclude(group: 'de.crazydev22.slimjar.helper')
|
exclude(group: 'de.crazydev22.slimjar.helper')
|
||||||
exclude(group: 'de.crazydev22.slimjar')
|
exclude(group: 'de.crazydev22.slimjar')
|
||||||
exclude(group: 'io.github.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)
|
configurations.compileClasspath.extendsFrom(configurations.devBundle)
|
||||||
@@ -216,7 +218,9 @@ dependencies {
|
|||||||
libs.caffeine,
|
libs.caffeine,
|
||||||
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
||||||
libs.dom4j,
|
libs.dom4j,
|
||||||
libs.jaxen
|
libs.jaxen,
|
||||||
|
libs.flatlaf,
|
||||||
|
libs.flatlaf.extras
|
||||||
]
|
]
|
||||||
shared.each { Object notation ->
|
shared.each { Object notation ->
|
||||||
['bundle', 'devBundle'].each { String configuration ->
|
['bundle', 'devBundle'].each { String configuration ->
|
||||||
|
|||||||
@@ -152,12 +152,16 @@ configurations.named('bundle').configure {
|
|||||||
exclude(group: 'de.crazydev22.slimjar.helper')
|
exclude(group: 'de.crazydev22.slimjar.helper')
|
||||||
exclude(group: 'de.crazydev22.slimjar')
|
exclude(group: 'de.crazydev22.slimjar')
|
||||||
exclude(group: 'io.github.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 {
|
configurations.named('runBundle').configure {
|
||||||
exclude(group: 'de.crazydev22.slimjar.helper')
|
exclude(group: 'de.crazydev22.slimjar.helper')
|
||||||
exclude(group: 'de.crazydev22.slimjar')
|
exclude(group: 'de.crazydev22.slimjar')
|
||||||
exclude(group: 'io.github.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)
|
configurations.compileClasspath.extendsFrom(configurations.devBundle)
|
||||||
@@ -215,7 +219,9 @@ dependencies {
|
|||||||
libs.caffeine,
|
libs.caffeine,
|
||||||
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
||||||
libs.dom4j,
|
libs.dom4j,
|
||||||
libs.jaxen
|
libs.jaxen,
|
||||||
|
libs.flatlaf,
|
||||||
|
libs.flatlaf.extras
|
||||||
]
|
]
|
||||||
shared.each { Object notation ->
|
shared.each { Object notation ->
|
||||||
['bundle', 'devBundle', 'runBundle'].each { String configuration ->
|
['bundle', 'devBundle', 'runBundle'].each { String configuration ->
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ public final class ModdedEngineBootstrap {
|
|||||||
failure = runStopStage(failure, "wand service", ModdedWandService::clearAll);
|
failure = runStopStage(failure, "wand service", ModdedWandService::clearAll);
|
||||||
failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear);
|
failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear);
|
||||||
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::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, "gui host", ModdedGuiHost::clear);
|
||||||
failure = runStopStage(failure, "services", () -> services().disableAll());
|
failure = runStopStage(failure, "services", () -> services().disableAll());
|
||||||
reportStopFailure("stopping", failure);
|
reportStopFailure("stopping", failure);
|
||||||
|
|||||||
+19
-9
@@ -23,10 +23,9 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldView;
|
|||||||
|
|
||||||
import art.arcane.iris.modded.ModdedIrisLog;
|
import art.arcane.iris.modded.ModdedIrisLog;
|
||||||
import art.arcane.iris.configuration.IrisSettings;
|
import art.arcane.iris.configuration.IrisSettings;
|
||||||
|
import art.arcane.iris.studio.map.MapWindow;
|
||||||
|
import art.arcane.iris.studio.map.PaletteExplorerStart;
|
||||||
import art.arcane.iris.studio.view.GuiHost;
|
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.loading.IrisData;
|
||||||
import art.arcane.iris.pack.StructurePackageClosure;
|
import art.arcane.iris.pack.StructurePackageClosure;
|
||||||
import art.arcane.iris.pack.ImageMapPackageClosure;
|
import art.arcane.iris.pack.ImageMapPackageClosure;
|
||||||
@@ -38,6 +37,7 @@ import art.arcane.iris.generation.biome.IrisBiome;
|
|||||||
import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink;
|
import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink;
|
||||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||||
import art.arcane.iris.world.entity.IrisEntitySpawn;
|
import art.arcane.iris.world.entity.IrisEntitySpawn;
|
||||||
|
import art.arcane.iris.world.IrisWorld;
|
||||||
import art.arcane.iris.generation.noise.IrisGenerator;
|
import art.arcane.iris.generation.noise.IrisGenerator;
|
||||||
import art.arcane.iris.pack.PackExportClosure;
|
import art.arcane.iris.pack.PackExportClosure;
|
||||||
import art.arcane.iris.world.entity.IrisEntity;
|
import art.arcane.iris.world.entity.IrisEntity;
|
||||||
@@ -247,8 +247,9 @@ public final class ModdedStudioCommands {
|
|||||||
NativeProtocolPlayer player = source.player();
|
NativeProtocolPlayer player = source.player();
|
||||||
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
|
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
|
||||||
}
|
}
|
||||||
if (generatorKey == null || generatorKey.isBlank()) {
|
Engine mapEngine = engine != null ? engine : GuiHost.get().findActiveEngine();
|
||||||
NoiseExplorerGUI.launch();
|
if ((generatorKey == null || generatorKey.isBlank()) && mapEngine != null) {
|
||||||
|
MapWindow.openNoise(mapStart(mapEngine, source.player()), new MapWindow.NoiseSelection(null, null, seed));
|
||||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_ON_SERVER_DISPLAY));
|
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_ON_SERVER_DISPLAY));
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -262,7 +263,7 @@ public final class ModdedStudioCommands {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
String selectedGeneratorKey = generatorKey.trim();
|
String selectedGeneratorKey = generatorKey.trim();
|
||||||
NoiseExplorerGUI.launchGeneratorKey(selectedGeneratorKey, generator, seed);
|
MapWindow.openNoise(mapStart(engine, source.player()), new MapWindow.NoiseSelection(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)));
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -280,11 +281,20 @@ public final class ModdedStudioCommands {
|
|||||||
}
|
}
|
||||||
NativeProtocolPlayer player = source.player();
|
NativeProtocolPlayer player = source.player();
|
||||||
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
|
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())));
|
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_VISION_MAP_ON_SERVER_DISPLAY, MessageArgument.untrusted("value", level.name())));
|
||||||
return 1;
|
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) {
|
||||||
|
IrisWorld world = engine.getWorld();
|
||||||
|
NativeWorld level = world == null ? null : world.platformWorld();
|
||||||
|
boolean inWorld = player != null && player.connected() && level != null && player.inWorld(level);
|
||||||
|
return new PaletteExplorerStart(engine, null, player == null ? null : player.id(), null,
|
||||||
|
inWorld ? player.x() : 0D, inWorld ? player.z() : 0D);
|
||||||
|
}
|
||||||
|
|
||||||
private static int imageMap(NativeCommandSource source) {
|
private static int imageMap(NativeCommandSource source) {
|
||||||
NativeWorld level = source.world();
|
NativeWorld level = source.world();
|
||||||
Engine engine = IrisModdedCommands.engineFor(level);
|
Engine engine = IrisModdedCommands.engineFor(level);
|
||||||
@@ -294,13 +304,13 @@ public final class ModdedStudioCommands {
|
|||||||
));
|
));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (!ImageMapStudioGUI.isAvailable()) {
|
if (!GuiHost.isAvailable() || !IrisSettings.get().getGui().isUseServerLaunchedGuis()) {
|
||||||
IrisModdedCommands.fail(source, guiUnavailableMessage());
|
IrisModdedCommands.fail(source, guiUnavailableMessage());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
NativeProtocolPlayer player = source.player();
|
NativeProtocolPlayer player = source.player();
|
||||||
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
|
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(
|
IrisModdedCommands.ok(source, IrisLanguage.plain(
|
||||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_IMAGE_MAP_STUDIO_ON_SERVER_DISPLAY,
|
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_IMAGE_MAP_STUDIO_ON_SERVER_DISPLAY,
|
||||||
MessageArgument.untrusted("value", level.name())
|
MessageArgument.untrusted("value", level.name())
|
||||||
|
|||||||
+48
-26
@@ -18,21 +18,22 @@
|
|||||||
|
|
||||||
package art.arcane.iris.modded.command;
|
package art.arcane.iris.modded.command;
|
||||||
|
|
||||||
import art.arcane.iris.studio.view.GuiHost;
|
import art.arcane.iris.studio.view.GuiMarker;
|
||||||
import art.arcane.volmlib.nativelib.view.WorldMarker;
|
|
||||||
import art.arcane.iris.studio.view.GuiOverlay;
|
import art.arcane.iris.studio.view.GuiOverlay;
|
||||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
import art.arcane.iris.studio.view.OpenerPosition;
|
||||||
import art.arcane.iris.generation.runtime.Engine;
|
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.ModdedIrisLog;
|
||||||
import art.arcane.iris.modded.ModdedTeleportDeadline;
|
import art.arcane.iris.modded.ModdedTeleportDeadline;
|
||||||
|
import art.arcane.iris.world.IrisWorld;
|
||||||
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||||
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||||
|
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||||
|
import art.arcane.volmlib.nativelib.view.WorldView;
|
||||||
|
|
||||||
import java.awt.Desktop;
|
|
||||||
import java.io.File;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import art.arcane.volmlib.nativelib.view.WorldView;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public final class ModdedVisionOverlay implements GuiOverlay {
|
public final class ModdedVisionOverlay implements GuiOverlay {
|
||||||
@@ -46,14 +47,33 @@ public final class ModdedVisionOverlay implements GuiOverlay {
|
|||||||
opener = context.opener();
|
opener = context.opener();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorldView doesn't report which way a player faces, so their markers all face south.
|
||||||
@Override
|
@Override
|
||||||
public List<WorldMarker> players() {
|
public List<GuiMarker> players() {
|
||||||
return world.players();
|
return world.players().stream()
|
||||||
|
.map(player -> GuiMarker.player(player.label(), player.worldX(), player.worldZ(), 0F))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void requestEntities(Consumer<List<WorldMarker>> sink) {
|
public void requestEntities(Consumer<List<GuiMarker>> sink) {
|
||||||
world.requestEntities(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
|
||||||
|
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
|
||||||
|
if (opener == null) {
|
||||||
|
sink.accept(OpenerPosition.OFFLINE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
world.execute(() -> sink.accept(openerPosition()));
|
||||||
|
} catch (RejectedExecutionException failure) {
|
||||||
|
sink.accept(OpenerPosition.OFFLINE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -80,23 +100,25 @@ public final class ModdedVisionOverlay implements GuiOverlay {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private OpenerPosition openerPosition() {
|
||||||
public String openInEditor(double worldX, double worldZ, RenderType type) {
|
IrisWorld target = engine.getWorld();
|
||||||
if (!GuiHost.isAvailable() || !Desktop.isDesktopSupported()) {
|
if (target == null || engine.isClosing() || engine.isClosed()) {
|
||||||
return null;
|
return OpenerPosition.OFFLINE;
|
||||||
}
|
}
|
||||||
IrisComplex complex = engine.getComplex();
|
NativeWorld level = target.platformWorld();
|
||||||
File file = switch (type) {
|
if (level == null) {
|
||||||
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
|
return OpenerPosition.OFFLINE;
|
||||||
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
|
}
|
||||||
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
|
NativeProtocolPlayer player = NativeModdedServer.forWorld(level).player(opener);
|
||||||
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
|
if (player == null || !player.connected()) {
|
||||||
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
|
return OpenerPosition.OFFLINE;
|
||||||
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
|
}
|
||||||
default -> null;
|
if (!player.inWorld(level)) {
|
||||||
};
|
return OpenerPosition.IN_OTHER_WORLD;
|
||||||
return file == null ? null : file.getName();
|
}
|
||||||
|
return OpenerPosition.inWorld(player.x(), player.y(), player.z());
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Context(WorldView world, Engine engine, UUID opener) {
|
public record Context(WorldView world, Engine engine, UUID opener) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+73
@@ -1,25 +1,98 @@
|
|||||||
package art.arcane.iris.modded.command;
|
package art.arcane.iris.modded.command;
|
||||||
|
|
||||||
import art.arcane.iris.generation.runtime.Engine;
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
|
import art.arcane.iris.studio.view.OpenerPosition;
|
||||||
|
import art.arcane.iris.world.IrisWorld;
|
||||||
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||||
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||||
|
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||||
import art.arcane.volmlib.nativelib.view.WorldView;
|
import art.arcane.volmlib.nativelib.view.WorldView;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
import org.mockito.invocation.InvocationOnMock;
|
import org.mockito.invocation.InvocationOnMock;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.doAnswer;
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
public class ModdedVisionOverlayTest {
|
public class ModdedVisionOverlayTest {
|
||||||
|
@Test
|
||||||
|
public void rejectedServerTaskCompletesThePositionRequestAsOffline() {
|
||||||
|
WorldView world = mock(WorldView.class);
|
||||||
|
doThrow(new RejectedExecutionException("Server stopped")).when(world).execute(any(Runnable.class));
|
||||||
|
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world,
|
||||||
|
mock(Engine.class), UUID.randomUUID()));
|
||||||
|
AtomicReference<OpenerPosition> position = new AtomicReference<>();
|
||||||
|
|
||||||
|
overlay.requestOpenerPosition(position::set);
|
||||||
|
|
||||||
|
assertEquals(OpenerPosition.OFFLINE, position.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void openerPositionRunsOnTheServerAndTracksDisconnectAndWorldChanges() {
|
||||||
|
WorldView world = mock(WorldView.class);
|
||||||
|
AtomicReference<Runnable> scheduled = new AtomicReference<>();
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
scheduled.set(invocation.getArgument(0));
|
||||||
|
return null;
|
||||||
|
}).when(world).execute(any(Runnable.class));
|
||||||
|
Engine engine = mock(Engine.class);
|
||||||
|
IrisWorld irisWorld = mock(IrisWorld.class);
|
||||||
|
NativeWorld level = mock(NativeWorld.class);
|
||||||
|
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||||
|
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
|
||||||
|
UUID opener = UUID.randomUUID();
|
||||||
|
when(engine.getWorld()).thenReturn(irisWorld);
|
||||||
|
when(irisWorld.platformWorld()).thenReturn(level);
|
||||||
|
when(server.player(opener)).thenReturn(player);
|
||||||
|
when(player.connected()).thenReturn(true);
|
||||||
|
when(player.inWorld(level)).thenReturn(true);
|
||||||
|
when(player.x()).thenReturn(21.5D);
|
||||||
|
when(player.y()).thenReturn(85.25D);
|
||||||
|
when(player.z()).thenReturn(-39.5D);
|
||||||
|
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world, engine, opener));
|
||||||
|
AtomicReference<OpenerPosition> position = new AtomicReference<>();
|
||||||
|
try (MockedStatic<NativeModdedServer> nativeServer = mockStatic(NativeModdedServer.class)) {
|
||||||
|
nativeServer.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
|
||||||
|
|
||||||
|
overlay.requestOpenerPosition(position::set);
|
||||||
|
assertNull("position reads must wait for the server task", position.get());
|
||||||
|
scheduled.get().run();
|
||||||
|
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
|
||||||
|
|
||||||
|
overlay.requestOpenerPosition(position::set);
|
||||||
|
when(player.connected()).thenReturn(false);
|
||||||
|
scheduled.get().run();
|
||||||
|
assertEquals(OpenerPosition.OFFLINE, position.get());
|
||||||
|
|
||||||
|
when(player.connected()).thenReturn(true);
|
||||||
|
when(player.inWorld(level)).thenReturn(false);
|
||||||
|
overlay.requestOpenerPosition(position::set);
|
||||||
|
scheduled.get().run();
|
||||||
|
assertEquals(OpenerPosition.IN_OTHER_WORLD, position.get());
|
||||||
|
|
||||||
|
when(player.inWorld(level)).thenReturn(true);
|
||||||
|
overlay.requestOpenerPosition(position::set);
|
||||||
|
scheduled.get().run();
|
||||||
|
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Vision click can land anywhere in the world, so the destination chunk is usually cold and gets the same
|
* A Vision click can land anywhere in the world, so the destination chunk is usually cold and gets the same
|
||||||
* bounded deadline as /iris tp.
|
* bounded deadline as /iris tp.
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ configurations.named('bundle').configure {
|
|||||||
exclude(group: 'de.crazydev22.slimjar.helper')
|
exclude(group: 'de.crazydev22.slimjar.helper')
|
||||||
exclude(group: 'de.crazydev22.slimjar')
|
exclude(group: 'de.crazydev22.slimjar')
|
||||||
exclude(group: 'io.github.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)
|
configurations.compileClasspath.extendsFrom(configurations.devBundle)
|
||||||
@@ -173,7 +175,9 @@ dependencies {
|
|||||||
libs.caffeine,
|
libs.caffeine,
|
||||||
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
'com.fulcrumgenomics:jlibdeflate:0.2.0',
|
||||||
libs.dom4j,
|
libs.dom4j,
|
||||||
libs.jaxen
|
libs.jaxen,
|
||||||
|
libs.flatlaf,
|
||||||
|
libs.flatlaf.extras
|
||||||
]
|
]
|
||||||
add('bundle', libs.asm)
|
add('bundle', libs.asm)
|
||||||
add('devBundle', 'org.ow2.asm:asm') {
|
add('devBundle', 'org.ow2.asm:asm') {
|
||||||
|
|||||||
@@ -136,6 +136,13 @@ dependencies {
|
|||||||
implementation('com.moandjiezana.toml:toml4j:0.7.2') {
|
implementation('com.moandjiezana.toml:toml4j:0.7.2') {
|
||||||
transitive = false
|
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
|
// Dynamically Loaded
|
||||||
slim(libs.gson)
|
slim(libs.gson)
|
||||||
slim(libs.lru)
|
slim(libs.lru)
|
||||||
@@ -161,6 +168,8 @@ dependencies {
|
|||||||
slim(libs.byteBuddy.agent)
|
slim(libs.byteBuddy.agent)
|
||||||
slim(libs.dom4j)
|
slim(libs.dom4j)
|
||||||
slim(libs.jaxen)
|
slim(libs.jaxen)
|
||||||
|
slim(libs.flatlaf)
|
||||||
|
slim(libs.flatlaf.extras)
|
||||||
|
|
||||||
testFixturesImplementation('junit:junit:4.13.2')
|
testFixturesImplementation('junit:junit:4.13.2')
|
||||||
testFixturesImplementation(libs.mockito.core)
|
testFixturesImplementation(libs.mockito.core)
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ public class IrisSettings {
|
|||||||
public boolean useServerLaunchedGuis = true;
|
public boolean useServerLaunchedGuis = true;
|
||||||
public boolean maximumPregenGuiFPS = false;
|
public boolean maximumPregenGuiFPS = false;
|
||||||
public boolean colorMode = true;
|
public boolean colorMode = true;
|
||||||
|
public String theme = "auto";
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@@ -580,6 +580,10 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
|
|||||||
return IrisBiomeLayerGenerator.getLayerSeaHeightGenerators(this, rng, data);
|
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) {
|
public NativeBlockState getSurfaceBlock(int x, int z, RNG rng, IrisData idm) {
|
||||||
return IrisBiomeLayerGenerator.getSurfaceBlock(this, x, z, rng, idm);
|
return IrisBiomeLayerGenerator.getSurfaceBlock(this, x, z, rng, idm);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,8 +148,7 @@ final class IrisBiomeLayerGenerator {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
KList<CNG> heightGenerators = getHeightGenerators(
|
KList<CNG> heightGenerators = getCaveCeilingLayerHeightGenerators(biome, random, rdata);
|
||||||
biome.getLayerCeilingHeightGenerators(), ceilingLayers, 7235, random, rdata);
|
|
||||||
|
|
||||||
for (int i = 0; i < layerCount; i++) {
|
for (int i = 0; i < layerCount; i++) {
|
||||||
IrisBiomePaletteLayer layer = ceilingLayers.get(i);
|
IrisBiomePaletteLayer layer = ceilingLayers.get(i);
|
||||||
@@ -317,6 +316,10 @@ final class IrisBiomeLayerGenerator {
|
|||||||
return getHeightGenerators(biome.getLayerSeaHeightGenerators(), biome.getSeaLayers(), 7735, rng, data);
|
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,
|
private static KList<CNG> getHeightGenerators(IrisBiomeLayerHeightCache cache,
|
||||||
KList<IrisBiomePaletteLayer> layers,
|
KList<IrisBiomePaletteLayer> layers,
|
||||||
int seedOffset, RNG rng, IrisData data) {
|
int seedOffset, RNG rng, IrisData data) {
|
||||||
|
|||||||
@@ -1112,33 +1112,118 @@ public class IrisComplex implements DataProvider {
|
|||||||
InferredType inferredType,
|
InferredType inferredType,
|
||||||
IrisBiome emptyBiome
|
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) {
|
return switch (inferredType) {
|
||||||
case CAVE -> new RarityStream<>(engine.getDimension().getCaveBiomeStyle()
|
case CAVE -> dimension.getCaveBiomeStyle()
|
||||||
.create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
|
.create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
|
||||||
.zoom(engine.getDimension().getBiomeZoom())
|
.zoom(dimension.getBiomeZoom())
|
||||||
.zoom(region.getCaveBiomeZoom()),
|
.zoom(region.getCaveBiomeZoom());
|
||||||
loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE))
|
case LAND -> dimension.getLandBiomeStyle()
|
||||||
.onNull(emptyBiome);
|
|
||||||
case LAND -> new RarityStream<>(engine.getDimension().getLandBiomeStyle()
|
|
||||||
.create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream()
|
.create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream()
|
||||||
.zoom(engine.getDimension().getBiomeZoom())
|
.zoom(dimension.getBiomeZoom())
|
||||||
.zoom(engine.getDimension().getLandZoom())
|
.zoom(dimension.getLandZoom())
|
||||||
.zoom(region.getLandBiomeZoom()),
|
.zoom(region.getLandBiomeZoom());
|
||||||
loadInferredBiomes(region.getLandBiomes(), InferredType.LAND));
|
case SEA -> dimension.getSeaBiomeStyle()
|
||||||
case SEA -> new RarityStream<>(engine.getDimension().getSeaBiomeStyle()
|
|
||||||
.create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream()
|
.create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream()
|
||||||
.zoom(engine.getDimension().getBiomeZoom())
|
.zoom(dimension.getBiomeZoom())
|
||||||
.zoom(engine.getDimension().getSeaZoom())
|
.zoom(dimension.getSeaZoom())
|
||||||
.zoom(region.getSeaBiomeZoom()),
|
.zoom(region.getSeaBiomeZoom());
|
||||||
loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA));
|
case SHORE -> dimension.getShoreBiomeStyle()
|
||||||
case SHORE -> new RarityStream<>(engine.getDimension().getShoreBiomeStyle()
|
|
||||||
.create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream()
|
.create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream()
|
||||||
.zoom(engine.getDimension().getBiomeZoom())
|
.zoom(dimension.getBiomeZoom())
|
||||||
.zoom(region.getShoreBiomeZoom()),
|
.zoom(region.getShoreBiomeZoom());
|
||||||
loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE));
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 Map<String, ProceduralStream<NativeBlockState>> createWaterFluidStreams(Map<String, IrisMaterialPalette> fluids) {
|
private Map<String, ProceduralStream<NativeBlockState>> createWaterFluidStreams(Map<String, IrisMaterialPalette> fluids) {
|
||||||
Map<String, ProceduralStream<NativeBlockState>> streams = new HashMap<>();
|
Map<String, ProceduralStream<NativeBlockState>> streams = new HashMap<>();
|
||||||
int index = 0;
|
int index = 0;
|
||||||
|
|||||||
@@ -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,221 @@
|
|||||||
|
/*
|
||||||
|
* 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();
|
||||||
|
return switch (palette.kind()) {
|
||||||
|
case LAYER -> new StackedLayer(palette.layerIn(biome), index, biome.getLayerHeightGenerators(terrainRandom, data),
|
||||||
|
terrainRandom, data);
|
||||||
|
case SEA_LAYER -> new StackedLayer(palette.layerIn(biome), index, biome.getLayerSeaHeightGenerators(terrainRandom, data),
|
||||||
|
terrainRandom, data);
|
||||||
|
case CAVE_CEILING_LAYER -> new StackedLayer(palette.layerIn(biome), index,
|
||||||
|
biome.getCaveCeilingLayerHeightGenerators(carveRandom, data), carveRandom, data);
|
||||||
|
case WALL -> new SingleBlocks(palette.layerIn(biome), data, carveRandom, seaLevel);
|
||||||
|
case SLAB -> new SingleBlocks(palette.layerIn(biome), 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,381 @@
|
|||||||
|
/*
|
||||||
|
* 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.GLX_BLUE_SIZE;
|
||||||
|
import static org.lwjgl.opengl.GLX13.GLX_DEPTH_SIZE;
|
||||||
|
import static org.lwjgl.opengl.GLX13.GLX_DOUBLEBUFFER;
|
||||||
|
import static org.lwjgl.opengl.GLX13.GLX_DRAWABLE_TYPE;
|
||||||
|
import static org.lwjgl.opengl.GLX13.GLX_GREEN_SIZE;
|
||||||
|
import static org.lwjgl.opengl.GLX13.GLX_RED_SIZE;
|
||||||
|
import static org.lwjgl.opengl.GLX13.GLX_RENDER_TYPE;
|
||||||
|
import static org.lwjgl.opengl.GLX13.GLX_RGBA_BIT;
|
||||||
|
import static org.lwjgl.opengl.GLX13.GLX_VISUAL_ID;
|
||||||
|
import static org.lwjgl.opengl.GLX13.GLX_WINDOW_BIT;
|
||||||
|
import static org.lwjgl.opengl.GLX13.GLX_X_RENDERABLE;
|
||||||
|
import static org.lwjgl.opengl.GLX13.glXChooseFBConfig;
|
||||||
|
import static org.lwjgl.opengl.GLX13.glXGetFBConfigAttrib;
|
||||||
|
import static org.lwjgl.opengl.GLX13.glXMakeContextCurrent;
|
||||||
|
import static org.lwjgl.opengl.GLXARBCreateContext.GLX_CONTEXT_MAJOR_VERSION_ARB;
|
||||||
|
import static org.lwjgl.opengl.GLXARBCreateContext.GLX_CONTEXT_MINOR_VERSION_ARB;
|
||||||
|
import static org.lwjgl.opengl.GLXARBCreateContext.glXCreateContextAttribsARB;
|
||||||
|
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,187 @@
|
|||||||
|
/*
|
||||||
|
* 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.JAWT_DrawingSurface_FreeDrawingSurfaceInfo;
|
||||||
|
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_DrawingSurface_GetDrawingSurfaceInfo;
|
||||||
|
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_DrawingSurface_Lock;
|
||||||
|
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_DrawingSurface_Unlock;
|
||||||
|
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_FreeDrawingSurface;
|
||||||
|
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_GetAWT;
|
||||||
|
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_GetDrawingSurface;
|
||||||
|
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_LOCK_ERROR;
|
||||||
|
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_VERSION_1_4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,75 @@
|
|||||||
|
/*
|
||||||
|
* 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.GL_BGRA;
|
||||||
|
import static org.lwjgl.opengl.GL12.GL_NEAREST;
|
||||||
|
import static org.lwjgl.opengl.GL12.GL_RGBA8;
|
||||||
|
import static org.lwjgl.opengl.GL12.GL_TEXTURE_2D;
|
||||||
|
import static org.lwjgl.opengl.GL12.GL_TEXTURE_MAG_FILTER;
|
||||||
|
import static org.lwjgl.opengl.GL12.GL_TEXTURE_MIN_FILTER;
|
||||||
|
import static org.lwjgl.opengl.GL12.GL_UNSIGNED_INT_8_8_8_8_REV;
|
||||||
|
import static org.lwjgl.opengl.GL12.glBindTexture;
|
||||||
|
import static org.lwjgl.opengl.GL12.glDeleteTextures;
|
||||||
|
import static org.lwjgl.opengl.GL12.glGenTextures;
|
||||||
|
import static org.lwjgl.opengl.GL12.glTexImage2D;
|
||||||
|
import static org.lwjgl.opengl.GL12.glTexParameteri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,301 @@
|
|||||||
|
/*
|
||||||
|
* 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.EGL_CONTEXT_MAJOR_VERSION;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_CONTEXT_MINOR_VERSION;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_CONTEXT_OPENGL_PROFILE_MASK;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_EXTENSIONS;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_NONE;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_NO_CONTEXT;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_NO_DISPLAY;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_NO_SURFACE;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_OPENGL_API;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_OPENGL_BIT;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_PBUFFER_BIT;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_RENDERABLE_TYPE;
|
||||||
|
import static org.lwjgl.egl.EGL15.EGL_SURFACE_TYPE;
|
||||||
|
import static org.lwjgl.egl.EGL15.eglBindAPI;
|
||||||
|
import static org.lwjgl.egl.EGL15.eglChooseConfig;
|
||||||
|
import static org.lwjgl.egl.EGL15.eglCreateContext;
|
||||||
|
import static org.lwjgl.egl.EGL15.eglDestroyContext;
|
||||||
|
import static org.lwjgl.egl.EGL15.eglInitialize;
|
||||||
|
import static org.lwjgl.egl.EGL15.eglMakeCurrent;
|
||||||
|
import static org.lwjgl.egl.EGL15.eglQueryString;
|
||||||
|
import static org.lwjgl.egl.EGL15.eglReleaseThread;
|
||||||
|
import static org.lwjgl.egl.EGL15.eglTerminate;
|
||||||
|
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,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 org.lwjgl.system.MemoryUtil;
|
||||||
|
|
||||||
|
import java.nio.IntBuffer;
|
||||||
|
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_RGBA32I;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_STATIC_DRAW;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_TEXTURE_BUFFER;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBindBuffer;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBindTexture;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBufferData;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBufferSubData;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDeleteBuffers;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDeleteTextures;
|
||||||
|
import static org.lwjgl.opengl.GL33.glGenBuffers;
|
||||||
|
import static org.lwjgl.opengl.GL33.glGenTextures;
|
||||||
|
import static org.lwjgl.opengl.GL33.glTexBuffer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,90 @@
|
|||||||
|
/*
|
||||||
|
* 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.GL_BGRA;
|
||||||
|
import static org.lwjgl.opengl.GL30.GL_COLOR_ATTACHMENT0;
|
||||||
|
import static org.lwjgl.opengl.GL30.GL_DEPTH_ATTACHMENT;
|
||||||
|
import static org.lwjgl.opengl.GL30.GL_DEPTH_COMPONENT24;
|
||||||
|
import static org.lwjgl.opengl.GL30.GL_FRAMEBUFFER;
|
||||||
|
import static org.lwjgl.opengl.GL30.GL_FRAMEBUFFER_COMPLETE;
|
||||||
|
import static org.lwjgl.opengl.GL30.GL_RENDERBUFFER;
|
||||||
|
import static org.lwjgl.opengl.GL30.GL_RGBA8;
|
||||||
|
import static org.lwjgl.opengl.GL30.GL_UNSIGNED_INT_8_8_8_8_REV;
|
||||||
|
import static org.lwjgl.opengl.GL30.glBindFramebuffer;
|
||||||
|
import static org.lwjgl.opengl.GL30.glBindRenderbuffer;
|
||||||
|
import static org.lwjgl.opengl.GL30.glCheckFramebufferStatus;
|
||||||
|
import static org.lwjgl.opengl.GL30.glFramebufferRenderbuffer;
|
||||||
|
import static org.lwjgl.opengl.GL30.glGenFramebuffers;
|
||||||
|
import static org.lwjgl.opengl.GL30.glGenRenderbuffers;
|
||||||
|
import static org.lwjgl.opengl.GL30.glReadPixels;
|
||||||
|
import static org.lwjgl.opengl.GL30.glRenderbufferStorage;
|
||||||
|
import static org.lwjgl.opengl.GL30.glViewport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,191 @@
|
|||||||
|
/*
|
||||||
|
* 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.GL_BLEND;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_COLOR_BUFFER_BIT;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_CULL_FACE;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_DEPTH_BUFFER_BIT;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_DEPTH_TEST;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_LESS;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_NO_ERROR;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_POLYGON_OFFSET_FILL;
|
||||||
|
import static org.lwjgl.opengl.GL33.glClear;
|
||||||
|
import static org.lwjgl.opengl.GL33.glClearColor;
|
||||||
|
import static org.lwjgl.opengl.GL33.glClearDepth;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDepthFunc;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDepthMask;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDisable;
|
||||||
|
import static org.lwjgl.opengl.GL33.glEnable;
|
||||||
|
import static org.lwjgl.opengl.GL33.glGetError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(screen.seeThrough()));
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
gpu.stopDrawingIntoWindows(failure);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the last frame drawn into the view's window on screen. Another view may have drawn into its own window
|
||||||
|
* since, so the view's window is made current again first.
|
||||||
|
*
|
||||||
|
* @return false when the window didn't take it, and the caller should draw a picture instead
|
||||||
|
*/
|
||||||
|
static boolean showOnScreen(ScreenTarget screen) {
|
||||||
|
return onCard(gpu -> {
|
||||||
|
Optional<GlxDisplay> windows = gpu.windows();
|
||||||
|
long window = screen.canvas().window();
|
||||||
|
if (windows.isEmpty() || window == 0L) {
|
||||||
|
return Optional.<Boolean>empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!windows.get().drawInto(window) || !windows.get().show(window)) {
|
||||||
|
throw new IllegalStateException("The window didn't take the frame");
|
||||||
|
}
|
||||||
|
return Optional.of(true);
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
gpu.stopDrawingIntoWindows(failure);
|
||||||
|
return Optional.<Boolean>empty();
|
||||||
|
}
|
||||||
|
}).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <T> Optional<T> onCard(CardWork<T> 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<T> {
|
||||||
|
Optional<T> drawOn(GpuContext gpu);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* 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 and its native bindings can load. 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Class.forName("org.lwjgl.system.jawt.JAWTFunctions", true, loader);
|
||||||
|
return true;
|
||||||
|
} catch (ClassNotFoundException | LinkageError | SecurityException unavailable) {
|
||||||
|
IrisLogging.info("3D views draw without a graphics card: native bindings are unavailable (%s)", unavailable);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/*
|
||||||
|
* 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.GL_R32I;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_RGBA32F;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_RGBA8;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_STATIC_DRAW;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_STREAM_DRAW;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_TEXTURE_BUFFER;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBindBuffer;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBindTexture;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBufferData;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBufferSubData;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDeleteBuffers;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDeleteTextures;
|
||||||
|
import static org.lwjgl.opengl.GL33.glGenBuffers;
|
||||||
|
import static org.lwjgl.opengl.GL33.glGenTextures;
|
||||||
|
import static org.lwjgl.opengl.GL33.glTexBuffer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,300 @@
|
|||||||
|
/*
|
||||||
|
* 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.GL_BLEND;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_LEQUAL;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_LINES;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_MAX_TEXTURE_BUFFER_SIZE;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_ONE;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_ONE_MINUS_SRC_ALPHA;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_SRC_ALPHA;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_TEXTURE0;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_TEXTURE1;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_TEXTURE2;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_TRIANGLES;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_ZERO;
|
||||||
|
import static org.lwjgl.opengl.GL33.glActiveTexture;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBindVertexArray;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBlendFuncSeparate;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDepthFunc;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDepthMask;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDisable;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDrawArrays;
|
||||||
|
import static org.lwjgl.opengl.GL33.glEnable;
|
||||||
|
import static org.lwjgl.opengl.GL33.glGenVertexArrays;
|
||||||
|
import static org.lwjgl.opengl.GL33.glGetInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,131 @@
|
|||||||
|
/*
|
||||||
|
* 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.GL_COMPILE_STATUS;
|
||||||
|
import static org.lwjgl.opengl.GL20.GL_FALSE;
|
||||||
|
import static org.lwjgl.opengl.GL20.GL_FRAGMENT_SHADER;
|
||||||
|
import static org.lwjgl.opengl.GL20.GL_LINK_STATUS;
|
||||||
|
import static org.lwjgl.opengl.GL20.GL_VERTEX_SHADER;
|
||||||
|
import static org.lwjgl.opengl.GL20.glAttachShader;
|
||||||
|
import static org.lwjgl.opengl.GL20.glCompileShader;
|
||||||
|
import static org.lwjgl.opengl.GL20.glCreateProgram;
|
||||||
|
import static org.lwjgl.opengl.GL20.glCreateShader;
|
||||||
|
import static org.lwjgl.opengl.GL20.glDeleteProgram;
|
||||||
|
import static org.lwjgl.opengl.GL20.glDeleteShader;
|
||||||
|
import static org.lwjgl.opengl.GL20.glGetProgramInfoLog;
|
||||||
|
import static org.lwjgl.opengl.GL20.glGetProgrami;
|
||||||
|
import static org.lwjgl.opengl.GL20.glGetShaderInfoLog;
|
||||||
|
import static org.lwjgl.opengl.GL20.glGetShaderi;
|
||||||
|
import static org.lwjgl.opengl.GL20.glGetUniformLocation;
|
||||||
|
import static org.lwjgl.opengl.GL20.glLinkProgram;
|
||||||
|
import static org.lwjgl.opengl.GL20.glShaderSource;
|
||||||
|
import static org.lwjgl.opengl.GL20.glUniform1f;
|
||||||
|
import static org.lwjgl.opengl.GL20.glUniform1i;
|
||||||
|
import static org.lwjgl.opengl.GL20.glUniform1iv;
|
||||||
|
import static org.lwjgl.opengl.GL20.glUniform2i;
|
||||||
|
import static org.lwjgl.opengl.GL20.glUniform3f;
|
||||||
|
import static org.lwjgl.opengl.GL20.glUniform3i;
|
||||||
|
import static org.lwjgl.opengl.GL20.glUniform4f;
|
||||||
|
import static org.lwjgl.opengl.GL20.glUniformMatrix4fv;
|
||||||
|
import static org.lwjgl.opengl.GL20.glUseProgram;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,400 @@
|
|||||||
|
/*
|
||||||
|
* 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.GL_BLEND;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_MAX_TEXTURE_BUFFER_SIZE;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_MAX_TEXTURE_SIZE;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_ONE_MINUS_SRC_ALPHA;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_POLYGON_OFFSET_FILL;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_SRC_ALPHA;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_TEXTURE0;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_TEXTURE1;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_TRIANGLES;
|
||||||
|
import static org.lwjgl.opengl.GL33.GL_TRIANGLE_STRIP;
|
||||||
|
import static org.lwjgl.opengl.GL33.glActiveTexture;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBindVertexArray;
|
||||||
|
import static org.lwjgl.opengl.GL33.glBlendFunc;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDepthMask;
|
||||||
|
import static org.lwjgl.opengl.GL33.glDrawArrays;
|
||||||
|
import static org.lwjgl.opengl.GL33.glEnable;
|
||||||
|
import static org.lwjgl.opengl.GL33.glGenVertexArrays;
|
||||||
|
import static org.lwjgl.opengl.GL33.glGetInteger;
|
||||||
|
import static org.lwjgl.opengl.GL33.glPolygonOffset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.GL_BACK;
|
||||||
|
import static org.lwjgl.opengl.GL30.GL_BGRA;
|
||||||
|
import static org.lwjgl.opengl.GL30.GL_FRAMEBUFFER;
|
||||||
|
import static org.lwjgl.opengl.GL30.GL_UNSIGNED_INT_8_8_8_8_REV;
|
||||||
|
import static org.lwjgl.opengl.GL30.glBindFramebuffer;
|
||||||
|
import static org.lwjgl.opengl.GL30.glDrawBuffer;
|
||||||
|
import static org.lwjgl.opengl.GL30.glReadBuffer;
|
||||||
|
import static org.lwjgl.opengl.GL30.glReadPixels;
|
||||||
|
import static org.lwjgl.opengl.GL30.glViewport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. The frame waits in the window's back buffer until {@link GpuFrames#showOnScreen},
|
||||||
|
* so it can go on screen together with its patches. Only for the GPU thread.
|
||||||
|
*/
|
||||||
|
final class GpuWindowFrame implements GpuFrameTarget {
|
||||||
|
private static final int WINDOW_FRAMEBUFFER = 0;
|
||||||
|
|
||||||
|
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(List<Rectangle> seeThrough) {
|
||||||
|
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) {
|
||||||
|
return OrbitPicture.onScreen(width, height, readPatches(), GpuFrameTarget.depthInMiddle(projection, depthRange, width, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
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.pack.loading.IrisData;
|
||||||
import art.arcane.iris.localization.DesktopUiMessages;
|
import art.arcane.iris.localization.DesktopUiMessages;
|
||||||
import art.arcane.iris.localization.IrisLanguage;
|
import art.arcane.iris.localization.IrisLanguage;
|
||||||
@@ -32,8 +31,8 @@ import javax.swing.DefaultCellEditor;
|
|||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JCheckBox;
|
import javax.swing.JCheckBox;
|
||||||
import javax.swing.JComboBox;
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JFileChooser;
|
import javax.swing.JFileChooser;
|
||||||
import javax.swing.JFrame;
|
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JScrollPane;
|
import javax.swing.JScrollPane;
|
||||||
@@ -47,17 +46,13 @@ import javax.swing.filechooser.FileNameExtensionFilter;
|
|||||||
import javax.swing.table.DefaultTableModel;
|
import javax.swing.table.DefaultTableModel;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.CardLayout;
|
import java.awt.CardLayout;
|
||||||
import java.awt.Color;
|
|
||||||
import java.awt.Component;
|
import java.awt.Component;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
import java.awt.EventQueue;
|
|
||||||
import java.awt.FlowLayout;
|
import java.awt.FlowLayout;
|
||||||
import java.awt.Font;
|
|
||||||
import java.awt.GridBagConstraints;
|
import java.awt.GridBagConstraints;
|
||||||
import java.awt.GridBagLayout;
|
import java.awt.GridBagLayout;
|
||||||
|
import java.awt.GridLayout;
|
||||||
import java.awt.Insets;
|
import java.awt.Insets;
|
||||||
import java.awt.event.WindowAdapter;
|
|
||||||
import java.awt.event.WindowEvent;
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -66,25 +61,23 @@ import java.util.List;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.function.DoubleBinaryOperator;
|
import java.util.function.DoubleBinaryOperator;
|
||||||
|
|
||||||
public final class ImageMapStudioGUI {
|
/**
|
||||||
private static final Color BACKGROUND = new Color(12, 15, 22);
|
* The map window's Image maps view: turns a PNG into terrain height, biomes or a mask, previews it on the world and
|
||||||
private static final Color SIDEBAR_BACKGROUND = new Color(17, 20, 29);
|
* exports it into the pack.
|
||||||
private static final Color PANEL_BACKGROUND = new Color(23, 27, 38);
|
*/
|
||||||
private static final Color FIELD_BACKGROUND = new Color(29, 34, 47);
|
final class ImageMapExplorer {
|
||||||
private static final Color PRIMARY_TEXT = new Color(230, 234, 242);
|
private static final int EDITOR_WIDTH = 470;
|
||||||
private static final Color SECONDARY_TEXT = new Color(151, 160, 178);
|
private static final int OVERLAY_COLUMNS = 2;
|
||||||
private static final Color ACCENT = new Color(99, 161, 255);
|
private static final int TABLE_ROW_HEIGHT = 23;
|
||||||
private static final Color BORDER = new Color(48, 55, 72);
|
private static final String STATUS_SEPARATOR = " · ";
|
||||||
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);
|
|
||||||
|
|
||||||
private final Engine engine;
|
private final Engine engine;
|
||||||
private final File packFolder;
|
private final File packFolder;
|
||||||
private final String dimensionKey;
|
private final String dimensionKey;
|
||||||
private final JFrame frame;
|
|
||||||
private final ImageMapStudioPreviewPanel previewPanel = new ImageMapStudioPreviewPanel();
|
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 JComboBox<String> presetPicker = new JComboBox<>();
|
||||||
private final JTextField bindingKeyField = field("image-map");
|
private final JTextField bindingKeyField = field("image-map");
|
||||||
private final JTextField mapKeyField = 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 JPanel typeCards = new JPanel(new CardLayout());
|
||||||
private final JTextArea metadataArea = area(3);
|
private final JTextArea metadataArea = area(3);
|
||||||
private final JTextArea diagnosticsArea = area(5);
|
private final JTextArea diagnosticsArea = area(5);
|
||||||
private final JLabel statusLabel = new JLabel();
|
|
||||||
private final JCheckBox chunksOverlay = selectedCheck();
|
private final JCheckBox chunksOverlay = selectedCheck();
|
||||||
private final JCheckBox regionsOverlay = selectedCheck();
|
private final JCheckBox regionsOverlay = selectedCheck();
|
||||||
private final JCheckBox boundaryOverlay = selectedCheck();
|
private final JCheckBox boundaryOverlay = selectedCheck();
|
||||||
@@ -141,59 +133,60 @@ public final class ImageMapStudioGUI {
|
|||||||
private IrisWorldBoundary boundary;
|
private IrisWorldBoundary boundary;
|
||||||
private boolean controlsUpdating;
|
private boolean controlsUpdating;
|
||||||
|
|
||||||
private ImageMapStudioGUI(Engine engine) {
|
ImageMapExplorer(Engine engine) {
|
||||||
this.engine = Objects.requireNonNull(engine, "Image Map Studio engine");
|
this.engine = Objects.requireNonNull(engine, "Image Map Studio engine");
|
||||||
this.packFolder = engine.getPackSource().toFile();
|
this.packFolder = engine.getPackSource().toFile();
|
||||||
this.dimensionKey = engine.getDimension().getLoadKey();
|
this.dimensionKey = engine.getDimension().getLoadKey();
|
||||||
this.boundary = engine.getDimension().getWorldBoundary() == null
|
this.boundary = engine.getDimension().getWorldBoundary() == null
|
||||||
? null
|
? null
|
||||||
: IrisWorldBoundary.snapshot(engine.getDimension().getWorldBoundary());
|
: IrisWorldBoundary.snapshot(engine.getDimension().getWorldBoundary());
|
||||||
this.frame = new JFrame(text(DesktopUiMessages.IMAGEMAP_TITLE));
|
buildControls();
|
||||||
configureFrame();
|
this.view = buildWorkspace();
|
||||||
|
setStatus(DesktopUiMessages.IMAGEMAP_READY);
|
||||||
configureControls();
|
configureControls();
|
||||||
refreshPresets();
|
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) {
|
static void reloadActiveEngine(Engine engine) {
|
||||||
engine.hotloadSilently();
|
engine.hotloadSilently();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void configureFrame() {
|
JComponent view() {
|
||||||
GuiHost.prepareFrame(frame);
|
return view;
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
JComponent statusBar() {
|
||||||
public void windowClosing(WindowEvent event) {
|
return statusBar;
|
||||||
previewPanel.close();
|
}
|
||||||
}
|
|
||||||
});
|
/**
|
||||||
|
* 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() {
|
private void configureControls() {
|
||||||
@@ -214,90 +207,64 @@ public final class ImageMapStudioGUI {
|
|||||||
updateOverlays();
|
updateOverlays();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void show() {
|
private void buildControls() {
|
||||||
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);
|
|
||||||
presetPicker.setPrototypeDisplayValue("terrain/example-long-preset");
|
presetPicker.setPrototypeDisplayValue("terrain/example-long-preset");
|
||||||
styleCombo(presetPicker);
|
presetPicker.setToolTipText("Image maps already in the pack");
|
||||||
leading.add(presetPicker);
|
presetPicker.setFocusable(false);
|
||||||
JButton load = button(DesktopUiMessages.IMAGEMAP_LOAD);
|
presetPicker.setMaximumSize(presetPicker.getPreferredSize());
|
||||||
load.addActionListener(event -> {
|
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();
|
String selected = (String) presetPicker.getSelectedItem();
|
||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
loadPreset(selected);
|
loadPreset(selected);
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
leading.add(load);
|
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_IMPORT_PNG), "Start a new image map from a PNG",
|
||||||
JButton importButton = button(DesktopUiMessages.IMAGEMAP_IMPORT_PNG);
|
() -> chooseSource(false)));
|
||||||
importButton.addActionListener(event -> chooseSource(false));
|
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_REPLACE_PNG), "Swap the PNG behind this image map",
|
||||||
leading.add(importButton);
|
() -> chooseSource(true)));
|
||||||
JButton replaceButton = button(DesktopUiMessages.IMAGEMAP_REPLACE_PNG);
|
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_PREVIEW), "Preview the image map on the world",
|
||||||
replaceButton.addActionListener(event -> chooseSource(true));
|
this::preview));
|
||||||
leading.add(replaceButton);
|
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_EXPORT), "Write the image map into the pack and hotload",
|
||||||
toolbar.add(leading, BorderLayout.WEST);
|
this::export));
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Component buildWorkspace() {
|
private JSplitPane buildWorkspace() {
|
||||||
JScrollPane editorScroll = new JScrollPane(buildEditor());
|
JScrollPane editorScroll = new JScrollPane(buildEditor());
|
||||||
editorScroll.setBorder(BorderFactory.createEmptyBorder());
|
editorScroll.setBorder(BorderFactory.createEmptyBorder());
|
||||||
editorScroll.getVerticalScrollBar().setUnitIncrement(18);
|
editorScroll.getVerticalScrollBar().setUnitIncrement(StudioTheme.scale(18));
|
||||||
editorScroll.setPreferredSize(new Dimension(470, 760));
|
editorScroll.setPreferredSize(StudioTheme.scaledSize(EDITOR_WIDTH, 760));
|
||||||
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, editorScroll, previewPanel);
|
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, editorScroll, previewPanel);
|
||||||
split.setBorder(BorderFactory.createEmptyBorder());
|
split.setBorder(BorderFactory.createEmptyBorder());
|
||||||
split.setDividerSize(5);
|
|
||||||
split.setResizeWeight(0D);
|
split.setResizeWeight(0D);
|
||||||
split.setDividerLocation(470);
|
split.setDividerLocation(StudioTheme.scale(EDITOR_WIDTH));
|
||||||
|
split.putClientProperty("FlatLaf.style", "style: grip");
|
||||||
return split;
|
return split;
|
||||||
}
|
}
|
||||||
|
|
||||||
private JPanel buildEditor() {
|
private JPanel buildEditor() {
|
||||||
JPanel editor = new JPanel();
|
JPanel editor = new ViewportWidthPanel();
|
||||||
editor.setLayout(new BoxLayout(editor, BoxLayout.Y_AXIS));
|
editor.setLayout(new BoxLayout(editor, BoxLayout.Y_AXIS));
|
||||||
editor.setBackground(SIDEBAR_BACKGROUND);
|
editor.setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||||
editor.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
|
editor.setBorder(StudioTheme.emptyBorder(10, 10, 10, 10));
|
||||||
editor.add(section(DesktopUiMessages.IMAGEMAP_METADATA, metadataPanel()));
|
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(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(section(DesktopUiMessages.IMAGEMAP_COORDINATES, coordinatePanel()));
|
||||||
editor.add(Box.createVerticalStrut(9));
|
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
|
||||||
typeCards.add(heightPanel(), "HEIGHT");
|
typeCards.add(heightPanel(), "HEIGHT");
|
||||||
typeCards.add(colorPanel(), "COLOR");
|
typeCards.add(colorPanel(), "COLOR");
|
||||||
typeCards.add(maskPanel(), "MASK");
|
typeCards.add(maskPanel(), "MASK");
|
||||||
typeCards.setBackground(SIDEBAR_BACKGROUND);
|
typeCards.setOpaque(false);
|
||||||
|
typeCards.setAlignmentX(Component.LEFT_ALIGNMENT);
|
||||||
editor.add(typeCards);
|
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(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(section(DesktopUiMessages.IMAGEMAP_OVERLAYS, overlayPanel()));
|
||||||
editor.add(Box.createVerticalStrut(9));
|
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
|
||||||
editor.add(section(DesktopUiMessages.IMAGEMAP_DIAGNOSTICS, diagnosticsPanel()));
|
editor.add(section(DesktopUiMessages.IMAGEMAP_DIAGNOSTICS, diagnosticsPanel()));
|
||||||
return editor;
|
return editor;
|
||||||
}
|
}
|
||||||
@@ -358,18 +325,15 @@ public final class ImageMapStudioGUI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private JPanel colorPanel() {
|
private JPanel colorPanel() {
|
||||||
JPanel panel = new JPanel(new BorderLayout(0, 7));
|
JPanel panel = new JPanel(new BorderLayout(0, StudioTheme.scale(7)));
|
||||||
panel.setOpaque(false);
|
panel.setOpaque(false);
|
||||||
JPanel settings = form();
|
JPanel settings = form();
|
||||||
addRow(settings, 0, DesktopUiMessages.IMAGEMAP_COLOR_TOLERANCE, toleranceField);
|
addRow(settings, 0, DesktopUiMessages.IMAGEMAP_COLOR_TOLERANCE, toleranceField);
|
||||||
addRow(settings, 1, DesktopUiMessages.IMAGEMAP_UNKNOWN_COLOR, unknownColorPicker);
|
addRow(settings, 1, DesktopUiMessages.IMAGEMAP_UNKNOWN_COLOR, unknownColorPicker);
|
||||||
panel.add(settings, BorderLayout.NORTH);
|
panel.add(settings, BorderLayout.NORTH);
|
||||||
configureLegendTable();
|
configureTable(legendTable);
|
||||||
JScrollPane scroll = new JScrollPane(legendTable);
|
panel.add(tableScroll(legendTable, 160), BorderLayout.CENTER);
|
||||||
scroll.setPreferredSize(new Dimension(390, 160));
|
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, StudioTheme.scale(6), 0));
|
||||||
scroll.setBorder(BorderFactory.createLineBorder(BORDER));
|
|
||||||
panel.add(scroll, BorderLayout.CENTER);
|
|
||||||
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
|
|
||||||
actions.setOpaque(false);
|
actions.setOpaque(false);
|
||||||
JButton add = button(DesktopUiMessages.IMAGEMAP_ADD_COLOR);
|
JButton add = button(DesktopUiMessages.IMAGEMAP_ADD_COLOR);
|
||||||
add.addActionListener(event -> legendModel.addRow(new Object[]{"#000000", "iris:"}));
|
add.addActionListener(event -> legendModel.addRow(new Object[]{"#000000", "iris:"}));
|
||||||
@@ -382,7 +346,7 @@ public final class ImageMapStudioGUI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private JPanel overlayPanel() {
|
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);
|
panel.setOpaque(false);
|
||||||
chunksOverlay.setText(text(DesktopUiMessages.IMAGEMAP_CHUNKS));
|
chunksOverlay.setText(text(DesktopUiMessages.IMAGEMAP_CHUNKS));
|
||||||
regionsOverlay.setText(text(DesktopUiMessages.IMAGEMAP_REGIONS));
|
regionsOverlay.setText(text(DesktopUiMessages.IMAGEMAP_REGIONS));
|
||||||
@@ -396,13 +360,10 @@ public final class ImageMapStudioGUI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private JPanel composedMaskPanel() {
|
private JPanel composedMaskPanel() {
|
||||||
JPanel panel = new JPanel(new BorderLayout(0, 7));
|
JPanel panel = new JPanel(new BorderLayout(0, StudioTheme.scale(7)));
|
||||||
panel.setOpaque(false);
|
panel.setOpaque(false);
|
||||||
JScrollPane scroll = new JScrollPane(composedMaskTable);
|
panel.add(tableScroll(composedMaskTable, 120), BorderLayout.CENTER);
|
||||||
scroll.setPreferredSize(new Dimension(390, 120));
|
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, StudioTheme.scale(6), 0));
|
||||||
scroll.setBorder(BorderFactory.createLineBorder(BORDER));
|
|
||||||
panel.add(scroll, BorderLayout.CENTER);
|
|
||||||
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
|
|
||||||
actions.setOpaque(false);
|
actions.setOpaque(false);
|
||||||
JButton add = button(DesktopUiMessages.IMAGEMAP_ADD_MASK);
|
JButton add = button(DesktopUiMessages.IMAGEMAP_ADD_MASK);
|
||||||
add.addActionListener(event -> composedMaskModel.addRow(new Object[]{
|
add.addActionListener(event -> composedMaskModel.addRow(new Object[]{
|
||||||
@@ -421,21 +382,6 @@ public final class ImageMapStudioGUI {
|
|||||||
return single(diagnosticsArea);
|
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() {
|
private void refreshPresets() {
|
||||||
IrisData data = IrisData.openRuntime(packFolder);
|
IrisData data = IrisData.openRuntime(packFolder);
|
||||||
try {
|
try {
|
||||||
@@ -518,7 +464,7 @@ public final class ImageMapStudioGUI {
|
|||||||
? DesktopUiMessages.IMAGEMAP_REPLACE_PNG
|
? DesktopUiMessages.IMAGEMAP_REPLACE_PNG
|
||||||
: DesktopUiMessages.IMAGEMAP_IMPORT_PNG));
|
: DesktopUiMessages.IMAGEMAP_IMPORT_PNG));
|
||||||
chooser.setFileFilter(new FileNameExtensionFilter("PNG", "png"));
|
chooser.setFileFilter(new FileNameExtensionFilter("PNG", "png"));
|
||||||
if (chooser.showOpenDialog(frame) != JFileChooser.APPROVE_OPTION) {
|
if (chooser.showOpenDialog(view) != JFileChooser.APPROVE_OPTION) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sourcePath = chooser.getSelectedFile().toPath().toAbsolutePath().normalize();
|
sourcePath = chooser.getSelectedFile().toPath().toAbsolutePath().normalize();
|
||||||
@@ -626,7 +572,7 @@ public final class ImageMapStudioGUI {
|
|||||||
diagnosticsArea.setText(warnings.isEmpty()
|
diagnosticsArea.setText(warnings.isEmpty()
|
||||||
? text(DesktopUiMessages.IMAGEMAP_PREVIEW_VALID)
|
? text(DesktopUiMessages.IMAGEMAP_PREVIEW_VALID)
|
||||||
: String.join("\n", warnings));
|
: String.join("\n", warnings));
|
||||||
diagnosticsArea.setForeground(warnings.isEmpty() ? SUCCESS : PRIMARY_TEXT);
|
diagnosticsArea.setForeground(warnings.isEmpty() ? StudioTheme.SUCCESS : StudioTheme.TEXT);
|
||||||
setStatus(DesktopUiMessages.IMAGEMAP_READY);
|
setStatus(DesktopUiMessages.IMAGEMAP_READY);
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
showFailure(text(DesktopUiMessages.IMAGEMAP_PREVIEW_FAILED), exception);
|
showFailure(text(DesktopUiMessages.IMAGEMAP_PREVIEW_FAILED), exception);
|
||||||
@@ -682,7 +628,7 @@ public final class ImageMapStudioGUI {
|
|||||||
for (String warning : result.warnings()) {
|
for (String warning : result.warnings()) {
|
||||||
message.append('\n').append(warning);
|
message.append('\n').append(warning);
|
||||||
}
|
}
|
||||||
diagnosticsArea.setForeground(SUCCESS);
|
diagnosticsArea.setForeground(StudioTheme.SUCCESS);
|
||||||
diagnosticsArea.setText(message.toString());
|
diagnosticsArea.setText(message.toString());
|
||||||
setStatus(DesktopUiMessages.IMAGEMAP_EXPORTED);
|
setStatus(DesktopUiMessages.IMAGEMAP_EXPORTED);
|
||||||
refreshPresets();
|
refreshPresets();
|
||||||
@@ -853,35 +799,25 @@ public final class ImageMapStudioGUI {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void configureLegendTable() {
|
private static void configureTable(JTable table) {
|
||||||
legendTable.setBackground(FIELD_BACKGROUND);
|
table.setRowHeight(StudioTheme.scale(TABLE_ROW_HEIGHT));
|
||||||
legendTable.setForeground(PRIMARY_TEXT);
|
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
|
||||||
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 void configureComposedMaskTable() {
|
private void configureComposedMaskTable() {
|
||||||
composedMaskTable.setBackground(FIELD_BACKGROUND);
|
configureTable(composedMaskTable);
|
||||||
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);
|
|
||||||
composedMaskTable.getColumnModel().getColumn(1).setCellEditor(
|
composedMaskTable.getColumnModel().getColumn(1).setCellEditor(
|
||||||
new DefaultCellEditor(new JComboBox<>(IrisImageMapMaskOperation.values()))
|
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() {
|
private void removeLegendRows() {
|
||||||
removeRows(legendTable, legendModel);
|
removeRows(legendTable, legendModel);
|
||||||
}
|
}
|
||||||
@@ -927,7 +863,7 @@ public final class ImageMapStudioGUI {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String current = diagnosticsArea.getText();
|
String current = diagnosticsArea.getText();
|
||||||
diagnosticsArea.setForeground(ERROR);
|
diagnosticsArea.setForeground(StudioTheme.ERROR);
|
||||||
diagnosticsArea.setText(current.isBlank() ? diagnostic : current + "\n" + diagnostic);
|
diagnosticsArea.setText(current.isBlank() ? diagnostic : current + "\n" + diagnostic);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,13 +875,13 @@ public final class ImageMapStudioGUI {
|
|||||||
if (!(cause instanceof IllegalArgumentException)) {
|
if (!(cause instanceof IllegalArgumentException)) {
|
||||||
IrisLogging.reportError(cause);
|
IrisLogging.reportError(cause);
|
||||||
}
|
}
|
||||||
diagnosticsArea.setForeground(ERROR);
|
diagnosticsArea.setForeground(StudioTheme.ERROR);
|
||||||
diagnosticsArea.setText(prefix + ": " + String.valueOf(cause.getMessage()));
|
diagnosticsArea.setText(prefix + ": " + String.valueOf(cause.getMessage()));
|
||||||
statusLabel.setText(prefix);
|
statusBar.showError(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setStatus(TextKey key) {
|
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() {
|
private void requireSource() {
|
||||||
@@ -961,42 +897,41 @@ public final class ImageMapStudioGUI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void addRow(JPanel panel, int row, TextKey key, Component component) {
|
private static void addRow(JPanel panel, int row, TextKey key, Component component) {
|
||||||
|
int rowGap = StudioTheme.scale(3);
|
||||||
GridBagConstraints labelConstraints = new GridBagConstraints();
|
GridBagConstraints labelConstraints = new GridBagConstraints();
|
||||||
labelConstraints.gridx = 0;
|
labelConstraints.gridx = 0;
|
||||||
labelConstraints.gridy = row;
|
labelConstraints.gridy = row;
|
||||||
labelConstraints.anchor = GridBagConstraints.WEST;
|
labelConstraints.anchor = GridBagConstraints.WEST;
|
||||||
labelConstraints.insets = new Insets(3, 0, 3, 8);
|
labelConstraints.insets = new Insets(rowGap, 0, rowGap, StudioTheme.scale(8));
|
||||||
JLabel label = label(key);
|
panel.add(label(key), labelConstraints);
|
||||||
panel.add(label, labelConstraints);
|
|
||||||
|
|
||||||
GridBagConstraints componentConstraints = new GridBagConstraints();
|
GridBagConstraints componentConstraints = new GridBagConstraints();
|
||||||
componentConstraints.gridx = 1;
|
componentConstraints.gridx = 1;
|
||||||
componentConstraints.gridy = row;
|
componentConstraints.gridy = row;
|
||||||
componentConstraints.weightx = 1D;
|
componentConstraints.weightx = 1D;
|
||||||
componentConstraints.fill = GridBagConstraints.HORIZONTAL;
|
componentConstraints.fill = GridBagConstraints.HORIZONTAL;
|
||||||
componentConstraints.insets = new Insets(3, 0, 3, 0);
|
componentConstraints.insets = new Insets(rowGap, 0, rowGap, 0);
|
||||||
panel.add(component, componentConstraints);
|
panel.add(component, componentConstraints);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JPanel section(TextKey title, Component content) {
|
private static JPanel section(TextKey title, Component content) {
|
||||||
JPanel panel = new JPanel(new BorderLayout());
|
JPanel panel = new JPanel(new BorderLayout());
|
||||||
panel.setAlignmentX(Component.LEFT_ALIGNMENT);
|
panel.setAlignmentX(Component.LEFT_ALIGNMENT);
|
||||||
panel.setBackground(PANEL_BACKGROUND);
|
panel.setOpaque(false);
|
||||||
panel.setBorder(BorderFactory.createLineBorder(BORDER));
|
panel.setBorder(BorderFactory.createLineBorder(StudioTheme.CONTROL_BORDER));
|
||||||
JLabel heading = new JLabel(text(title));
|
JLabel heading = new JLabel(text(title));
|
||||||
heading.setForeground(PRIMARY_TEXT);
|
heading.setFont(StudioTheme.sectionFont());
|
||||||
heading.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 12));
|
|
||||||
heading.setBorder(BorderFactory.createCompoundBorder(
|
heading.setBorder(BorderFactory.createCompoundBorder(
|
||||||
BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER),
|
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.CONTROL_BORDER),
|
||||||
BorderFactory.createEmptyBorder(7, 9, 7, 9)
|
StudioTheme.emptyBorder(7, 9, 7, 9)
|
||||||
));
|
));
|
||||||
panel.add(heading, BorderLayout.NORTH);
|
panel.add(heading, BorderLayout.NORTH);
|
||||||
JPanel body = new JPanel(new BorderLayout());
|
JPanel body = new JPanel(new BorderLayout());
|
||||||
body.setOpaque(false);
|
body.setOpaque(false);
|
||||||
body.setBorder(BorderFactory.createEmptyBorder(8, 9, 9, 9));
|
body.setBorder(StudioTheme.emptyBorder(8, 9, 9, 9));
|
||||||
body.add(content, BorderLayout.CENTER);
|
body.add(content, BorderLayout.CENTER);
|
||||||
panel.add(body, 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;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1009,34 +944,19 @@ public final class ImageMapStudioGUI {
|
|||||||
|
|
||||||
private static JLabel label(TextKey key) {
|
private static JLabel label(TextKey key) {
|
||||||
JLabel label = new JLabel(text(key));
|
JLabel label = new JLabel(text(key));
|
||||||
label.setForeground(SECONDARY_TEXT);
|
label.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||||
label.setFont(LABEL_FONT);
|
label.setFont(StudioTheme.smallFont());
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JButton button(TextKey key) {
|
private static JButton button(TextKey key) {
|
||||||
JButton button = new JButton(text(key));
|
JButton button = new JButton(text(key));
|
||||||
button.setBackground(FIELD_BACKGROUND);
|
button.setFocusable(false);
|
||||||
button.setForeground(PRIMARY_TEXT);
|
|
||||||
button.setFocusPainted(false);
|
|
||||||
button.setBorder(BorderFactory.createCompoundBorder(
|
|
||||||
BorderFactory.createLineBorder(BORDER),
|
|
||||||
BorderFactory.createEmptyBorder(5, 9, 5, 9)
|
|
||||||
));
|
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JTextField field(String value) {
|
private static JTextField field(String value) {
|
||||||
JTextField field = new JTextField(value);
|
return 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JTextArea area(int rows) {
|
private static JTextArea area(int rows) {
|
||||||
@@ -1044,18 +964,14 @@ public final class ImageMapStudioGUI {
|
|||||||
area.setEditable(false);
|
area.setEditable(false);
|
||||||
area.setLineWrap(true);
|
area.setLineWrap(true);
|
||||||
area.setWrapStyleWord(true);
|
area.setWrapStyleWord(true);
|
||||||
area.setBackground(FIELD_BACKGROUND);
|
area.setBorder(StudioTheme.emptyBorder(6, 7, 6, 7));
|
||||||
area.setForeground(PRIMARY_TEXT);
|
|
||||||
area.setFont(BODY_FONT);
|
|
||||||
area.setBorder(BorderFactory.createEmptyBorder(6, 7, 6, 7));
|
|
||||||
return area;
|
return area;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JCheckBox check() {
|
private static JCheckBox check() {
|
||||||
JCheckBox check = new JCheckBox();
|
JCheckBox check = new JCheckBox();
|
||||||
check.setOpaque(false);
|
check.setOpaque(false);
|
||||||
check.setForeground(PRIMARY_TEXT);
|
check.setFocusable(false);
|
||||||
check.setFocusPainted(false);
|
|
||||||
return check;
|
return check;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1067,15 +983,8 @@ public final class ImageMapStudioGUI {
|
|||||||
|
|
||||||
private static <T> JComboBox<T> combo(T[] values) {
|
private static <T> JComboBox<T> combo(T[] values) {
|
||||||
JComboBox<T> combo = new JComboBox<>(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);
|
combo.setFocusable(false);
|
||||||
|
return combo;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double number(JTextField field, String name) {
|
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.IrisImage;
|
||||||
import art.arcane.iris.generation.image.IrisImageMap;
|
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.DesktopUiMessages;
|
||||||
import art.arcane.iris.localization.IrisLanguage;
|
import art.arcane.iris.localization.IrisLanguage;
|
||||||
@@ -40,11 +40,9 @@ import java.util.function.Consumer;
|
|||||||
import java.util.function.DoubleBinaryOperator;
|
import java.util.function.DoubleBinaryOperator;
|
||||||
|
|
||||||
final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
||||||
private static final Color BACKGROUND = new Color(12, 15, 22);
|
// The images and their overlay lines are drawn for a dark backdrop, in either theme.
|
||||||
private static final Color PANEL_BACKGROUND = new Color(20, 24, 33);
|
private static final Color CANVAS_BACKGROUND = new Color(12, 15, 22);
|
||||||
private static final Color BORDER = new Color(48, 55, 72);
|
private static final Color CANVAS_TEXT = new Color(151, 160, 178);
|
||||||
private static final Color TEXT = new Color(230, 234, 242);
|
|
||||||
private static final Color SECONDARY = new Color(151, 160, 178);
|
|
||||||
|
|
||||||
private final SourceCanvas sourceCanvas = new SourceCanvas();
|
private final SourceCanvas sourceCanvas = new SourceCanvas();
|
||||||
private final WorldCanvas worldCanvas = new WorldCanvas();
|
private final WorldCanvas worldCanvas = new WorldCanvas();
|
||||||
@@ -52,21 +50,19 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
|||||||
|
|
||||||
ImageMapStudioPreviewPanel() {
|
ImageMapStudioPreviewPanel() {
|
||||||
super(new BorderLayout());
|
super(new BorderLayout());
|
||||||
setBackground(BACKGROUND);
|
setBackground(StudioTheme.PANEL_BACKGROUND);
|
||||||
JPanel source = wrap(IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_SOURCE), sourceCanvas);
|
JPanel source = wrap(IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_SOURCE), sourceCanvas);
|
||||||
JPanel interpreted = wrap(IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_INTERPRETED), worldCanvas);
|
JPanel interpreted = wrap(IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_INTERPRETED), worldCanvas);
|
||||||
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, source, interpreted);
|
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, source, interpreted);
|
||||||
split.setBorder(BorderFactory.createEmptyBorder());
|
split.setBorder(BorderFactory.createEmptyBorder());
|
||||||
split.setDividerSize(5);
|
|
||||||
split.setResizeWeight(0.42D);
|
split.setResizeWeight(0.42D);
|
||||||
split.setBackground(BACKGROUND);
|
split.putClientProperty("FlatLaf.style", "style: grip");
|
||||||
add(split, BorderLayout.CENTER);
|
add(split, BorderLayout.CENTER);
|
||||||
statusLabel.setForeground(SECONDARY);
|
statusLabel.setForeground(StudioTheme.SECONDARY_TEXT);
|
||||||
statusLabel.setBackground(PANEL_BACKGROUND);
|
statusLabel.setFont(StudioTheme.smallFont());
|
||||||
statusLabel.setOpaque(true);
|
|
||||||
statusLabel.setBorder(BorderFactory.createCompoundBorder(
|
statusLabel.setBorder(BorderFactory.createCompoundBorder(
|
||||||
BorderFactory.createMatteBorder(1, 0, 0, 0, BORDER),
|
BorderFactory.createMatteBorder(1, 0, 0, 0, StudioTheme.SEPARATOR),
|
||||||
BorderFactory.createEmptyBorder(5, 9, 5, 9)
|
StudioTheme.emptyBorder(5, 9, 5, 9)
|
||||||
));
|
));
|
||||||
add(statusLabel, BorderLayout.SOUTH);
|
add(statusLabel, BorderLayout.SOUTH);
|
||||||
worldCanvas.setHoverConsumer(statusLabel::setText);
|
worldCanvas.setHoverConsumer(statusLabel::setText);
|
||||||
@@ -123,15 +119,13 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
|||||||
|
|
||||||
private static JPanel wrap(String title, JPanel canvas) {
|
private static JPanel wrap(String title, JPanel canvas) {
|
||||||
JPanel panel = new JPanel(new BorderLayout());
|
JPanel panel = new JPanel(new BorderLayout());
|
||||||
panel.setBackground(PANEL_BACKGROUND);
|
panel.setOpaque(false);
|
||||||
panel.setBorder(BorderFactory.createLineBorder(BORDER));
|
panel.setBorder(BorderFactory.createLineBorder(StudioTheme.SEPARATOR));
|
||||||
JLabel label = new JLabel(title);
|
JLabel label = new JLabel(title);
|
||||||
label.setForeground(TEXT);
|
label.setFont(StudioTheme.sectionFont());
|
||||||
label.setBackground(PANEL_BACKGROUND);
|
|
||||||
label.setOpaque(true);
|
|
||||||
label.setBorder(BorderFactory.createCompoundBorder(
|
label.setBorder(BorderFactory.createCompoundBorder(
|
||||||
BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER),
|
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR),
|
||||||
BorderFactory.createEmptyBorder(6, 9, 6, 9)
|
StudioTheme.emptyBorder(6, 9, 6, 9)
|
||||||
));
|
));
|
||||||
panel.add(label, BorderLayout.NORTH);
|
panel.add(label, BorderLayout.NORTH);
|
||||||
panel.add(canvas, BorderLayout.CENTER);
|
panel.add(canvas, BorderLayout.CENTER);
|
||||||
@@ -142,7 +136,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
|||||||
private BufferedImage source;
|
private BufferedImage source;
|
||||||
|
|
||||||
private SourceCanvas() {
|
private SourceCanvas() {
|
||||||
setBackground(BACKGROUND);
|
setBackground(CANVAS_BACKGROUND);
|
||||||
setPreferredSize(new Dimension(420, 520));
|
setPreferredSize(new Dimension(420, 520));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +211,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
|||||||
private double dragCenterZ;
|
private double dragCenterZ;
|
||||||
|
|
||||||
private WorldCanvas() {
|
private WorldCanvas() {
|
||||||
setBackground(BACKGROUND);
|
setBackground(CANVAS_BACKGROUND);
|
||||||
setPreferredSize(new Dimension(620, 520));
|
setPreferredSize(new Dimension(620, 520));
|
||||||
MouseAdapter mouse = new MouseAdapter() {
|
MouseAdapter mouse = new MouseAdapter() {
|
||||||
@Override
|
@Override
|
||||||
@@ -414,7 +408,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
|||||||
);
|
);
|
||||||
case COLOR_MAP -> categoricalApplication()
|
case COLOR_MAP -> categoricalApplication()
|
||||||
&& !IrisImageMapRuntime.selectCategorical(maskWeight)
|
&& !IrisImageMapRuntime.selectCategorical(maskWeight)
|
||||||
? BACKGROUND.getRGB()
|
? CANVAS_BACKGROUND.getRGB()
|
||||||
: ImageMapStudioModel.targetColor(compiled.sampleTarget(worldX, worldZ));
|
: ImageMapStudioModel.targetColor(compiled.sampleTarget(worldX, worldZ));
|
||||||
case BINARY_MASK, GRAYSCALE_MASK, ALPHA_MASK -> grayscale(
|
case BINARY_MASK, GRAYSCALE_MASK, ALPHA_MASK -> grayscale(
|
||||||
compiled.sampleNormalized(worldX, worldZ)
|
compiled.sampleNormalized(worldX, worldZ)
|
||||||
@@ -603,7 +597,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
|
|||||||
private static void drawEmpty(Graphics graphics, String text) {
|
private static void drawEmpty(Graphics graphics, String text) {
|
||||||
Graphics2D canvas = (Graphics2D) graphics.create();
|
Graphics2D canvas = (Graphics2D) graphics.create();
|
||||||
try {
|
try {
|
||||||
canvas.setColor(SECONDARY);
|
canvas.setColor(CANVAS_TEXT);
|
||||||
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||||
int width = canvas.getFontMetrics().stringWidth(text);
|
int width = canvas.getFontMetrics().stringWidth(text);
|
||||||
int x = Math.max(8, (graphics.getClipBounds() == null ? 0 : graphics.getClipBounds().width - width) / 2);
|
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,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 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),
|
||||||
|
OBJECT_LOAD("Object Load", "Configuration estimate of object density and chance, not measured performance",
|
||||||
|
Group.DIAGNOSTICS, TileType.OBJECT_LOAD, KeyEvent.VK_F6),
|
||||||
|
DECORATOR_LOAD("Decorator Load", "Configuration estimate of decorator chance and stacks, not measured performance",
|
||||||
|
Group.DIAGNOSTICS, TileType.DECORATOR_LOAD, KeyEvent.VK_F7),
|
||||||
|
LAYER_LOAD("Layer Load", "Configuration estimate of biome layer count, not measured performance",
|
||||||
|
Group.DIAGNOSTICS, TileType.LAYER_LOAD, KeyEvent.VK_F8);
|
||||||
|
|
||||||
|
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"),
|
||||||
|
DIAGNOSTICS("Diagnostics");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
Group(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 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) {
|
||||||
|
if (view.group() == MapView.Group.DIAGNOSTICS) {
|
||||||
|
return new FlatSVGIcon("iris/icons/profiler.svg", size, size, MapViewIcon.class.getClassLoader());
|
||||||
|
}
|
||||||
|
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,673 @@
|
|||||||
|
/*
|
||||||
|
* 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.noise.IrisGenerator;
|
||||||
|
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 NoiseSelection noiseSelection;
|
||||||
|
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 JToggleButton waterToggle = StudioToolbar.toggle("Water", "Overlay water on biome picks (W)");
|
||||||
|
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, NoiseSelection noiseSelection) {
|
||||||
|
this.start = start;
|
||||||
|
this.noiseSelection = noiseSelection;
|
||||||
|
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 openNoise(PaletteExplorerStart start, NoiseSelection selection) {
|
||||||
|
if (!MapHost.isAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EventQueue.invokeLater(() -> open(start, MapView.NOISE, selection));
|
||||||
|
}
|
||||||
|
|
||||||
|
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, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void open(PaletteExplorerStart start, MapView initialView, NoiseSelection selection) {
|
||||||
|
if (!MapHost.isAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StudioTheme.install();
|
||||||
|
MapWindow window = new MapWindow(start, selection);
|
||||||
|
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);
|
||||||
|
mapControls.add(waterToggle);
|
||||||
|
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());
|
||||||
|
waterToggle.addActionListener(event -> canvas.toggleWaterOverlay());
|
||||||
|
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);
|
||||||
|
onTogglesChanged();
|
||||||
|
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);
|
||||||
|
waterToggle.setVisible(view == MapView.BIOMES);
|
||||||
|
if (view == MapView.REGIONS) {
|
||||||
|
showRegionExplorer(previousView);
|
||||||
|
} else if (view == MapView.BIOMES) {
|
||||||
|
showBiomeExplorer();
|
||||||
|
} else {
|
||||||
|
useSidebar(viewListScroll);
|
||||||
|
useInspector(legendPanel);
|
||||||
|
}
|
||||||
|
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(StudioTheme.matchTheme(inspector));
|
||||||
|
pickInspectorSplit.setBottomComponent(StudioTheme.matchTheme(legendPanel));
|
||||||
|
useInspector(pickInspectorSplit);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()));
|
||||||
|
useInspector(paletteExplorer.paletteInspector());
|
||||||
|
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()));
|
||||||
|
useInspector(explorer.overview());
|
||||||
|
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);
|
||||||
|
useInspector(worldExplorer.legend());
|
||||||
|
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(StudioTheme.matchTheme(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 = noiseSelection == null
|
||||||
|
? NoiseExplorerGUI.createNoiseExplorer(start)
|
||||||
|
: NoiseExplorerGUI.createGeneratorExplorer(start, noiseSelection);
|
||||||
|
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());
|
||||||
|
waterToggle.setSelected(canvas.isWaterOverlayEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(StudioTheme.matchTheme(component), BorderLayout.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void useInspector(JComponent component) {
|
||||||
|
inspectorSlot.removeAll();
|
||||||
|
inspectorSlot.add(StudioTheme.matchTheme(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_W, 0, "water", canvas::toggleWaterOverlay);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record NoiseSelection(String generatorKey, IrisGenerator generator, long seed) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,146 @@
|
|||||||
|
/*
|
||||||
|
* 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 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 (JsonElement 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;
|
import java.awt.Color;
|
||||||
|
|
||||||
enum NoisePalette {
|
enum NoisePalette implements NoiseColorScale {
|
||||||
TERRAIN("Terrain", 0D, 1D, new int[]{0x071A2F, 0x155E75, 0x2A9D8F, 0xE9C46A, 0xF4F1DE}),
|
TERRAIN("Terrain", 0D, 1D, new int[]{0x071A2F, 0x155E75, 0x2A9D8F, 0xE9C46A, 0xF4F1DE}),
|
||||||
SIGNED("Signed", -1D, 1D, new int[]{0x173B66, 0x4F86C6, 0xE7EDF3, 0xE89555, 0x9D3B35}),
|
SIGNED("Signed", -1D, 1D, new int[]{0x173B66, 0x4F86C6, 0xE7EDF3, 0xE89555, 0x9D3B35}),
|
||||||
GRAYSCALE("Grayscale", 0D, 1D, new int[]{0x050505, 0xFFFFFF});
|
GRAYSCALE("Grayscale", 0D, 1D, new int[]{0x050505, 0xFFFFFF});
|
||||||
@@ -30,7 +48,8 @@ enum NoisePalette {
|
|||||||
return colorFinite(value);
|
return colorFinite(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
int colorFinite(double value) {
|
@Override
|
||||||
|
public int colorFinite(double value) {
|
||||||
double normalized = (value - minimum) / (maximum - minimum);
|
double normalized = (value - minimum) / (maximum - minimum);
|
||||||
return colorNormalized(normalized);
|
return colorNormalized(normalized);
|
||||||
}
|
}
|
||||||
@@ -40,20 +59,29 @@ enum NoisePalette {
|
|||||||
return lookup[(int) Math.round(clipped * (lookup.length - 1))];
|
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));
|
double clipped = Math.max(0D, Math.min(1D, normalized));
|
||||||
return displayColors[(int) Math.round(clipped * (displayColors.length - 1))];
|
return displayColors[(int) Math.round(clipped * (displayColors.length - 1))];
|
||||||
}
|
}
|
||||||
|
|
||||||
String label() {
|
@Override
|
||||||
|
public String label() {
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
double minimum() {
|
@Override
|
||||||
|
public double minimum() {
|
||||||
return minimum;
|
return minimum;
|
||||||
}
|
}
|
||||||
|
|
||||||
double maximum() {
|
@Override
|
||||||
|
public double maximum() {
|
||||||
return 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
-136
@@ -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 art.arcane.volmlib.util.function.NoiseProvider;
|
||||||
|
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.awt.image.DataBufferInt;
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.concurrent.ArrayBlockingQueue;
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
import java.util.concurrent.CountDownLatch;
|
|
||||||
import java.util.concurrent.RejectedExecutionException;
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
import java.util.concurrent.ThreadFactory;
|
import java.util.concurrent.ThreadFactory;
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
@@ -19,10 +33,11 @@ import java.util.concurrent.atomic.AtomicLong;
|
|||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
final class NoiseRenderCoordinator implements AutoCloseable {
|
final class NoiseRenderCoordinator implements AutoCloseable {
|
||||||
|
private static final int BANDS_PER_WORKER = 4;
|
||||||
|
|
||||||
private final int workerCount;
|
private final int workerCount;
|
||||||
private final Listener listener;
|
private final Listener listener;
|
||||||
private final ThreadFactory workerThreadFactory;
|
private final ThreadFactory workerThreadFactory;
|
||||||
private final ThreadFactory coordinatorThreadFactory;
|
|
||||||
private final AtomicReference<RenderGeneration> latestRequested = new AtomicReference<>();
|
private final AtomicReference<RenderGeneration> latestRequested = new AtomicReference<>();
|
||||||
private final AtomicLong latestRevision = new AtomicLong();
|
private final AtomicLong latestRevision = new AtomicLong();
|
||||||
private final AtomicBoolean closed = new AtomicBoolean();
|
private final AtomicBoolean closed = new AtomicBoolean();
|
||||||
@@ -41,27 +56,33 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
|||||||
thread.setPriority(Thread.MIN_PRIORITY);
|
thread.setPriority(Thread.MIN_PRIORITY);
|
||||||
return thread;
|
return thread;
|
||||||
};
|
};
|
||||||
coordinatorThreadFactory = runnable -> {
|
|
||||||
Thread thread = new Thread(runnable, "Iris Noise Coordinator " + threadIds.incrementAndGet());
|
|
||||||
thread.setDaemon(true);
|
|
||||||
thread.setPriority(Thread.MIN_PRIORITY);
|
|
||||||
return thread;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void request(Request request) {
|
synchronized void request(Request request) {
|
||||||
Objects.requireNonNull(request, "request");
|
Objects.requireNonNull(request, "request");
|
||||||
if (closed.get()) {
|
if (closed.get() || request.revision() < latestRevision.get()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
latestRevision.accumulateAndGet(request.revision(), Math::max);
|
latestRevision.accumulateAndGet(request.revision(), Math::max);
|
||||||
cancelActiveRender();
|
cancelActiveRender();
|
||||||
RenderGeneration generation = createGeneration(request);
|
ThreadPoolExecutor workers = new ThreadPoolExecutor(workerCount, workerCount, 0L, TimeUnit.MILLISECONDS,
|
||||||
|
new LinkedBlockingQueue<>(), workerThreadFactory);
|
||||||
|
MapHost.interruptOnPluginShutdown(workers);
|
||||||
|
RenderGeneration generation = new RenderGeneration(request, workers);
|
||||||
latestRequested.set(generation);
|
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) {
|
synchronized void cancel(long revision) {
|
||||||
latestRevision.accumulateAndGet(revision, Math::max);
|
latestRevision.accumulateAndGet(revision, Math::max);
|
||||||
cancelActiveRender();
|
cancelActiveRender();
|
||||||
}
|
}
|
||||||
@@ -115,7 +136,7 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public synchronized void close() {
|
||||||
if (!closed.compareAndSet(false, true)) {
|
if (!closed.compareAndSet(false, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -123,92 +144,52 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
|||||||
cancelActiveRender();
|
cancelActiveRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void renderGeneration(RenderGeneration generation) {
|
private void renderBand(RenderGeneration generation, int bandIndex) {
|
||||||
Request request = generation.request();
|
|
||||||
if (!isCurrent(request)) {
|
|
||||||
generation.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
listener.onRenderStarted(request);
|
|
||||||
try {
|
try {
|
||||||
Result result = render(generation);
|
if (isCurrent(generation)) {
|
||||||
if (result != null && isCurrent(request)) {
|
renderRows(generation, bandIndex);
|
||||||
listener.onRenderCompleted(result);
|
|
||||||
}
|
}
|
||||||
} catch (Throwable error) {
|
} catch (Throwable error) {
|
||||||
if (isCurrent(request)) {
|
generation.fail(error);
|
||||||
listener.onRenderFailed(request, error);
|
} finally {
|
||||||
|
finishBand(generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void finishBand(RenderGeneration generation) {
|
||||||
|
if (!generation.completeBand()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
generation.executor().shutdown();
|
||||||
|
if (!isCurrent(generation)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Throwable failure = generation.failure();
|
||||||
|
if (failure != null) {
|
||||||
|
listener.onRenderFailed(generation.request(), failure);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
listener.onRenderCompleted(generation.result());
|
||||||
} finally {
|
} finally {
|
||||||
latestRequested.compareAndSet(generation, null);
|
latestRequested.compareAndSet(generation, null);
|
||||||
generation.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Result render(RenderGeneration generation) throws InterruptedException {
|
private void renderRows(RenderGeneration generation, int bandIndex) {
|
||||||
Request request = generation.request();
|
Request request = generation.request();
|
||||||
long started = System.nanoTime();
|
int outputWidth = generation.outputWidth();
|
||||||
int outputWidth = Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
|
int outputHeight = generation.outputHeight();
|
||||||
int outputHeight = Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
|
int[] pixels = generation.pixels();
|
||||||
BufferedImage image = new BufferedImage(outputWidth, outputHeight, BufferedImage.TYPE_INT_RGB);
|
BandStats stats = generation.bandStats(bandIndex);
|
||||||
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
int fromY = (outputHeight * bandIndex) / generation.bandCount();
|
||||||
int bandCount = Math.min(workerCount, outputHeight);
|
int toY = (outputHeight * (bandIndex + 1)) / generation.bandCount();
|
||||||
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;
|
|
||||||
double sampleOffset = request.sampleStep() * 0.5D;
|
double sampleOffset = request.sampleStep() * 0.5D;
|
||||||
double startWorldX = request.viewport().worldX(sampleOffset, request.width());
|
double startWorldX = request.viewport().worldX(sampleOffset, request.width());
|
||||||
double worldStep = request.viewport().blocksPerPixel() * request.sampleStep();
|
double worldStep = request.viewport().blocksPerPixel() * request.sampleStep();
|
||||||
NoisePalette palette = request.palette();
|
NoiseColorScale colorScale = request.colorScale();
|
||||||
double paletteMinimum = palette.minimum();
|
double paletteMinimum = colorScale.minimum();
|
||||||
double paletteMaximum = palette.maximum();
|
double paletteMaximum = colorScale.maximum();
|
||||||
for (int y = fromY; y < toY; y++) {
|
for (int y = fromY; y < toY; y++) {
|
||||||
double screenY = (y * (double) request.sampleStep()) + sampleOffset;
|
double screenY = (y * (double) request.sampleStep()) + sampleOffset;
|
||||||
double worldZ = request.viewport().worldZ(screenY, request.height());
|
double worldZ = request.viewport().worldZ(screenY, request.height());
|
||||||
@@ -216,16 +197,16 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
|||||||
int pixelIndex = y * outputWidth;
|
int pixelIndex = y * outputWidth;
|
||||||
boolean centerRow = y == outputHeight / 2;
|
boolean centerRow = y == outputHeight / 2;
|
||||||
for (int x = 0; x < outputWidth; x++) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
double value = request.sampler().noise(worldX, worldZ);
|
double value = request.sampler().noise(worldX, worldZ);
|
||||||
boolean center = centerRow && x == outputWidth / 2;
|
boolean center = centerRow && x == outputWidth / 2;
|
||||||
if (Double.isFinite(value)) {
|
if (Double.isFinite(value)) {
|
||||||
pixels[pixelIndex++] = palette.colorFinite(value);
|
pixels[pixelIndex++] = colorScale.colorAt(value, worldX, worldZ);
|
||||||
stats.acceptFinite(value, center, paletteMinimum, paletteMaximum);
|
stats.acceptFinite(value, center, paletteMinimum, paletteMaximum);
|
||||||
} else {
|
} else {
|
||||||
pixels[pixelIndex++] = NoisePalette.INVALID_COLOR;
|
pixels[pixelIndex++] = colorScale.colorInvalidAt(worldX, worldZ);
|
||||||
stats.acceptInvalid(value, center);
|
stats.acceptInvalid(value, center);
|
||||||
}
|
}
|
||||||
worldX += worldStep;
|
worldX += worldStep;
|
||||||
@@ -233,41 +214,25 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isCurrent(Request request) {
|
private boolean isCurrent(RenderGeneration generation) {
|
||||||
return !closed.get()
|
return !closed.get()
|
||||||
&& latestRequested.get() != null
|
&& latestRequested.get() == generation
|
||||||
&& latestRequested.get().request() == request
|
&& generation.request().revision() >= latestRevision.get();
|
||||||
&& request.revision() >= latestRevision.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cancelActiveRender() {
|
private void cancelActiveRender() {
|
||||||
RenderGeneration generation = latestRequested.getAndSet(null);
|
RenderGeneration generation = latestRequested.getAndSet(null);
|
||||||
if (generation != null) {
|
if (generation != null) {
|
||||||
generation.close();
|
generation.executor().shutdownNow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private RenderGeneration createGeneration(Request request) {
|
private static int outputWidth(Request request) {
|
||||||
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
return Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
|
||||||
workerCount,
|
}
|
||||||
workerCount,
|
|
||||||
0L,
|
private static int outputHeight(Request request) {
|
||||||
TimeUnit.MILLISECONDS,
|
return Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
|
||||||
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 RenderStats combine(BandStats[] bands) {
|
private static RenderStats combine(BandStats[] bands) {
|
||||||
@@ -304,12 +269,12 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
|||||||
void onRenderFailed(Request request, Throwable error);
|
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) {
|
int width, int height, int sampleStep) {
|
||||||
Request {
|
Request {
|
||||||
Objects.requireNonNull(sampler, "sampler");
|
Objects.requireNonNull(sampler, "sampler");
|
||||||
Objects.requireNonNull(viewport, "viewport");
|
Objects.requireNonNull(viewport, "viewport");
|
||||||
Objects.requireNonNull(palette, "palette");
|
Objects.requireNonNull(colorScale, "colorScale");
|
||||||
if (revision < 0L || width < 1 || height < 1 || sampleStep < 1) {
|
if (revision < 0L || width < 1 || height < 1 || sampleStep < 1) {
|
||||||
throw new IllegalArgumentException("Invalid noise render request");
|
throw new IllegalArgumentException("Invalid noise render request");
|
||||||
}
|
}
|
||||||
@@ -323,11 +288,25 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
|||||||
private static final class RenderGeneration {
|
private static final class RenderGeneration {
|
||||||
private final Request request;
|
private final Request request;
|
||||||
private final ThreadPoolExecutor executor;
|
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) {
|
private RenderGeneration(Request request, ThreadPoolExecutor executor) {
|
||||||
this.request = Objects.requireNonNull(request, "request");
|
this.request = request;
|
||||||
this.executor = Objects.requireNonNull(executor, "executor");
|
this.executor = executor;
|
||||||
|
this.outputWidth = NoiseRenderCoordinator.outputWidth(request);
|
||||||
|
this.outputHeight = NoiseRenderCoordinator.outputHeight(request);
|
||||||
|
this.pixels = new int[outputWidth * outputHeight];
|
||||||
|
this.bandStats = new BandStats[Math.min(executor.getCorePoolSize() * BANDS_PER_WORKER, outputHeight)];
|
||||||
|
for (int band = 0; band < bandStats.length; band++) {
|
||||||
|
bandStats[band] = new BandStats();
|
||||||
|
}
|
||||||
|
this.remainingBands = new AtomicInteger(bandStats.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Request request() {
|
private Request request() {
|
||||||
@@ -338,20 +317,45 @@ final class NoiseRenderCoordinator implements AutoCloseable {
|
|||||||
return executor;
|
return executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setRunner(Thread runner) {
|
private int bandCount() {
|
||||||
this.runner = Objects.requireNonNull(runner, "runner");
|
return bandStats.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void start() {
|
private int outputWidth() {
|
||||||
runner.start();
|
return outputWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void close() {
|
private int outputHeight() {
|
||||||
Thread activeRunner = runner;
|
return outputHeight;
|
||||||
if (activeRunner != null) {
|
}
|
||||||
activeRunner.interrupt();
|
|
||||||
}
|
private int[] pixels() {
|
||||||
executor.shutdownNow();
|
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,256 @@
|
|||||||
|
/*
|
||||||
|
* 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 javax.swing.Timer;
|
||||||
|
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.RejectedExecutionException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
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 final AtomicBoolean closed = new AtomicBoolean();
|
||||||
|
private final Timer retryTimer = new Timer(1, event -> requestCover());
|
||||||
|
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;
|
||||||
|
retryTimer.setRepeats(false);
|
||||||
|
MapHost.interruptOnPluginShutdown(coverer);
|
||||||
|
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 (closed.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
retryTimer.stop();
|
||||||
|
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 (closed.get() || tiles == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hidden = false;
|
||||||
|
viewChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing is sampled for a view no one sees.
|
||||||
|
void hide() {
|
||||||
|
hidden = true;
|
||||||
|
retryTimer.stop();
|
||||||
|
TerrainTiles current = tiles;
|
||||||
|
if (current != null) {
|
||||||
|
current.sampleOnly(List.of(), Set.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void invalidate() {
|
||||||
|
retryTimer.stop();
|
||||||
|
TerrainTiles current = tiles;
|
||||||
|
tiles = null;
|
||||||
|
shown = null;
|
||||||
|
if (current != null) {
|
||||||
|
current.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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() {
|
||||||
|
if (!closed.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hidden = true;
|
||||||
|
invalidate();
|
||||||
|
coverer.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before the first scene, the viewport still holds whatever camera it had; the scene will start it afresh.
|
||||||
|
private void viewChanged() {
|
||||||
|
if (closed.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 (closed.get() || hidden || coverQueued.getAndSet(true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
coverer.execute(() -> {
|
||||||
|
coverQueued.set(false);
|
||||||
|
coverSafely();
|
||||||
|
});
|
||||||
|
} catch (RejectedExecutionException rejected) {
|
||||||
|
coverQueued.set(false);
|
||||||
|
if (!closed.get() && !coverer.isShutdown()) {
|
||||||
|
throw rejected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void coverSafely() {
|
||||||
|
try {
|
||||||
|
cover();
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
IrisLogging.reportError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cover() {
|
||||||
|
TerrainTiles current = tiles;
|
||||||
|
ViewState state = view;
|
||||||
|
Vec3 at = anchor;
|
||||||
|
if (closed.get() || 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 (closed.get() || hidden || coveredTiles != tiles) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long retryDelay = coveredTiles.retryDelayNanos();
|
||||||
|
if (retryDelay >= 0L) {
|
||||||
|
retryTimer.setInitialDelay((int) Math.max(1L, TimeUnit.NANOSECONDS.toMillis(retryDelay) + 1L));
|
||||||
|
retryTimer.restart();
|
||||||
|
} else {
|
||||||
|
retryTimer.stop();
|
||||||
|
}
|
||||||
|
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) {
|
record NoiseViewport(double centerX, double centerZ, double blocksPerPixel) {
|
||||||
static final double MIN_BLOCKS_PER_PIXEL = 0.0001D;
|
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);
|
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) {
|
NoiseViewport panPixels(double deltaX, double deltaZ) {
|
||||||
return new NoiseViewport(
|
return new NoiseViewport(
|
||||||
centerX - (deltaX * blocksPerPixel),
|
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,649 @@
|
|||||||
|
/*
|
||||||
|
* 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.ExecutionException;
|
||||||
|
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 boolean closed;
|
||||||
|
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.
|
||||||
|
synchronized void open() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewport.showLoading("Loading Minecraft textures");
|
||||||
|
loader.execute(() -> {
|
||||||
|
if (!loadTextures() || closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> keys = objectKeys();
|
||||||
|
publish(() -> showCatalog(keys));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized void close() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
MapHost.unregisterHotloadHook(hotloadHook);
|
||||||
|
objectRevision.incrementAndGet();
|
||||||
|
variantListRevision.incrementAndGet();
|
||||||
|
meshRevision.incrementAndGet();
|
||||||
|
loader.shutdownNow();
|
||||||
|
thumbnailPainter.shutdownNow();
|
||||||
|
bakeTimer.discardPending();
|
||||||
|
bakeTimer.close();
|
||||||
|
viewport.close();
|
||||||
|
gallery.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean loadTextures() {
|
||||||
|
try {
|
||||||
|
BlockTextures loaded = BlockTextures.shared().get();
|
||||||
|
BiomeTintColors colors = loaded.biomeColors().vanilla(TINT_BIOME);
|
||||||
|
synchronized (this) {
|
||||||
|
if (closed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tintColors = colors;
|
||||||
|
textures = loaded;
|
||||||
|
}
|
||||||
|
} catch (InterruptedException interrupted) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return false;
|
||||||
|
} catch (ExecutionException | RuntimeException unavailable) {
|
||||||
|
publish(() -> statusBar.showError("Minecraft textures are unavailable, objects use plain colours"));
|
||||||
|
}
|
||||||
|
return !closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publish(Runnable update) {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EventQueue.invokeLater(() -> {
|
||||||
|
synchronized (ObjectExplorer.this) {
|
||||||
|
if (!closed) {
|
||||||
|
update.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> objectKeys() {
|
||||||
|
return source.keys(engine().getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void showCatalog(List<String> keys) {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 synchronized void showObject(String key) {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 (closed || 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 synchronized void showSeed(long seed) {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 (!closed && 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) {
|
||||||
|
if (closed || revision != objectRevision.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if (closed || revision != objectRevision.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int index = preferredIndex < variants.size() ? preferredIndex : 0;
|
||||||
|
LoadedObject loaded = load(variants.get(index));
|
||||||
|
publish(() -> {
|
||||||
|
if (closed || revision != objectRevision.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showVariantList(variants, pack, seed, index);
|
||||||
|
showLoaded(revision, loaded);
|
||||||
|
timeBake(data, key, seed);
|
||||||
|
});
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
publish(() -> 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),
|
||||||
|
timing -> publish(() -> overview.showBakeTiming(timing)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 synchronized void showVariant(int index) {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (index >= shownVariants.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shownVariantIndex = index;
|
||||||
|
gallery.select(index);
|
||||||
|
IrisObject variant = shownVariants.get(index);
|
||||||
|
long revision = startLoading(variant.getLoadKey());
|
||||||
|
loader.execute(() -> {
|
||||||
|
if (closed || revision != objectRevision.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LoadedObject loaded = load(variant);
|
||||||
|
publish(() -> showLoaded(revision, loaded));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void openVariant(int index) {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
variantPicker.showVariants(shownVariants.size(), index);
|
||||||
|
showVariant(index);
|
||||||
|
toggleComparing();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void toggleComparing() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 synchronized void redrawThumbnails() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 synchronized void drawThumbnails() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 (closed || revision != variantListRevision.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
meshes.add(ObjectMesh.build(visibleBlocks(variant), this::modelOf));
|
||||||
|
}
|
||||||
|
ObjectMesh.Bounds sizeToFit = largestSize(meshes);
|
||||||
|
for (int index = 0; index < meshes.size() && !closed && revision == variantListRevision.get(); index++) {
|
||||||
|
BufferedImage thumbnail = ObjectRenderer.renderThumbnail(meshes.get(index), sizeToFit, size, size);
|
||||||
|
int shownIndex = index;
|
||||||
|
publish(() -> {
|
||||||
|
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 (closed || 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 synchronized void showSeeThrough(Set<String> blockTypes) {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seeThroughTypes = blockTypes;
|
||||||
|
rebuildWholeMesh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocks behind see-through ones get their hidden faces back, so the mesh is built again.
|
||||||
|
private synchronized void rebuildWholeMesh() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LoadedObject object = shown;
|
||||||
|
if (object == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long revision = meshRevision.incrementAndGet();
|
||||||
|
Set<String> seeThrough = seeThroughTypes;
|
||||||
|
loader.execute(() -> {
|
||||||
|
if (closed || 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)));
|
||||||
|
publish(() -> {
|
||||||
|
if (revision == meshRevision.get() && shown == object) {
|
||||||
|
wholeMesh = mesh;
|
||||||
|
showLayerCut();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void showLayerCut() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 (closed || 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 synchronized void reloadPack() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loader.execute(() -> {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> keys = objectKeys();
|
||||||
|
publish(() -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user