simplify asset code: unread average colour and constant model fields dropped (bake returns the element list, shared empty model), baked elements cached per blockstate variant and parent models cached while flattening, one element constructor plus withFaces, blockstate box/face/axis rotation in one pass instead of per-step x/y copies, face rotation normalised once, block state parsing inlined into the resolver, shared json read and image-to-grid helpers, path concatenation instead of format, dead zip directory probe and unused dto/font members removed, comments trimmed
This commit is contained in:
1 parent
7f2520a52d
commit
44f2b3c59c
27 files changed
+356
-701
No files matched your search
@@ -1,36 +1,34 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
/**
|
||||
* Builds pack-relative asset paths from namespaced ids, following the vanilla layout
|
||||
* {@code assets/<namespace>/<category>/<path>.<ext>}.
|
||||
*/
|
||||
/** Pack-relative asset paths in the vanilla layout {@code assets/<namespace>/<category>/<path>.<ext>}. */
|
||||
public final class AssetPaths {
|
||||
|
||||
private AssetPaths() {}
|
||||
|
||||
/** {@code assets/<ns>/blockstates/<name>.json} for a plain block name (no {@code block/} prefix). */
|
||||
/** For a plain block name such as {@code oak_stairs}. */
|
||||
public static String blockState(String blockName) {
|
||||
return String.format(
|
||||
"assets/%s/blockstates/%s.json", ResourceLocation.DEFAULT_NAMESPACE, blockName.toLowerCase());
|
||||
return "assets/" + ResourceLocation.DEFAULT_NAMESPACE + "/blockstates/" + blockName.toLowerCase() + ".json";
|
||||
}
|
||||
|
||||
/** {@code assets/<ns>/models/<path>.json}. The id path already contains e.g. {@code block/stone}. */
|
||||
/** The id path includes the folder, e.g. {@code block/stone}. */
|
||||
public static String model(ResourceLocation id) {
|
||||
return String.format("assets/%s/models/%s.json", id.namespace(), id.path());
|
||||
return path(id, "models", ".json");
|
||||
}
|
||||
|
||||
/** {@code assets/<ns>/textures/<path>.png}. The id path already contains e.g. {@code block/stone}. */
|
||||
public static String texture(ResourceLocation id) {
|
||||
return String.format("assets/%s/textures/%s.png", id.namespace(), id.path());
|
||||
return path(id, "textures", ".png");
|
||||
}
|
||||
|
||||
/** {@code assets/<ns>/textures/<path>.png.mcmeta} animation metadata, if present. */
|
||||
/** Animation metadata of a texture. */
|
||||
public static String textureMeta(ResourceLocation id) {
|
||||
return texture(id) + ".mcmeta";
|
||||
}
|
||||
|
||||
/** {@code assets/<ns>/font/<path>.json}, e.g. for {@code minecraft:default} or {@code minecraft:include/space}. */
|
||||
public static String font(ResourceLocation id) {
|
||||
return String.format("assets/%s/font/%s.json", id.namespace(), id.path());
|
||||
return path(id, "font", ".json");
|
||||
}
|
||||
|
||||
private static String path(ResourceLocation id, String category, String extension) {
|
||||
return "assets/" + id.namespace() + "/" + category + "/" + id.path() + extension;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,9 @@ import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Thin convenience layer over a {@link ResourcePack} for reading JSON assets.
|
||||
*/
|
||||
/** Reads JSON assets from a {@link ResourcePack}; unreadable or malformed files are empty. */
|
||||
public final class AssetReader {
|
||||
|
||||
private final ResourcePack pack;
|
||||
@@ -19,20 +18,17 @@ public final class AssetReader {
|
||||
}
|
||||
|
||||
public <T> Optional<T> readJson(String path, Class<T> type) {
|
||||
return pack.read(path).flatMap(bytes -> {
|
||||
try {
|
||||
return Optional.ofNullable(gson.fromJson(new String(bytes, StandardCharsets.UTF_8), type));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
return read(path, text -> gson.fromJson(text, type));
|
||||
}
|
||||
|
||||
public Optional<JsonObject> readJsonObject(String path) {
|
||||
return read(path, text -> JsonParser.parseString(text).getAsJsonObject());
|
||||
}
|
||||
|
||||
private <T> Optional<T> read(String path, Function<String, T> parser) {
|
||||
return pack.read(path).flatMap(bytes -> {
|
||||
try {
|
||||
return Optional.of(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8))
|
||||
.getAsJsonObject());
|
||||
return Optional.ofNullable(parser.apply(new String(bytes, StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.AverageColor;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
@@ -14,12 +14,51 @@ import org.bukkit.Material;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
/**
|
||||
* Top-level entry point that turns a {@link BlockData} into a baked {@link ResolvedModel}, combining
|
||||
* blockstate resolution, model resolution and geometry baking. Results are cached per BlockData and
|
||||
* the cache is safe for concurrent access by the parallel renderer.
|
||||
* Turns a {@link BlockData} into a baked {@link ResolvedModel} (blockstate -> variants -> flattened models ->
|
||||
* elements). Thread-safe; models are cached per BlockData and the baked elements per {@link Variant}.
|
||||
*/
|
||||
public final class BlockModelRegistry {
|
||||
|
||||
private static final int FALLBACK_GREY = 0xFF7F7F7F;
|
||||
|
||||
// Render nothing in the world: hand-only technical blocks, and liquids (vanilla RenderShape.INVISIBLE; drawn by
|
||||
// render.raytrace.FluidRenderer, block displays of them show nothing like vanilla).
|
||||
private static final EnumSet<Material> INVISIBLE_MATERIALS = EnumSet.of(
|
||||
Material.BARRIER,
|
||||
Material.LIGHT,
|
||||
Material.STRUCTURE_VOID,
|
||||
Material.WATER,
|
||||
Material.LAVA,
|
||||
Material.BUBBLE_COLUMN);
|
||||
|
||||
// Drawn by the block-entity renderer (entity scene) and without block-model geometry. Matched by name, not
|
||||
// Material#isBlock (needs a live registry); only queried with placed block materials. Keep in sync with
|
||||
// render.snapshot.BlockEntitySnapshotBuilder#classify (which also covers kinds that keep their block model).
|
||||
private static final EnumSet<Material> BLOCK_ENTITY_MATERIALS = EnumSet.noneOf(Material.class);
|
||||
|
||||
static {
|
||||
for (Material m : Material.values()) {
|
||||
String n = m.name();
|
||||
if (n.endsWith("_SIGN")
|
||||
|| n.endsWith("_BANNER")
|
||||
|| n.endsWith("SHULKER_BOX")
|
||||
|| ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD)
|
||||
|| m == Material.CHEST
|
||||
|| m == Material.TRAPPED_CHEST
|
||||
|| m == Material.ENDER_CHEST
|
||||
|| n.endsWith("COPPER_CHEST")
|
||||
|| m == Material.CONDUIT
|
||||
|| m == Material.DECORATED_POT
|
||||
|| m == Material.BELL
|
||||
|| n.endsWith("COPPER_GOLEM_STATUE")
|
||||
|| m == Material.END_PORTAL
|
||||
|| m == Material.END_GATEWAY) {
|
||||
BLOCK_ENTITY_MATERIALS.add(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final AssetReader reader;
|
||||
private final TextureCache textures;
|
||||
private final BlockStateResolver blockStateResolver;
|
||||
private final ModelResolver modelResolver;
|
||||
@@ -27,18 +66,7 @@ public final class BlockModelRegistry {
|
||||
|
||||
private final Map<BlockData, ResolvedModel> cache = new ConcurrentHashMap<>();
|
||||
private final Map<ResourceLocation, ResolvedModel> modelCache = new ConcurrentHashMap<>();
|
||||
|
||||
private final AssetReader reader;
|
||||
|
||||
/** The texture cache the models are baked from (also used for the fluid sprites). */
|
||||
public TextureCache textures() {
|
||||
return textures;
|
||||
}
|
||||
|
||||
/** The asset reader this registry resolves models with (shared by item-model resolution). */
|
||||
public AssetReader reader() {
|
||||
return reader;
|
||||
}
|
||||
private final Map<Variant, List<Element>> variantCache = new ConcurrentHashMap<>();
|
||||
|
||||
public BlockModelRegistry(AssetReader reader, TextureCache textures) {
|
||||
this.reader = reader;
|
||||
@@ -48,128 +76,82 @@ public final class BlockModelRegistry {
|
||||
this.baker = new ModelBaker(textures);
|
||||
}
|
||||
|
||||
public TextureCache textures() {
|
||||
return textures;
|
||||
}
|
||||
|
||||
public AssetReader reader() {
|
||||
return reader;
|
||||
}
|
||||
|
||||
public ResolvedModel get(BlockData data) {
|
||||
return cache.computeIfAbsent(data, this::resolve);
|
||||
ResolvedModel model = cache.get(data);
|
||||
return model != null ? model : cache.computeIfAbsent(data, this::resolve);
|
||||
}
|
||||
|
||||
/**
|
||||
* A block model by id (e.g. {@code block/item_frame_map}) without a blockstate, unrotated: for models
|
||||
* vanilla draws from a fake block state (item frames). Empty geometry when the model is missing.
|
||||
* A block model by id (e.g. {@code block/item_frame_map}) without a blockstate, unrotated: for models vanilla
|
||||
* draws from a fake block state (item frames). Empty when the model is missing.
|
||||
*/
|
||||
public ResolvedModel model(ResourceLocation modelId) {
|
||||
return modelCache.computeIfAbsent(modelId, id -> {
|
||||
ModelBaker.BakedGeometry baked = baker.bake(modelResolver.resolve(id), new Variant(id, 0, 0, false));
|
||||
return new ResolvedModel(baked.elements(), baked.averageColor(), 0, 0, false, baked.hasGeometry());
|
||||
List<Element> elements = bake(new Variant(id, 0, 0, false));
|
||||
return new ResolvedModel(elements, !elements.isEmpty());
|
||||
});
|
||||
}
|
||||
|
||||
private List<Element> bake(Variant variant) {
|
||||
return variantCache.computeIfAbsent(variant, v -> baker.bake(modelResolver.resolve(v.model()), v));
|
||||
}
|
||||
|
||||
private ResolvedModel resolve(BlockData data) {
|
||||
Material material = data.getMaterial();
|
||||
// Technical blocks that are invisible in the world (their textures only show held in hand), and liquid
|
||||
// blocks (vanilla RenderShape.INVISIBLE: water, lava and bubble columns are drawn by the fluid renderer,
|
||||
// see render.raytrace.FluidRenderer; block displays of them show nothing, like vanilla).
|
||||
if (INVISIBLE_MATERIALS.contains(material)) {
|
||||
return new ResolvedModel(List.of(), 0, 0, 0, false, false);
|
||||
}
|
||||
if (INVISIBLE_MATERIALS.contains(material)) return ResolvedModel.EMPTY;
|
||||
|
||||
List<Variant> variants = blockStateResolver.resolve(data);
|
||||
|
||||
List<Element> elements = new ArrayList<>();
|
||||
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
|
||||
FlatModel lastFlat = null;
|
||||
|
||||
for (Variant variant : variants) {
|
||||
FlatModel flat = modelResolver.resolve(variant.model());
|
||||
lastFlat = flat;
|
||||
ModelBaker.BakedGeometry baked = baker.bake(flat, variant);
|
||||
elements.addAll(baked.elements());
|
||||
if (baked.hasGeometry()) {
|
||||
avgColor.add(baked.averageColor());
|
||||
}
|
||||
List<Element> elements;
|
||||
if (variants.size() == 1) {
|
||||
elements = bake(variants.getFirst()); // shared with other states of the same variant; never mutated
|
||||
} else {
|
||||
elements = new ArrayList<>();
|
||||
for (Variant variant : variants) elements.addAll(bake(variant));
|
||||
}
|
||||
if (!elements.isEmpty()) return new ResolvedModel(elements, true);
|
||||
|
||||
if (!elements.isEmpty()) {
|
||||
return new ResolvedModel(elements, avgColor.average(0xFF7F7F7F), 0, 0, false, true);
|
||||
}
|
||||
|
||||
int avg = fallbackColor(lastFlat);
|
||||
|
||||
// Block-entities (chests, banners, heads, shulker boxes, …) use builtin/entity models with no
|
||||
// geometry; their real shape is rendered separately through the entity scene. Return empty
|
||||
// geometry so the ray passes through to that geometry instead of drawing a grey fallback cube.
|
||||
if (isBlockEntityRendered(material)) {
|
||||
return new ResolvedModel(List.of(), avg, 0, 0, false, false);
|
||||
}
|
||||
|
||||
// No geometry: render a flat full cube using a fallback average color.
|
||||
return new ResolvedModel(List.of(solidCube(avg)), avg, 0, 0, false, false);
|
||||
// Block entities (builtin/entity models) are drawn by the entity scene: no grey cube in front of them.
|
||||
if (BLOCK_ENTITY_MATERIALS.contains(material)) return ResolvedModel.EMPTY;
|
||||
FlatModel last = variants.isEmpty()
|
||||
? null
|
||||
: modelResolver.resolve(variants.getLast().model());
|
||||
return new ResolvedModel(List.of(solidCube(particleColor(last))), false);
|
||||
}
|
||||
|
||||
/** Technical blocks that render nothing in the world (barrier/light/structure void are hand-only). */
|
||||
private static final EnumSet<Material> INVISIBLE_MATERIALS = buildInvisibleMaterials();
|
||||
|
||||
private static EnumSet<Material> buildInvisibleMaterials() {
|
||||
EnumSet<Material> set = EnumSet.noneOf(Material.class);
|
||||
for (String n : new String[] {"BARRIER", "LIGHT", "STRUCTURE_VOID", "WATER", "LAVA", "BUBBLE_COLUMN"}) {
|
||||
try {
|
||||
set.add(Material.valueOf(n));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
/* older/newer server */
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/** Block materials whose shape is drawn by the block-entity renderer, not the block-model pipeline. */
|
||||
private static final EnumSet<Material> BLOCK_ENTITY_MATERIALS = buildBlockEntityMaterials();
|
||||
|
||||
private static boolean isBlockEntityRendered(Material material) {
|
||||
return BLOCK_ENTITY_MATERIALS.contains(material);
|
||||
}
|
||||
|
||||
private static EnumSet<Material> buildBlockEntityMaterials() {
|
||||
// Matched purely by name/identity (no Material#isBlock, which needs a live registry): the set is
|
||||
// only ever queried with placed block materials, so any item-only matches are harmless.
|
||||
EnumSet<Material> set = EnumSet.noneOf(Material.class);
|
||||
for (Material m : Material.values()) {
|
||||
String n = m.name();
|
||||
boolean match = n.endsWith("_SIGN") // signs: standing/wall/(wall_)hanging
|
||||
|| n.endsWith("_BANNER") // banners: standing/wall
|
||||
|| n.endsWith("SHULKER_BOX") // SHULKER_BOX + <color>_SHULKER_BOX
|
||||
|| ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD)
|
||||
|| m == Material.CHEST
|
||||
|| m == Material.TRAPPED_CHEST
|
||||
|| m == Material.ENDER_CHEST
|
||||
|| n.endsWith("COPPER_CHEST") // copper chests incl. weathered/waxed stages
|
||||
|| m == Material.CONDUIT
|
||||
|| m == Material.DECORATED_POT
|
||||
|| m == Material.BELL
|
||||
|| n.endsWith("COPPER_GOLEM_STATUE") // statue incl. weathered/waxed stages
|
||||
|| m == Material.END_PORTAL
|
||||
|| m == Material.END_GATEWAY;
|
||||
if (match) set.add(m);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/** A full-block cube whose six faces sample a single solid color (1x1 texture). */
|
||||
private Element solidCube(int color) {
|
||||
/** A full cube whose faces sample one solid colour. */
|
||||
private static Element solidCube(int color) {
|
||||
int[][] tex = {{color}};
|
||||
Face[] faces = new Face[6];
|
||||
for (Direction d : Direction.values()) {
|
||||
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
|
||||
}
|
||||
return new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces, null, -1, 0, false);
|
||||
for (Direction d : Direction.values()) faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
|
||||
return new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces);
|
||||
}
|
||||
|
||||
private int fallbackColor(FlatModel flat) {
|
||||
if (flat != null && flat.textures() != null) {
|
||||
String particle = flat.textures().get("particle");
|
||||
if (particle != null && !particle.startsWith("#")) {
|
||||
int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null);
|
||||
if (tex != null) return AverageColor.of(tex);
|
||||
/** Average colour of the model's particle texture (texels with alpha > 16), else grey. */
|
||||
private int particleColor(FlatModel flat) {
|
||||
String particle = flat == null ? null : flat.textures().get("particle");
|
||||
if (particle == null || particle.startsWith("#")) return FALLBACK_GREY;
|
||||
int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null);
|
||||
if (tex == null) return FALLBACK_GREY;
|
||||
long r = 0, g = 0, b = 0;
|
||||
int count = 0;
|
||||
for (int[] row : tex) {
|
||||
for (int argb : row) {
|
||||
if (!ColorUtil.opaque(argb)) continue;
|
||||
r += ColorUtil.red(argb);
|
||||
g += ColorUtil.green(argb);
|
||||
b += ColorUtil.blue(argb);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return 0xFF7F7F7F;
|
||||
if (count == 0) return FALLBACK_GREY;
|
||||
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
/**
|
||||
* Parses the property map and plain block name out of a {@link BlockData} string such as
|
||||
* {@code minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]}.
|
||||
*/
|
||||
public final class BlockStateProperties {
|
||||
|
||||
private BlockStateProperties() {}
|
||||
|
||||
/** The block name without namespace, e.g. {@code oak_stairs}. */
|
||||
public static String blockName(BlockData data) {
|
||||
String s = data.getAsString(false);
|
||||
int bracket = s.indexOf('[');
|
||||
String id = bracket < 0 ? s : s.substring(0, bracket);
|
||||
int colon = id.indexOf(':');
|
||||
return (colon < 0 ? id : id.substring(colon + 1)).trim();
|
||||
}
|
||||
|
||||
/** The {@code prop -> value} map (empty when the block has no properties). */
|
||||
public static Map<String, String> properties(BlockData data) {
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
String s = data.getAsString(false);
|
||||
int open = s.indexOf('[');
|
||||
int close = s.lastIndexOf(']');
|
||||
if (open < 0 || close < 0 || close <= open) return props;
|
||||
|
||||
String body = s.substring(open + 1, close);
|
||||
for (String pair : body.split(",")) {
|
||||
int eq = pair.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
props.put(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
|
||||
}
|
||||
return props;
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,12 @@ import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
/**
|
||||
* Resolves a {@link BlockData} to the list of model variants vanilla would render, by reading the
|
||||
* block's {@code blockstates/<name>.json} (either {@code variants} or {@code multipart}).
|
||||
*/
|
||||
/** Resolves a {@link BlockData} to the model variants of its {@code blockstates/<name>.json} (variants or multipart). */
|
||||
public final class BlockStateResolver {
|
||||
|
||||
private final AssetReader reader;
|
||||
@@ -21,8 +19,21 @@ public final class BlockStateResolver {
|
||||
}
|
||||
|
||||
public List<Variant> resolve(BlockData data) {
|
||||
String name = BlockStateProperties.blockName(data);
|
||||
Map<String, String> props = BlockStateProperties.properties(data);
|
||||
// e.g. minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]
|
||||
String state = data.getAsString(false);
|
||||
int open = state.indexOf('[');
|
||||
String id = open < 0 ? state : state.substring(0, open);
|
||||
String name = id.substring(id.indexOf(':') + 1).trim();
|
||||
Map<String, String> props = new HashMap<>();
|
||||
int close = state.lastIndexOf(']');
|
||||
if (open >= 0 && close > open) {
|
||||
for (String pair : state.substring(open + 1, close).split(",")) {
|
||||
int eq = pair.indexOf('=');
|
||||
if (eq >= 0)
|
||||
props.put(
|
||||
pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
|
||||
}
|
||||
}
|
||||
|
||||
JsonObject root = reader.readJsonObject(AssetPaths.blockState(name)).orElse(null);
|
||||
if (root == null) return List.of();
|
||||
@@ -36,7 +47,7 @@ public final class BlockStateResolver {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<Variant> resolveVariants(JsonObject variants, Map<String, String> props) {
|
||||
private static List<Variant> resolveVariants(JsonObject variants, Map<String, String> props) {
|
||||
for (Map.Entry<String, JsonElement> entry : variants.entrySet()) {
|
||||
if (variantKeyMatches(entry.getKey(), props)) {
|
||||
return List.of(parseVariant(firstOf(entry.getValue())));
|
||||
@@ -45,7 +56,7 @@ public final class BlockStateResolver {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<Variant> resolveMultipart(JsonArray multipart, Map<String, String> props) {
|
||||
private static List<Variant> resolveMultipart(JsonArray multipart, Map<String, String> props) {
|
||||
List<Variant> result = new ArrayList<>();
|
||||
for (JsonElement caseEl : multipart) {
|
||||
JsonObject caseObj = caseEl.getAsJsonObject();
|
||||
@@ -55,8 +66,8 @@ public final class BlockStateResolver {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A variant key like {@code facing=east,half=bottom} matches when every pair holds for the block. */
|
||||
private boolean variantKeyMatches(String key, Map<String, String> props) {
|
||||
/** A variant key like {@code facing=east,half=bottom} matches when every pair holds. */
|
||||
private static boolean variantKeyMatches(String key, Map<String, String> props) {
|
||||
if (key.isEmpty()) return true;
|
||||
for (String pair : key.split(",")) {
|
||||
int eq = pair.indexOf('=');
|
||||
@@ -68,7 +79,7 @@ public final class BlockStateResolver {
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean whenMatches(JsonElement when, Map<String, String> props) {
|
||||
private static boolean whenMatches(JsonElement when, Map<String, String> props) {
|
||||
JsonObject obj = when.getAsJsonObject();
|
||||
if (obj.has("OR")) {
|
||||
for (JsonElement sub : obj.getAsJsonArray("OR")) {
|
||||
@@ -82,7 +93,6 @@ public final class BlockStateResolver {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Implicit AND over property conditions.
|
||||
for (Map.Entry<String, JsonElement> e : obj.entrySet()) {
|
||||
if (!valueMatches(props.get(e.getKey()), e.getValue().getAsString())) return false;
|
||||
}
|
||||
@@ -90,7 +100,7 @@ public final class BlockStateResolver {
|
||||
}
|
||||
|
||||
/** A condition value may be an OR list like {@code "north|south"}. */
|
||||
private boolean valueMatches(String actual, String expected) {
|
||||
private static boolean valueMatches(String actual, String expected) {
|
||||
if (actual == null) return false;
|
||||
for (String option : expected.split("\\|")) {
|
||||
if (option.equals(actual)) return true;
|
||||
@@ -98,14 +108,15 @@ public final class BlockStateResolver {
|
||||
return false;
|
||||
}
|
||||
|
||||
private JsonObject firstOf(JsonElement element) {
|
||||
/** Weighted variant lists: the first entry. */
|
||||
private static JsonObject firstOf(JsonElement element) {
|
||||
if (element.isJsonArray()) {
|
||||
return element.getAsJsonArray().get(0).getAsJsonObject();
|
||||
}
|
||||
return element.getAsJsonObject();
|
||||
}
|
||||
|
||||
private Variant parseVariant(JsonObject obj) {
|
||||
private static Variant parseVariant(JsonObject obj) {
|
||||
ResourceLocation model = ResourceLocation.parse(obj.get("model").getAsString());
|
||||
int x = obj.has("x") ? obj.get("x").getAsInt() : 0;
|
||||
int y = obj.has("y") ? obj.get("y").getAsInt() : 0;
|
||||
|
||||
@@ -3,10 +3,7 @@ package eu.mhsl.minecraft.pixelpics.assets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Combines several packs into one; the first pack that contains an asset wins. Lets an admin drop
|
||||
* both an unpacked directory and one or more {@code .zip} packs.
|
||||
*/
|
||||
/** Several packs as one; the first pack containing an asset wins. */
|
||||
public final class CompositeResourcePack implements ResourcePack {
|
||||
|
||||
private final List<ResourcePack> packs;
|
||||
|
||||
@@ -5,10 +5,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A {@link ResourcePack} backed by a directory on disk. The root typically contains the
|
||||
* {@code assets/} folder.
|
||||
*/
|
||||
/** A {@link ResourcePack} backed by a directory (the one containing {@code assets/}). */
|
||||
public final class DirectoryResourcePack implements ResourcePack {
|
||||
|
||||
private final Path root;
|
||||
@@ -19,7 +16,7 @@ public final class DirectoryResourcePack implements ResourcePack {
|
||||
|
||||
private Optional<Path> resolve(String path) {
|
||||
Path resolved = root.resolve(path).normalize();
|
||||
// Guard against path traversal outside the pack root.
|
||||
// No path traversal outside the pack root.
|
||||
if (!resolved.startsWith(root)) return Optional.empty();
|
||||
return Optional.of(resolved);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A model with its parent chain flattened: textures merged (child wins) and the nearest non-empty
|
||||
* {@code elements} list selected (vanilla does not merge elements across parents).
|
||||
* A model with its parent chain flattened: textures merged (child wins), elements from the nearest model that has
|
||||
* any (vanilla does not merge elements across parents).
|
||||
*/
|
||||
public record FlatModel(Map<String, String> textures, List<ModelFileDto.ElementDto> elements) {
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Thread-safe cache of ARGB pixel grids keyed by {@code K}. Subclasses implement {@link #load} to
|
||||
* produce the grid (or {@code null} on failure); a failure is cached as a sentinel so it is not
|
||||
* retried, and {@link #get} returns empty for it. The grid is indexed {@code [y][x]}, top-left origin.
|
||||
* Thread-safe cache of ARGB pixel grids ({@code [y][x]}, top-left origin). Failed loads ({@link #load} returns null)
|
||||
* are cached too and never retried.
|
||||
*/
|
||||
public abstract class ImageCache<K> {
|
||||
|
||||
@@ -15,7 +15,6 @@ public abstract class ImageCache<K> {
|
||||
|
||||
private final Map<K, int[][]> cache = new ConcurrentHashMap<>();
|
||||
|
||||
/** Loads the pixel grid for {@code key}, or returns {@code null} if it cannot be loaded. */
|
||||
protected abstract int[][] load(K key);
|
||||
|
||||
public Optional<int[][]> get(K key) {
|
||||
@@ -23,6 +22,13 @@ public abstract class ImageCache<K> {
|
||||
return result == MISSING ? Optional.empty() : Optional.of(result);
|
||||
}
|
||||
|
||||
/** The top-left {@code w×h} pixels of an image as ARGB rows. */
|
||||
protected static int[][] pixels(BufferedImage img, int w, int h) {
|
||||
int[][] out = new int[h][];
|
||||
for (int y = 0; y < h; y++) out[y] = img.getRGB(0, y, w, 1, null, 0, w);
|
||||
return out;
|
||||
}
|
||||
|
||||
private int[][] loadOrSentinel(K key) {
|
||||
int[][] loaded = load(key);
|
||||
return loaded == null ? MISSING : loaded;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.AverageColor;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||
@@ -10,11 +9,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Bakes a {@link FlatModel} plus a blockstate {@link Variant} into intersectable {@link Element}
|
||||
* boxes. Texture variables are resolved against the model's merged texture map; default UVs follow
|
||||
* the vanilla element extents; blockstate {@code x}/{@code y} rotations (90-degree steps) are baked
|
||||
* into geometry and face directions, while a model element's own rotation is preserved for OBB
|
||||
* intersection.
|
||||
* Bakes a {@link FlatModel} plus a blockstate {@link Variant} into {@link Element} boxes. Blockstate x/y rotations
|
||||
* (90° steps) are applied to box, faces and the element's own rotation; rotated elements keep their model-space copy.
|
||||
*/
|
||||
public final class ModelBaker {
|
||||
|
||||
@@ -24,26 +20,19 @@ public final class ModelBaker {
|
||||
this.textures = textures;
|
||||
}
|
||||
|
||||
public record BakedGeometry(List<Element> elements, int averageColor, boolean hasGeometry) {}
|
||||
public List<Element> bake(FlatModel model, Variant variant) {
|
||||
if (!model.hasElements()) return List.of();
|
||||
|
||||
public BakedGeometry bake(FlatModel model, Variant variant) {
|
||||
if (!model.hasElements()) {
|
||||
return new BakedGeometry(List.of(), 0, false);
|
||||
}
|
||||
|
||||
int xSteps = ((variant.x() / 90) % 4 + 4) % 4;
|
||||
int ySteps = ((variant.y() / 90) % 4 + 4) % 4;
|
||||
int xSteps = Math.floorMod(variant.x() / 90, 4);
|
||||
int ySteps = Math.floorMod(variant.y() / 90, 4);
|
||||
|
||||
List<Element> baked = new ArrayList<>();
|
||||
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
|
||||
|
||||
for (ModelFileDto.ElementDto dto : model.elements()) {
|
||||
if (dto.from == null || dto.to == null) continue;
|
||||
|
||||
double[] from = {dto.from[0] / 16.0, dto.from[1] / 16.0, dto.from[2] / 16.0};
|
||||
double[] to = {dto.to[0] / 16.0, dto.to[1] / 16.0, dto.to[2] / 16.0};
|
||||
|
||||
// Build faces (pre variant rotation).
|
||||
Face[] faces = new Face[6];
|
||||
if (dto.faces != null) {
|
||||
for (Map.Entry<String, ModelFileDto.FaceDto> e : dto.faces.entrySet()) {
|
||||
@@ -54,7 +43,6 @@ public final class ModelBaker {
|
||||
}
|
||||
}
|
||||
|
||||
// Element's own rotation (origin in 0..1, axis index, radians).
|
||||
double[] rotOrigin = null;
|
||||
int rotAxis = -1;
|
||||
double rotAngle = 0;
|
||||
@@ -75,97 +63,61 @@ public final class ModelBaker {
|
||||
}
|
||||
}
|
||||
|
||||
Element modelElement = rotMatrix != null
|
||||
? new Element(from, to, faces, rotOrigin, rotMatrix, rescale, null, 0, 0)
|
||||
: new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale);
|
||||
Element modelElement =
|
||||
new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rotMatrix, rescale, null, 0, 0);
|
||||
|
||||
// Apply blockstate variant rotation (90-degree steps) to box + faces + element rotation.
|
||||
for (int i = 0; i < xSteps; i++) {
|
||||
double[][] r = rotateBoxX(from, to);
|
||||
from = r[0];
|
||||
to = r[1];
|
||||
faces = rotateFacesX(faces);
|
||||
if (rotAxis >= 0) rotOrigin = rotatePointX(rotOrigin);
|
||||
if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) {
|
||||
AxisRotation na = rotateAxisX(rotAxis);
|
||||
rotAxis = na.axis();
|
||||
if (na.flip()) rotAngle = -rotAngle;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < ySteps; i++) {
|
||||
double[][] r = rotateBoxY(from, to);
|
||||
from = r[0];
|
||||
to = r[1];
|
||||
faces = rotateFacesY(faces);
|
||||
if (rotAxis >= 0) rotOrigin = rotatePointY(rotOrigin);
|
||||
if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) {
|
||||
AxisRotation na = rotateAxisY(rotAxis);
|
||||
rotAxis = na.axis();
|
||||
if (na.flip()) rotAngle = -rotAngle;
|
||||
}
|
||||
}
|
||||
|
||||
// Rotated variants keep the model-space element; intersectors hit it with the inverse-rotated ray
|
||||
// (vanilla bakes the same way). Without uvlock the textures thus turn with the model; with uvlock
|
||||
// each face's final UVs are additionally transformed so the texture stays world-aligned.
|
||||
// Rotated variants keep the model-space element; intersectors hit it with the inverse-rotated ray (as
|
||||
// vanilla's FaceBakery, textures turn with the model). Under uvlock its faces get a UV transform instead.
|
||||
// The world box is min/max-normalised, so only the model-space copy of an inverted element stays inverted.
|
||||
Element unrotated = null;
|
||||
if (xSteps != 0 || ySteps != 0) {
|
||||
unrotated = variant.uvlock() ? uvLocked(modelElement, xSteps, ySteps) : modelElement;
|
||||
double[][] box = minMax(rotatePoint(from, xSteps, ySteps), rotatePoint(to, xSteps, ySteps));
|
||||
from = box[0];
|
||||
to = box[1];
|
||||
Face[] rotated = new Face[6];
|
||||
for (Direction d : Direction.values()) {
|
||||
rotated[d.rotateX(xSteps).rotateY(ySteps).ordinal()] = faces[d.ordinal()];
|
||||
}
|
||||
faces = rotated;
|
||||
if (rotAxis >= 0) rotOrigin = rotatePoint(rotOrigin, xSteps, ySteps);
|
||||
if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) {
|
||||
Direction axis =
|
||||
Direction.of(rotAxis, false).rotateX(xSteps).rotateY(ySteps);
|
||||
rotAxis = axis.axis;
|
||||
if (axis.nx + axis.ny + axis.nz < 0) rotAngle = -rotAngle;
|
||||
}
|
||||
}
|
||||
baked.add(
|
||||
rotMatrix != null
|
||||
? new Element(
|
||||
from,
|
||||
to,
|
||||
faces,
|
||||
rotOrigin,
|
||||
conjugate(rotMatrix, xSteps, ySteps),
|
||||
rescale,
|
||||
unrotated,
|
||||
xSteps,
|
||||
ySteps)
|
||||
: new Element(
|
||||
from, to, faces, rotOrigin, rotAxis, rotAngle, rescale, unrotated, xSteps, ySteps));
|
||||
|
||||
// Accumulate average color from the element's face textures.
|
||||
for (Face f : faces) {
|
||||
if (f == null) continue;
|
||||
avgColor.add(AverageColor.of(f.texture));
|
||||
}
|
||||
baked.add(new Element(
|
||||
from,
|
||||
to,
|
||||
faces,
|
||||
rotOrigin,
|
||||
rotAxis,
|
||||
rotAngle,
|
||||
rotMatrix == null ? null : conjugate(rotMatrix, xSteps, ySteps),
|
||||
rescale,
|
||||
unrotated,
|
||||
xSteps,
|
||||
ySteps));
|
||||
}
|
||||
|
||||
return new BakedGeometry(baked, avgColor.average(0xFF7F7F7F), !baked.isEmpty());
|
||||
return baked;
|
||||
}
|
||||
|
||||
private Face buildFace(
|
||||
Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to, Map<String, String> textureVars) {
|
||||
int[][] tex = resolveTexture(dto.texture, textureVars);
|
||||
if (tex == null) return null;
|
||||
|
||||
double u1, v1, u2, v2;
|
||||
if (dto.uv != null && dto.uv.length == 4) {
|
||||
u1 = dto.uv[0] / 16.0;
|
||||
v1 = dto.uv[1] / 16.0;
|
||||
u2 = dto.uv[2] / 16.0;
|
||||
v2 = dto.uv[3] / 16.0;
|
||||
} else {
|
||||
double[] d = defaultUv(dir, from, to);
|
||||
u1 = d[0];
|
||||
v1 = d[1];
|
||||
u2 = d[2];
|
||||
v2 = d[3];
|
||||
}
|
||||
double[] uv = dto.uv != null && dto.uv.length == 4
|
||||
? new double[] {dto.uv[0] / 16.0, dto.uv[1] / 16.0, dto.uv[2] / 16.0, dto.uv[3] / 16.0}
|
||||
: defaultUv(dir, from, to);
|
||||
int tint = dto.tintindex != null ? dto.tintindex : -1;
|
||||
return new Face(tex, u1, v1, u2, v2, dto.rotation, tint);
|
||||
return new Face(tex, uv[0], uv[1], uv[2], uv[3], dto.rotation, tint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default UV (normalized 0..1) from the element extents, matching vanilla. Texture V is top-down,
|
||||
* so for side faces v1 (texture top) corresponds to the element's high-Y edge: v = [1-to.y, 1-from.y].
|
||||
*/
|
||||
private double[] defaultUv(Direction dir, double[] f, double[] t) {
|
||||
/** Vanilla {@code BlockElement.uvsByFace}, normalised (texture v is top-down). */
|
||||
private static double[] defaultUv(Direction dir, double[] f, double[] t) {
|
||||
return switch (dir) {
|
||||
// Vanilla BlockElement.uvsByFace (u/v in 0..1).
|
||||
case DOWN -> new double[] {f[0], 1 - t[2], t[0], 1 - f[2]};
|
||||
case UP -> new double[] {f[0], f[2], t[0], t[2]};
|
||||
case NORTH -> new double[] {1 - t[0], 1 - t[1], 1 - f[0], 1 - f[1]};
|
||||
@@ -217,25 +169,21 @@ public final class ModelBaker {
|
||||
return r;
|
||||
}
|
||||
|
||||
/** The model-space element with every face's UVs locked to the world under the given rotation. */
|
||||
private static Element uvLocked(Element e, int xSteps, int ySteps) {
|
||||
Face[] locked = new Face[6];
|
||||
for (Direction d : Direction.values()) {
|
||||
Face f = e.faces[d.ordinal()];
|
||||
if (f != null) locked[d.ordinal()] = f.withUvLock(uvLockTransform(d, xSteps, ySteps));
|
||||
}
|
||||
if (e.rotMatrix != null)
|
||||
return new Element(e.from, e.to, locked, e.rotOrigin, e.rotMatrix, e.rescale, null, 0, 0);
|
||||
return new Element(e.from, e.to, locked, e.rotOrigin, e.rotAxis, e.rotAngleRad, e.rescale);
|
||||
return e.withFaces(locked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla uvlock ({@code BlockMath.getFaceTransformation}, inverted as in {@code FaceBakery}): the final
|
||||
* texture coordinates of model face {@code d}, centred on the texture, are mapped by
|
||||
* {@code M = G2L(d) · R⁻¹ · L2G(D)} where R is the blockstate rotation, D the world face {@code d} turns into,
|
||||
* and L2G the per-face UV frames (south = identity). For side faces this keeps the texture world-aligned;
|
||||
* up/down under x rotations follow vanilla's frames (not strictly world-aligned). M is one of the 8 symmetries
|
||||
* of the unit UV square, returned as the affine of {@link Face#uvLock}.
|
||||
* and L2G the per-face UV frames (south = identity). Up/down under x rotations follow vanilla's frames (not
|
||||
* strictly world-aligned). M is one of the 8 symmetries of the unit UV square, as the affine of {@link Face#uvLock}.
|
||||
*/
|
||||
static double[] uvLockTransform(Direction d, int xSteps, int ySteps) {
|
||||
Direction world = d.rotateX(xSteps).rotateY(ySteps);
|
||||
@@ -275,7 +223,7 @@ public final class ModelBaker {
|
||||
return textures.get(ResourceLocation.parse(current)).orElse(null);
|
||||
}
|
||||
|
||||
private int axisIndex(String axis) {
|
||||
private static int axisIndex(String axis) {
|
||||
if (axis == null) return -1;
|
||||
return switch (axis.toLowerCase()) {
|
||||
case "x" -> 0;
|
||||
@@ -285,74 +233,27 @@ public final class ModelBaker {
|
||||
};
|
||||
}
|
||||
|
||||
// --- 90-degree box rotations around the block center (0.5, 0.5, 0.5) ---
|
||||
|
||||
private double[][] rotateBoxY(double[] from, double[] to) {
|
||||
// 90 deg clockwise viewed from above (vanilla), see rotatePointY.
|
||||
double[] c1 = rotatePointY(from);
|
||||
double[] c2 = rotatePointY(to);
|
||||
return minMax(c1, c2);
|
||||
/**
|
||||
* Blockstate rotation of a point about the block centre: x steps, then y steps, both clockwise like vanilla
|
||||
* (y: north -> east). Re-centred after every step instead of using {@link Element#rotate}: that one adds
|
||||
* {@code (p - 0.5) + 0.5} on the fixed axis, which is not exact for every coordinate.
|
||||
*/
|
||||
private static double[] rotatePoint(double[] p, int xSteps, int ySteps) {
|
||||
double[] r = p;
|
||||
for (int i = 0; i < xSteps; i++) {
|
||||
double y = r[1] - 0.5, z = r[2] - 0.5;
|
||||
r = new double[] {r[0], 0.5 + z, 0.5 - y};
|
||||
}
|
||||
for (int i = 0; i < ySteps; i++) {
|
||||
double x = r[0] - 0.5, z = r[2] - 0.5;
|
||||
r = new double[] {0.5 - z, r[1], 0.5 + x};
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
private double[][] rotateBoxX(double[] from, double[] to) {
|
||||
double[] c1 = rotatePointX(from);
|
||||
double[] c2 = rotatePointX(to);
|
||||
return minMax(c1, c2);
|
||||
}
|
||||
|
||||
private double[] rotatePointY(double[] p) {
|
||||
// Vanilla blockstate "y": clockwise seen from above (north -> east, south -> west), i.e. a rotation
|
||||
// by -90° about +Y: (dx, dz) -> (-dz, dx). Must match Direction.rotateY used for the faces.
|
||||
double x = p[0] - 0.5, z = p[2] - 0.5;
|
||||
return new double[] {0.5 - z, p[1], 0.5 + x};
|
||||
}
|
||||
|
||||
private double[] rotatePointX(double[] p) {
|
||||
double y = p[1] - 0.5, z = p[2] - 0.5;
|
||||
return new double[] {p[0], 0.5 + z, 0.5 - y};
|
||||
}
|
||||
|
||||
private double[][] minMax(double[] a, double[] b) {
|
||||
private static double[][] minMax(double[] a, double[] b) {
|
||||
double[] from = {Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])};
|
||||
double[] to = {Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])};
|
||||
return new double[][] {from, to};
|
||||
}
|
||||
|
||||
private Face[] rotateFacesY(Face[] faces) {
|
||||
Face[] out = new Face[6];
|
||||
for (Direction d : Direction.values()) {
|
||||
out[d.rotateY(1).ordinal()] = faces[d.ordinal()];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Face[] rotateFacesX(Face[] faces) {
|
||||
Face[] out = new Face[6];
|
||||
for (Direction d : Direction.values()) {
|
||||
out[d.rotateX(1).ordinal()] = faces[d.ordinal()];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Rotating an element's own rotation axis under a 90-degree block rotation.
|
||||
// {@code flip} indicates the angle sign should invert.
|
||||
private record AxisRotation(int axis, boolean flip) {}
|
||||
|
||||
private AxisRotation rotateAxisY(int axis) {
|
||||
// Clockwise Y rotation maps +x -> +z and +z -> -x; the y axis is unchanged.
|
||||
return switch (axis) {
|
||||
case 0 -> new AxisRotation(2, false); // x -> z
|
||||
case 2 -> new AxisRotation(0, true); // z -> -x
|
||||
default -> new AxisRotation(axis, false);
|
||||
};
|
||||
}
|
||||
|
||||
private AxisRotation rotateAxisX(int axis) {
|
||||
// X rotation maps y<->z; the x axis is unchanged.
|
||||
return switch (axis) {
|
||||
case 1 -> new AxisRotation(2, true); // y -> z
|
||||
case 2 -> new AxisRotation(1, false); // z -> y
|
||||
default -> new AxisRotation(axis, false);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Loads block models and flattens their parent chains into {@link FlatModel}s. Results are cached by
|
||||
* model id.
|
||||
*/
|
||||
/** Loads block models and flattens their parent chains into {@link FlatModel}s, cached per model id. */
|
||||
public final class ModelResolver {
|
||||
|
||||
private static final int MAX_DEPTH = 16;
|
||||
@@ -22,15 +20,11 @@ public final class ModelResolver {
|
||||
}
|
||||
|
||||
public FlatModel resolve(ResourceLocation modelId) {
|
||||
FlatModel cached = cache.get(modelId);
|
||||
if (cached != null) return cached;
|
||||
FlatModel resolved = resolve(modelId, 0);
|
||||
cache.put(modelId, resolved);
|
||||
return resolved;
|
||||
return resolve(modelId, 0);
|
||||
}
|
||||
|
||||
/** A texture value: a plain reference, or the {@code sprite} of a 26.x texture object; null otherwise. */
|
||||
public static String textureRef(com.google.gson.JsonElement v) {
|
||||
public static String textureRef(JsonElement v) {
|
||||
if (v == null) return null;
|
||||
if (v.isJsonPrimitive()) return v.getAsString();
|
||||
if (v.isJsonObject() && v.getAsJsonObject().has("sprite")) {
|
||||
@@ -39,30 +33,34 @@ public final class ModelResolver {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parents go through the cache too (shared chains like block/cube_all are parsed once). No computeIfAbsent:
|
||||
// the recursion would modify the map from inside its own compute. depth only guards against parent cycles.
|
||||
private FlatModel resolve(ResourceLocation modelId, int depth) {
|
||||
FlatModel cached = cache.get(modelId);
|
||||
if (cached != null) return cached;
|
||||
|
||||
ModelFileDto dto =
|
||||
reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null);
|
||||
FlatModel resolved;
|
||||
if (dto == null) {
|
||||
return new FlatModel(new HashMap<>(), null);
|
||||
}
|
||||
|
||||
Map<String, String> textures = new HashMap<>();
|
||||
List<ModelFileDto.ElementDto> elements = dto.elements;
|
||||
|
||||
if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) {
|
||||
FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1);
|
||||
textures.putAll(parent.textures());
|
||||
if (elements == null || elements.isEmpty()) {
|
||||
elements = parent.elements();
|
||||
resolved = new FlatModel(new HashMap<>(), null);
|
||||
} else {
|
||||
Map<String, String> textures = new HashMap<>();
|
||||
List<ModelFileDto.ElementDto> elements = dto.elements;
|
||||
if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) {
|
||||
FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1);
|
||||
textures.putAll(parent.textures());
|
||||
if (elements == null || elements.isEmpty()) elements = parent.elements();
|
||||
}
|
||||
if (dto.textures != null) {
|
||||
dto.textures.forEach((key, value) -> {
|
||||
String ref = textureRef(value);
|
||||
if (ref != null) textures.put(key, ref);
|
||||
});
|
||||
}
|
||||
resolved = new FlatModel(textures, elements);
|
||||
}
|
||||
if (dto.textures != null) {
|
||||
dto.textures.forEach((key, value) -> {
|
||||
String ref = textureRef(value);
|
||||
if (ref != null) textures.put(key, ref);
|
||||
});
|
||||
}
|
||||
|
||||
return new FlatModel(textures, elements);
|
||||
FlatModel raced = cache.putIfAbsent(modelId, resolved);
|
||||
return raced != null ? raced : resolved;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
/**
|
||||
* A namespaced identifier as used throughout Minecraft assets, e.g. {@code minecraft:block/stone}.
|
||||
* When no namespace is present the default {@code minecraft} is assumed.
|
||||
*/
|
||||
/** A namespaced asset id such as {@code minecraft:block/stone}; lower-cased, namespace defaults to minecraft. */
|
||||
public record ResourceLocation(String namespace, String path) {
|
||||
|
||||
public static final String DEFAULT_NAMESPACE = "minecraft";
|
||||
@@ -13,9 +10,6 @@ public record ResourceLocation(String namespace, String path) {
|
||||
path = path.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string like {@code minecraft:block/stone} or {@code block/stone}.
|
||||
*/
|
||||
public static ResourceLocation parse(String raw) {
|
||||
int colon = raw.indexOf(':');
|
||||
if (colon < 0) {
|
||||
|
||||
@@ -4,22 +4,13 @@ import java.io.Closeable;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A read-only source of Minecraft assets. Paths are pack-relative and always use {@code /} as a
|
||||
* separator, e.g. {@code assets/minecraft/blockstates/oak_fence.json}.
|
||||
*
|
||||
* <p>Implementations must be safe for concurrent {@link #read} calls, since the renderer accesses
|
||||
* assets from multiple threads.
|
||||
* A read-only source of assets by pack-relative {@code /}-separated path, e.g.
|
||||
* {@code assets/minecraft/blockstates/oak_fence.json}. Must be safe for concurrent reads (parallel renderer).
|
||||
*/
|
||||
public interface ResourcePack extends Closeable {
|
||||
|
||||
/**
|
||||
* Reads the raw bytes of an asset, or an empty optional if it does not exist.
|
||||
*/
|
||||
Optional<byte[]> read(String path);
|
||||
|
||||
/**
|
||||
* Whether the given asset path exists in this pack.
|
||||
*/
|
||||
boolean exists(String path);
|
||||
|
||||
@Override
|
||||
|
||||
@@ -11,13 +11,11 @@ import java.util.logging.Logger;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} data folder. The folder may
|
||||
* contain an unpacked pack (a directory with {@code assets/minecraft/...}) and/or one or more
|
||||
* {@code .zip} packs. Returns an empty optional when nothing usable is found.
|
||||
* Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} folder: unpacked packs (the folder itself or
|
||||
* a direct sub-folder with {@code assets/minecraft}) and {@code .zip} packs anywhere below it, in that priority.
|
||||
*/
|
||||
public final class ResourcePackLoader {
|
||||
|
||||
/** The marker that identifies a valid pack root: {@code <root>/assets/minecraft}. */
|
||||
private static final String MARKER = "assets/minecraft";
|
||||
|
||||
private ResourcePackLoader() {}
|
||||
@@ -29,7 +27,6 @@ public final class ResourcePackLoader {
|
||||
|
||||
List<ResourcePack> packs = new ArrayList<>();
|
||||
|
||||
// Directory packs: probe the folder itself and any direct sub-folder for the assets marker.
|
||||
List<Path> dirCandidates = new ArrayList<>();
|
||||
dirCandidates.add(resourcePackDir.toPath());
|
||||
File[] children = resourcePackDir.listFiles(File::isDirectory);
|
||||
@@ -43,7 +40,6 @@ public final class ResourcePackLoader {
|
||||
}
|
||||
}
|
||||
|
||||
// Zip packs anywhere under the resourcepack folder.
|
||||
try (Stream<Path> walk = Files.walk(resourcePackDir.toPath())) {
|
||||
List<Path> zips = walk.filter(Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
||||
@@ -51,7 +47,10 @@ public final class ResourcePackLoader {
|
||||
for (Path zip : zips) {
|
||||
try {
|
||||
ZipResourcePack pack = new ZipResourcePack(zip);
|
||||
if (pack.exists(MARKER + "/blockstates") || pack.exists("pack.mcmeta") || hasAnyBlockstate(pack)) {
|
||||
// Zip entries are files only; the client jar has no pack.mcmeta, so probe two vanilla files.
|
||||
if (pack.exists("pack.mcmeta")
|
||||
|| pack.exists(AssetPaths.blockState("stone"))
|
||||
|| pack.exists(AssetPaths.blockState("dirt"))) {
|
||||
packs.add(pack);
|
||||
logger.info("Loaded resource pack zip: " + zip);
|
||||
} else {
|
||||
@@ -69,9 +68,4 @@ public final class ResourcePackLoader {
|
||||
if (packs.size() == 1) return Optional.of(packs.getFirst());
|
||||
return Optional.of(new CompositeResourcePack(packs));
|
||||
}
|
||||
|
||||
private static boolean hasAnyBlockstate(ResourcePack pack) {
|
||||
// Cheap sanity probe for a couple of guaranteed-present vanilla blockstates.
|
||||
return pack.exists(AssetPaths.blockState("stone")) || pack.exists(AssetPaths.blockState("dirt"));
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,8 @@ import java.util.Optional;
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
/**
|
||||
* Downloads and caches player skin textures (by URL) as ARGB pixel grids. Legacy 64x32 skins are
|
||||
* converted to the modern 64x64 layout (so the model's overlay/second-layer bones map correctly).
|
||||
* Downloads happen off the main thread (from the entity baking step) and are cached.
|
||||
* Player skins by URL as 64x64 ARGB grids (legacy 64x32 skins converted). Downloaded off the main thread, from
|
||||
* the baking step.
|
||||
*/
|
||||
public final class SkinCache extends ImageCache<String> {
|
||||
|
||||
@@ -42,14 +41,11 @@ public final class SkinCache extends ImageCache<String> {
|
||||
|
||||
/** Reads the image to a 64x64 grid, converting the legacy 64x32 layout (mirrored arm/leg) if needed. */
|
||||
private int[][] toModern(BufferedImage img) {
|
||||
int w = img.getWidth();
|
||||
int h = img.getHeight();
|
||||
int w = Math.min(img.getWidth(), 64);
|
||||
int h = Math.min(img.getHeight(), 64);
|
||||
int[][] out = new int[64][64];
|
||||
for (int y = 0; y < Math.min(h, 64); y++) {
|
||||
for (int x = 0; x < Math.min(w, 64); x++) {
|
||||
out[y][x] = img.getRGB(x, y);
|
||||
}
|
||||
}
|
||||
int[][] src = pixels(img, w, h);
|
||||
for (int y = 0; y < h; y++) System.arraycopy(src[y], 0, out[y], 0, w);
|
||||
if (h <= 32) {
|
||||
// Legacy skin: copy the right arm/leg regions into the modern left arm/leg slots (mirrored).
|
||||
copyMirrored(img, out, 44, 16, 36, 48); // right leg -> left leg (top/quads handled by mirror)
|
||||
|
||||
@@ -4,15 +4,7 @@ import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
/**
|
||||
* Loads and caches block textures as raw ARGB pixel grids. Textures are stored unflipped (vanilla
|
||||
* UV origin is top-left); all orientation is handled by the UV math in the intersector.
|
||||
*
|
||||
* <p>Animated textures (e.g. {@code water_still.png}, a vertical strip of frames) are reduced to their
|
||||
* first frame — but ONLY when an actual {@code .png.mcmeta} animation file is present. A tall sprite
|
||||
* without one (e.g. a 64×128 entity texture like the witch/strider) is a real texture, not an animation,
|
||||
* and must be loaded in full.
|
||||
*/
|
||||
/** Pack textures as ARGB grids by id (unflipped; the UV origin is top-left like vanilla). */
|
||||
public final class TextureCache extends ImageCache<ResourceLocation> {
|
||||
|
||||
private final ResourcePack pack;
|
||||
@@ -35,17 +27,9 @@ public final class TextureCache extends ImageCache<ResourceLocation> {
|
||||
|
||||
int width = img.getWidth();
|
||||
int height = img.getHeight();
|
||||
// Reduce animated strips to the first frame — but only a REAL animation (has a .mcmeta); a tall
|
||||
// sprite without one (e.g. a 64×128 entity texture) is a full texture, not a frame strip.
|
||||
// Animated strips keep their first frame, but only real animations (with a .mcmeta like vanilla): tall
|
||||
// entity textures (64×128 witch/strider) are full textures.
|
||||
boolean animated = height > width && height % width == 0 && pack.exists(AssetPaths.textureMeta(id));
|
||||
int frameHeight = animated ? width : height;
|
||||
|
||||
int[][] pixels = new int[frameHeight][width];
|
||||
for (int y = 0; y < frameHeight; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
pixels[y][x] = img.getRGB(x, y);
|
||||
}
|
||||
}
|
||||
return pixels;
|
||||
return pixels(img, width, animated ? width : height);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
/**
|
||||
* A resolved blockstate variant: which model to use plus its {@code x}/{@code y} rotation (in
|
||||
* degrees, multiples of 90) and {@code uvlock}.
|
||||
*/
|
||||
/** A blockstate variant: model, {@code x}/{@code y} rotation in degrees (multiples of 90) and {@code uvlock}. */
|
||||
public record Variant(ResourceLocation model, int x, int y, boolean uvlock) {}
|
||||
@@ -9,12 +9,7 @@ import java.util.Optional;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
/**
|
||||
* A {@link ResourcePack} backed by a {@code .zip} archive (e.g. a vanilla/custom resource pack).
|
||||
*
|
||||
* <p>{@link ZipFile} allows concurrent {@link ZipFile#getInputStream} calls, so reads are thread
|
||||
* safe. The entry lookup map is built once at construction.
|
||||
*/
|
||||
/** A {@link ResourcePack} backed by a {@code .zip}; {@link ZipFile} allows concurrent reads. */
|
||||
public final class ZipResourcePack implements ResourcePack {
|
||||
|
||||
private final ZipFile zipFile;
|
||||
|
||||
@@ -4,10 +4,7 @@ import com.google.gson.JsonElement;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Gson-bound representation of a vanilla block model JSON
|
||||
* ({@code assets/minecraft/models/block/*.json}).
|
||||
*/
|
||||
/** Gson binding of a block model JSON; coordinates and UVs in 0..16. */
|
||||
public class ModelFileDto {
|
||||
public String parent;
|
||||
// Values are texture refs ("block/stone", "#side") or, since 26.x, objects {"sprite": …, "force_translucent": …}.
|
||||
@@ -15,26 +12,25 @@ public class ModelFileDto {
|
||||
public List<ElementDto> elements;
|
||||
|
||||
public static class ElementDto {
|
||||
public double[] from; // 0..16
|
||||
public double[] to; // 0..16
|
||||
public RotationDto rotation; // optional
|
||||
public Map<String, FaceDto> faces; // keys: down/up/north/south/west/east
|
||||
public double[] from;
|
||||
public double[] to;
|
||||
public RotationDto rotation;
|
||||
public Map<String, FaceDto> faces; // keyed by direction name
|
||||
}
|
||||
|
||||
public static class FaceDto {
|
||||
public double[] uv; // optional, 0..16 (x1,y1,x2,y2)
|
||||
public double[] uv; // u1, v1, u2, v2; default from the element extents
|
||||
public String texture; // e.g. "#side" or "minecraft:block/oak_planks"
|
||||
public Integer tintindex;
|
||||
public String cullface; // ignored by the renderer
|
||||
public int rotation; // 0/90/180/270
|
||||
}
|
||||
|
||||
public static class RotationDto {
|
||||
public double[] origin; // 0..16
|
||||
public String axis; // "x" | "y" | "z"
|
||||
public double angle; // degrees (vanilla models: 22.5 steps)
|
||||
// Since 26.x alternatively Euler angles in degrees instead of axis/angle (CuboidRotation.EulerXYZRotation,
|
||||
// matrix Rz·Ry·Rx), e.g. the chains of template_hanging_sign_rot_3.
|
||||
public double[] origin;
|
||||
public String axis;
|
||||
public double angle; // degrees
|
||||
// Since 26.x alternatively Euler angles in degrees (CuboidRotation.EulerXYZRotation, Rz·Ry·Rx), e.g. the
|
||||
// chains of template_hanging_sign_rot_3.
|
||||
public Double x, y, z;
|
||||
public boolean rescale;
|
||||
}
|
||||
|
||||
@@ -2,24 +2,19 @@ package eu.mhsl.minecraft.pixelpics.assets.font;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A Minecraft bitmap font assembled from the resource pack's {@code font/default.json} providers
|
||||
* (the {@code bitmap} and {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf}
|
||||
* are out of scope). Maps codepoints to {@link Glyph}s and supplies advance widths, enough to rasterize
|
||||
* a line of text. Immutable after construction.
|
||||
*/
|
||||
/** Glyphs and advances of the pack's bitmap font (see {@link FontLoader}); immutable. */
|
||||
public final class BitmapFont {
|
||||
|
||||
private final Map<Integer, Glyph> glyphs;
|
||||
private final Map<Integer, Integer> spaceAdvances;
|
||||
private final int maxAscent;
|
||||
private final int maxDescent;
|
||||
private final int lineHeight;
|
||||
|
||||
public BitmapFont(Map<Integer, Glyph> glyphs, Map<Integer, Integer> spaceAdvances, int maxAscent, int maxDescent) {
|
||||
this.glyphs = glyphs;
|
||||
this.spaceAdvances = spaceAdvances;
|
||||
this.maxAscent = maxAscent;
|
||||
this.maxDescent = maxDescent;
|
||||
this.lineHeight = maxAscent + maxDescent;
|
||||
}
|
||||
|
||||
/** The glyph for a codepoint, or null when no bitmap provider supplies it (e.g. CJK via unihex). */
|
||||
@@ -35,19 +30,14 @@ public final class BitmapFont {
|
||||
return g != null ? g.advance() : 0;
|
||||
}
|
||||
|
||||
/** Largest baseline offset across all glyphs (font px) — the common baseline for a rendered line. */
|
||||
/** Largest ascent of all glyphs (font px): the common baseline of a line. */
|
||||
public int maxAscent() {
|
||||
return maxAscent;
|
||||
}
|
||||
|
||||
/** Largest below-baseline extent across all glyphs (font px). */
|
||||
public int maxDescent() {
|
||||
return maxDescent;
|
||||
}
|
||||
|
||||
/** Total vertical extent of one line (ascent + descent, font px). */
|
||||
/** Largest ascent + largest descent (font px). */
|
||||
public int lineHeight() {
|
||||
return maxAscent + maxDescent;
|
||||
return lineHeight;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
|
||||
@@ -8,6 +8,8 @@ import eu.mhsl.minecraft.pixelpics.assets.AssetPaths;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.ResourcePack;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
@@ -17,19 +19,15 @@ import java.util.Set;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Builds a {@link BitmapFont} from a resource pack's {@code font/default.json}. Resolves {@code reference}
|
||||
* providers recursively, parses {@code bitmap} providers into per-codepoint {@link Glyph}s (cell size and
|
||||
* per-glyph pixel width derived from the PNG), and {@code space} providers into advance overrides.
|
||||
* {@code unihex}/{@code legacy_unicode}/{@code ttf} providers are skipped (CJK etc. render as gaps).
|
||||
*
|
||||
* <p>Provider order is priority order (first match wins, matching vanilla): an earlier provider's glyph is
|
||||
* never overwritten by a later one.
|
||||
* Builds a {@link BitmapFont} from the pack's {@code font/default.json}: {@code reference}, {@code bitmap} and
|
||||
* {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf} are skipped (CJK etc. render as
|
||||
* gaps). Earlier providers win, like vanilla.
|
||||
*/
|
||||
public final class FontLoader {
|
||||
|
||||
private FontLoader() {}
|
||||
|
||||
/** Loads {@code minecraft:default}. Returns an empty font (no glyphs) when the pack has no font assets. */
|
||||
/** {@code minecraft:default}; a font without glyphs when the pack has no font assets. */
|
||||
public static BitmapFont load(ResourcePack pack, TextureCache textures, Logger log) {
|
||||
Builder b = new Builder(pack, textures, log);
|
||||
b.loadFont(ResourceLocation.parse("minecraft:default"), new HashSet<>());
|
||||
@@ -57,10 +55,9 @@ public final class FontLoader {
|
||||
|
||||
void loadFont(ResourceLocation id, Set<String> visited) {
|
||||
String path = AssetPaths.font(id);
|
||||
if (!visited.add(path)) return; // cycle guard
|
||||
if (!visited.add(path)) return;
|
||||
Optional<byte[]> bytes = pack.read(path);
|
||||
// Packs built from just the textures/models lack font/*.json — use the bundled vanilla font
|
||||
// definitions (tiny JSON; the glyph sheets still come from the pack's textures/font/).
|
||||
// Packs without font/*.json: the bundled vanilla definitions (glyph sheets still come from the pack).
|
||||
if (bytes.isEmpty()) bytes = bundled(path);
|
||||
if (bytes.isEmpty()) return;
|
||||
JsonObject root;
|
||||
@@ -68,7 +65,7 @@ public final class FontLoader {
|
||||
root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8))
|
||||
.getAsJsonObject();
|
||||
} catch (Exception e) {
|
||||
if (log != null) log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage());
|
||||
log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
JsonArray providers = root.getAsJsonArray("providers");
|
||||
@@ -80,11 +77,11 @@ public final class FontLoader {
|
||||
}
|
||||
|
||||
private Optional<byte[]> bundled(String path) {
|
||||
try (java.io.InputStream in = FontLoader.class.getResourceAsStream("/fallback/" + path)) {
|
||||
try (InputStream in = FontLoader.class.getResourceAsStream("/fallback/" + path)) {
|
||||
if (in == null) return Optional.empty();
|
||||
if (log != null) log.info("PixelPics: resource pack has no " + path + ", using the bundled copy.");
|
||||
log.info("PixelPics: resource pack has no " + path + ", using the bundled copy.");
|
||||
return Optional.of(in.readAllBytes());
|
||||
} catch (java.io.IOException e) {
|
||||
} catch (IOException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -97,9 +94,7 @@ public final class FontLoader {
|
||||
}
|
||||
case "space" -> space(p);
|
||||
case "bitmap" -> bitmap(p);
|
||||
default -> {
|
||||
/* unihex, legacy_unicode, ttf, … — out of scope */
|
||||
}
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +118,7 @@ public final class FontLoader {
|
||||
if (file.endsWith(".png")) file = file.substring(0, file.length() - 4);
|
||||
int[][] tex = textures.get(ResourceLocation.parse(file)).orElse(null);
|
||||
if (tex == null || tex.length == 0) {
|
||||
if (log != null) log.warning("PixelPics: font bitmap missing: " + file);
|
||||
log.warning("PixelPics: font bitmap missing: " + file);
|
||||
return;
|
||||
}
|
||||
int imgH = tex.length;
|
||||
@@ -132,9 +127,8 @@ public final class FontLoader {
|
||||
JsonArray rows = p.getAsJsonArray("chars");
|
||||
int nRows = rows.size();
|
||||
if (nRows == 0) return;
|
||||
int nCols = rows.get(0)
|
||||
.getAsString()
|
||||
.codePointCount(0, rows.get(0).getAsString().length());
|
||||
String firstRow = rows.get(0).getAsString();
|
||||
int nCols = firstRow.codePointCount(0, firstRow.length());
|
||||
if (nCols == 0) return;
|
||||
int cellW = imgW / nCols;
|
||||
int cellH = imgH / nRows;
|
||||
@@ -153,7 +147,7 @@ public final class FontLoader {
|
||||
int srcX = thisCol * cellW;
|
||||
int srcY = r * cellH;
|
||||
int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1;
|
||||
if (glyphPx <= 0) continue; // blank glyph (handled by space provider)
|
||||
if (glyphPx <= 0) continue; // blank: spaces come from the space provider
|
||||
int advance = (int) (glyphPx * scale + 0.5) + 1;
|
||||
glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance));
|
||||
maxAscent = Math.max(maxAscent, ascent);
|
||||
@@ -162,13 +156,13 @@ public final class FontLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rightmost column index (0-based, relative to the cell) containing an opaque pixel, or -1 if blank. */
|
||||
/** Rightmost cell column with a non-transparent pixel, -1 when blank. */
|
||||
private static int rightmostOpaqueColumn(int[][] tex, int srcX, int srcY, int cellW, int cellH) {
|
||||
int last = -1;
|
||||
for (int x = 0; x < cellW; x++) {
|
||||
for (int y = 0; y < cellH; y++) {
|
||||
int argb = tex[srcY + y][srcX + x];
|
||||
if (((argb >>> 24) & 0xFF) != 0) {
|
||||
if (argb >>> 24 != 0) {
|
||||
last = x;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.font;
|
||||
|
||||
/**
|
||||
* A single bitmap-font glyph: a rectangular cell {@code [srcX, srcX+cellW) × [srcY, srcY+cellH)} inside
|
||||
* its provider PNG ({@code tex}, ARGB top-left), the number of opaque pixel columns to actually draw
|
||||
* ({@code glyphPx}, measured to the rightmost non-transparent column), the rendered {@code height} and
|
||||
* {@code ascent} (baseline offset from the top, font px) and the horizontal {@code advance} (font px).
|
||||
*
|
||||
* <p>Glyphs from different providers may declare different cell sizes and ascents (e.g. ascii 8px/ascent
|
||||
* 7 vs accented 12px/ascent 10); rendering aligns them on a common baseline so umlauts and Latin mix.
|
||||
* A bitmap-font glyph: its cell in the provider sheet {@code tex}, the drawn width {@code glyphPx} (up to the
|
||||
* rightmost non-transparent column), and {@code height}, {@code ascent} and {@code advance} in font px. Providers
|
||||
* differ in cell size and ascent (ascii 8/7, accented 12/10), so lines align glyphs on a common baseline.
|
||||
*/
|
||||
public record Glyph(
|
||||
int[][] tex, int srcX, int srcY, int cellW, int cellH, int glyphPx, int height, int ascent, int advance) {
|
||||
|
||||
/** Vertical pixels below the baseline (font px). */
|
||||
public int descent() {
|
||||
return height - ascent;
|
||||
}
|
||||
}
|
||||
int[][] tex, int srcX, int srcY, int cellW, int cellH, int glyphPx, int height, int ascent, int advance) {}
|
||||
@@ -1,47 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||
|
||||
/**
|
||||
* Computes the average opaque color of a texture, used as the 100% coverage fallback.
|
||||
*/
|
||||
public final class AverageColor {
|
||||
|
||||
private AverageColor() {}
|
||||
|
||||
/** Average ARGB over pixels with alpha > 16; returns opaque gray when fully transparent/empty. */
|
||||
public static int of(int[][] texture) {
|
||||
long r = 0, g = 0, b = 0;
|
||||
int count = 0;
|
||||
for (int[] row : texture) {
|
||||
for (int argb : row) {
|
||||
if (ColorUtil.alpha(argb) <= 16) continue;
|
||||
r += ColorUtil.red(argb);
|
||||
g += ColorUtil.green(argb);
|
||||
b += ColorUtil.blue(argb);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count == 0) return 0xFF7F7F7F;
|
||||
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
|
||||
}
|
||||
|
||||
/** Mutable accumulator that averages a set of already-opaque ARGB colors (e.g. per-face/per-variant). */
|
||||
public static final class Accumulator {
|
||||
private long r, g, b;
|
||||
private int count;
|
||||
|
||||
public void add(int argb) {
|
||||
r += ColorUtil.red(argb);
|
||||
g += ColorUtil.green(argb);
|
||||
b += ColorUtil.blue(argb);
|
||||
count++;
|
||||
}
|
||||
|
||||
/** Opaque average of the added colors, or {@code fallback} if none were added. */
|
||||
public int average(int fallback) {
|
||||
if (count == 0) return fallback;
|
||||
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
|
||||
/**
|
||||
* The six block face directions, with unit normals and 90-degree rotation helpers used when baking
|
||||
* blockstate {@code x}/{@code y} rotations.
|
||||
*/
|
||||
/** The six block face directions with unit normals and the blockstate 90° rotations. */
|
||||
public enum Direction {
|
||||
DOWN(0, -1, 0),
|
||||
UP(0, 1, 0),
|
||||
@@ -54,9 +51,9 @@ public enum Direction {
|
||||
};
|
||||
}
|
||||
|
||||
/** Rotate this direction by {@code steps * 90} degrees around the Y axis (clockwise from above). */
|
||||
/** Rotated by {@code steps}·90° about Y, clockwise seen from above (vanilla blockstate {@code y}). */
|
||||
public Direction rotateY(int steps) {
|
||||
steps = ((steps % 4) + 4) % 4;
|
||||
steps = Math.floorMod(steps, 4);
|
||||
Direction d = this;
|
||||
for (int i = 0; i < steps; i++) {
|
||||
d = switch (d) {
|
||||
@@ -64,15 +61,14 @@ public enum Direction {
|
||||
case EAST -> SOUTH;
|
||||
case SOUTH -> WEST;
|
||||
case WEST -> NORTH;
|
||||
// up/down unchanged
|
||||
default -> d;};
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Rotate this direction by {@code steps * 90} degrees around the X axis. */
|
||||
/** Rotated by {@code steps}·90° about X (vanilla blockstate {@code x}). */
|
||||
public Direction rotateX(int steps) {
|
||||
steps = ((steps % 4) + 4) % 4;
|
||||
steps = Math.floorMod(steps, 4);
|
||||
Direction d = this;
|
||||
for (int i = 0; i < steps; i++) {
|
||||
d = switch (d) {
|
||||
@@ -80,7 +76,6 @@ public enum Direction {
|
||||
case NORTH -> DOWN;
|
||||
case DOWN -> SOUTH;
|
||||
case SOUTH -> UP;
|
||||
// east/west unchanged
|
||||
default -> d;};
|
||||
}
|
||||
return d;
|
||||
|
||||
@@ -1,48 +1,45 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
|
||||
/**
|
||||
* A single box of a baked block model. Coordinates are normalized to the 0..1 block cube. Faces are
|
||||
* indexed by {@link Direction#ordinal()} and may be {@code null} when absent.
|
||||
*
|
||||
* <p>An optional element rotation (from the model JSON) is kept as origin/axis/angle so the
|
||||
* intersector can treat the box as oriented (OBB) when {@code angle != 0}.
|
||||
* A box of a baked block model in block units (0..1 inside the cell); faces indexed by {@link Direction#ordinal()},
|
||||
* null when absent. {@code from > to} on an axis marks an inverted element (inward-facing faces, like vanilla).
|
||||
*/
|
||||
public final class Element {
|
||||
|
||||
public final double[] from; // length 3, 0..1
|
||||
public final double[] to; // length 3, 0..1
|
||||
public final Face[] faces; // length 6, indexed by Direction.ordinal()
|
||||
public final double[] from;
|
||||
public final double[] to;
|
||||
public final Face[] faces;
|
||||
|
||||
// Element rotation (0..1 origin), null/zero when axis-aligned.
|
||||
public final double[] rotOrigin; // length 3, 0..1, may be null
|
||||
public final int rotAxis; // 0=x,1=y,2=z, AXIS_MATRIX (Euler x/y/z rotation in rotMatrix), -1 when none
|
||||
public final double rotAngleRad; // radians (single-axis rotations only)
|
||||
// Own element rotation about rotOrigin: single axis (0=x, 1=y, 2=z) by rotAngleRad, or AXIS_MATRIX for a
|
||||
// general (Euler x/y/z) rotation in rotMatrix; rotAxis -1 and rotOrigin null when unrotated.
|
||||
public final double[] rotOrigin;
|
||||
public final int rotAxis;
|
||||
public final double rotAngleRad;
|
||||
public final boolean rescale;
|
||||
|
||||
/** {@link #rotAxis} of an element rotated by a general (Euler x/y/z) rotation held in {@link #rotMatrix}. */
|
||||
public static final int AXIS_MATRIX = 3;
|
||||
|
||||
// cos/sin of the element rotation (+angle: local -> block) and of its inverse (-angle: block -> local),
|
||||
// precomputed for the intersector's hot path.
|
||||
// Precomputed for the intersector's hot path: cos/sin of +angle (local -> block) and -angle (block -> local).
|
||||
public final double rotCos, rotSin, invRotCos, invRotSin;
|
||||
|
||||
// General rotation (rotAxis == AXIS_MATRIX): row-major 3x3 local -> block and its inverse (transpose); null
|
||||
// otherwise.
|
||||
// Row-major 3x3 local -> block and its transpose, only for AXIS_MATRIX.
|
||||
public final double[] rotMatrix, invRotMatrix;
|
||||
|
||||
// Vanilla rescale (CuboidRotation.computeRescale): the element is scaled per LOCAL axis by
|
||||
// 1 / max|component of R·axis| before the rotation (1/cos 22.5° or 1/cos 45° on the axes the rotation tilts),
|
||||
// so a rotated cross plane still spans the whole block. Reciprocals (block -> local); null without rescale.
|
||||
// Vanilla rescale (CuboidRotation.computeRescale): scale per LOCAL axis by 1 / max|component of R·axis| before
|
||||
// rotating, so a tilted cross plane still spans the block. Stored as reciprocals (block -> local); null without.
|
||||
public final double[] invScale;
|
||||
|
||||
// Blockstate rotation: the model-space element and the 90° steps (x first, then y) that turn it into this
|
||||
// world-space one. Intersectors hit the unrotated element with the inverse-rotated ray, so textures stay
|
||||
// bound to the model (vanilla); under uvlock its faces carry a UV transform keeping them world-aligned.
|
||||
// Null/0 when unrotated.
|
||||
// world-space one; null/0 when unrotated. Intersectors hit the unrotated element with the inverse-rotated ray.
|
||||
public final Element unrotated;
|
||||
public final int xSteps;
|
||||
public final int ySteps;
|
||||
|
||||
/** An unrotated box. */
|
||||
public Element(double[] from, double[] to, Face[] faces) {
|
||||
this(from, to, faces, null, -1, 0, null, false, null, 0, 0);
|
||||
}
|
||||
|
||||
public Element(
|
||||
double[] from,
|
||||
double[] to,
|
||||
@@ -50,50 +47,11 @@ public final class Element {
|
||||
double[] rotOrigin,
|
||||
int rotAxis,
|
||||
double rotAngleRad,
|
||||
boolean rescale) {
|
||||
this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, null, 0, 0);
|
||||
}
|
||||
|
||||
/** An element rotated by a general rotation matrix (row-major 3x3, local -> block, e.g. Euler x/y/z). */
|
||||
public Element(
|
||||
double[] from,
|
||||
double[] to,
|
||||
Face[] faces,
|
||||
double[] rotOrigin,
|
||||
double[] rotMatrix,
|
||||
boolean rescale,
|
||||
Element unrotated,
|
||||
int xSteps,
|
||||
int ySteps) {
|
||||
this(from, to, faces, rotOrigin, AXIS_MATRIX, 0, rescale, unrotated, xSteps, ySteps, rotMatrix);
|
||||
}
|
||||
|
||||
public Element(
|
||||
double[] from,
|
||||
double[] to,
|
||||
Face[] faces,
|
||||
double[] rotOrigin,
|
||||
int rotAxis,
|
||||
double rotAngleRad,
|
||||
boolean rescale,
|
||||
Element unrotated,
|
||||
int xSteps,
|
||||
int ySteps) {
|
||||
this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, unrotated, xSteps, ySteps, null);
|
||||
}
|
||||
|
||||
private Element(
|
||||
double[] from,
|
||||
double[] to,
|
||||
Face[] faces,
|
||||
double[] rotOrigin,
|
||||
int rotAxis,
|
||||
double rotAngleRad,
|
||||
boolean rescale,
|
||||
Element unrotated,
|
||||
int xSteps,
|
||||
int ySteps,
|
||||
double[] rotMatrix) {
|
||||
this.unrotated = unrotated;
|
||||
this.xSteps = xSteps;
|
||||
this.ySteps = ySteps;
|
||||
@@ -113,6 +71,12 @@ public final class Element {
|
||||
this.invScale = rescale && !isAxisAligned() ? inverseRescale(linearRotation()) : null;
|
||||
}
|
||||
|
||||
/** This element with other faces (rotation and blockstate rotation kept). */
|
||||
public Element withFaces(Face[] faces) {
|
||||
return new Element(
|
||||
from, to, faces, rotOrigin, rotAxis, rotAngleRad, rotMatrix, rescale, unrotated, xSteps, ySteps);
|
||||
}
|
||||
|
||||
/** The rotation as a row-major 3x3 (local -> block), identity when axis-aligned. */
|
||||
public double[] linearRotation() {
|
||||
if (rotMatrix != null) return rotMatrix.clone();
|
||||
@@ -125,12 +89,11 @@ public final class Element {
|
||||
};
|
||||
}
|
||||
|
||||
/** Vanilla rescale factors per local axis: 1 / max|component of R·axis| (column of R); as reciprocals. */
|
||||
/** Reciprocal rescale per local axis: max|component of R·axis| (column a of R). */
|
||||
private static double[] inverseRescale(double[] r) {
|
||||
double[] inv = new double[3];
|
||||
for (int a = 0; a < 3; a++) {
|
||||
double max = Math.max(Math.max(Math.abs(r[a]), Math.abs(r[3 + a])), Math.abs(r[6 + a]));
|
||||
inv[a] = max; // 1 / (1 / max)
|
||||
inv[a] = Math.max(Math.max(Math.abs(r[a]), Math.abs(r[3 + a])), Math.abs(r[6 + a]));
|
||||
}
|
||||
return inv;
|
||||
}
|
||||
@@ -152,7 +115,7 @@ public final class Element {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** {@link #rotate} into {@code out} (allocation-free, for the intersectors). */
|
||||
/** {@link #rotate} into {@code out} (allocation-free). */
|
||||
public static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
|
||||
double c = point ? 0.5 : 0;
|
||||
double dx = x - c, dy = y - c, dz = z - c;
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
|
||||
/**
|
||||
* A textured face of an {@link Element}. UV coordinates are normalized to 0..1 in texture space with
|
||||
* the origin at the top-left ({@code v} increasing downwards), matching the vanilla convention.
|
||||
*/
|
||||
/** A textured face of an {@link Element}; UVs in 0..1 with a top-left origin (v down), like vanilla. */
|
||||
public final class Face {
|
||||
|
||||
public final int[][] texture; // [y][x] ARGB, top-left origin
|
||||
public final double u1, v1, u2, v2;
|
||||
public final int rotation; // 0/90/180/270, applied to the sampled UV
|
||||
public final int rotation; // 0/90/180/270 (normalised), applied to the sampled UV
|
||||
public final int tintIndex; // -1 = no tint
|
||||
|
||||
// uvlock: affine applied to the final texture (u, v) as {a, b, c, d, e, f} with u' = a + b*u + c*v and
|
||||
@@ -27,7 +24,7 @@ public final class Face {
|
||||
this.v1 = v1;
|
||||
this.u2 = u2;
|
||||
this.v2 = v2;
|
||||
this.rotation = rotation;
|
||||
this.rotation = Math.floorMod(rotation, 360);
|
||||
this.tintIndex = tintIndex;
|
||||
}
|
||||
|
||||
@@ -41,14 +38,10 @@ public final class Face {
|
||||
return new Face(texture, u1, v1, u2, v2, rotation, tintIndex, lock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples the ARGB pixel for a position within the face, where {@code (s, t)} are in 0..1 across
|
||||
* the face's two in-plane axes (s = horizontal, t = vertical, top-left origin).
|
||||
*/
|
||||
/** The ARGB texel at {@code (s, t)} in 0..1 across the face (s horizontal, t vertical, top-left origin). */
|
||||
public int sample(double s, double t) {
|
||||
// Apply face rotation by rotating the (s,t) lookup.
|
||||
double rs = s, rt = t;
|
||||
switch (((rotation % 360) + 360) % 360) {
|
||||
switch (rotation) {
|
||||
case 90 -> {
|
||||
rs = t;
|
||||
rt = 1.0 - s;
|
||||
|
||||
@@ -3,34 +3,18 @@ package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A fully baked, intersectable block model: a list of {@link Element} boxes plus rendering hints.
|
||||
*
|
||||
* <p>{@code averageColor} is the 100% coverage fallback used for blocks without geometry
|
||||
* (builtin/generated models, unresolved blocks) and as a backstop. {@code hasGeometry} is false when
|
||||
* the model has no usable elements; the renderer then draws a flat shaded cube using
|
||||
* {@code averageColor}.
|
||||
* A baked block model. {@code hasGeometry} is false for empty models and for the solid fallback cube of blocks
|
||||
* without model elements. Deliberately not a record: callers key caches on identity (one instance per BlockData).
|
||||
*/
|
||||
public final class ResolvedModel {
|
||||
|
||||
public static final ResolvedModel EMPTY = new ResolvedModel(List.of(), false);
|
||||
|
||||
public final List<Element> elements;
|
||||
public final int averageColor; // ARGB
|
||||
public final double transparency; // 0..1
|
||||
public final double reflection; // 0..1
|
||||
public final boolean occluding;
|
||||
public final boolean hasGeometry;
|
||||
|
||||
public ResolvedModel(
|
||||
List<Element> elements,
|
||||
int averageColor,
|
||||
double transparency,
|
||||
double reflection,
|
||||
boolean occluding,
|
||||
boolean hasGeometry) {
|
||||
public ResolvedModel(List<Element> elements, boolean hasGeometry) {
|
||||
this.elements = elements;
|
||||
this.averageColor = averageColor;
|
||||
this.transparency = transparency;
|
||||
this.reflection = reflection;
|
||||
this.occluding = occluding;
|
||||
this.hasGeometry = hasGeometry;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user