From 6c0d28579c969858c9cac7b91ca02cb363c261f8 Mon Sep 17 00:00:00 2001 From: Natan Date: Mon, 3 Aug 2026 11:41:54 -0300 Subject: [PATCH 1/2] feat: render real item icons in the IntelliJ inventory preview --- intellij-plugin/TOOLING_SUPPORT.md | 25 ++- .../intellij/InventoryPreviewPanel.kt | 25 ++- .../intellij/ItemIconProvider.kt | 152 ++++++++++++++++++ .../intellij/MinecraftIconSettings.kt | 39 +++++ .../MinecraftIconSettingsConfigurable.kt | 55 +++++++ .../src/main/resources/META-INF/plugin.xml | 5 + 6 files changed, 286 insertions(+), 15 deletions(-) create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemIconProvider.kt create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettings.kt create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettingsConfigurable.kt diff --git a/intellij-plugin/TOOLING_SUPPORT.md b/intellij-plugin/TOOLING_SUPPORT.md index b5c8262d..f210fa61 100644 --- a/intellij-plugin/TOOLING_SUPPORT.md +++ b/intellij-plugin/TOOLING_SUPPORT.md @@ -45,17 +45,26 @@ the user's code, so anything that depends on runtime state can only ever be appr nearest-neighbor interpolation to keep the pixel art crisp. Slot content (material color+label, dynamic marker, layout fill) is overlaid at the sprite's real slot positions. - Every other view type still renders as a plain drawn grid — there's no sprite for them. +- **Real item icons** (`ItemIconProvider`), opportunistically: if the machine running the IDE has + a vanilla Minecraft client installed, icons are read directly from that client jar's + `assets/minecraft/textures/{item,block}/.png` at render time — nothing is bundled or + redistributed by the plugin itself, since Mojang's usage guidelines prohibit that for + third-party tools. Animated textures are cropped to their first frame and anything above 16x16 + is downscaled. If a material has no same-named texture file (e.g. a stained glass pane's icon is + really just its plain glass block's texture), the reference is read out of the item's own + `assets/minecraft/models/item/.json` instead - one model deep, without following + parent chains or resolving `"#variable"` texture substitution, so composite block-shaped items + whose icon only inherits a texture from a parent model (fences, walls, carpets, stairs, ...) + still fall back to the placeholder. When no client jar can be found, slots fall back to the + original colored square + 3-letter material abbreviation. (The bundled chest frame sprites are + original/generic art, not extracted Mojang textures, so they don't carry the same restriction and + are unaffected either way.) The `.minecraft` directory used for auto-detection can be overridden + per-machine in **Settings > Tools > Inventory Framework** (`MinecraftIconSettings`), for setups + the platform default guess can't find (portable/custom launchers, an install on another drive, + etc.). ## Known limitations / not supported -- **No real item icons.** Items still render as a deterministic colored square + a 3-letter - material abbreviation, not actual item textures — only the chest *frame* is a real sprite, not - the items placed inside it. Mojang's usage guidelines prohibit redistributing or serving game - assets from a tool, which ruled out both bundling an item texture pack and fetching from any - hosted API (including reputable-looking third-party ones). The compliant path — reading item - textures from a client jar the user already owns, entirely locally — was scoped out as a - separate follow-up, not built in this pass. (The bundled chest frame sprites are original/generic - art, not extracted Mojang textures, so they don't carry the same restriction.) - **Nothing dynamic is ever evaluated.** Non-literal titles, `renderWith`/`onRender` lambdas, `displayIf`/state-driven conditions, and any item expression that isn't a literal `new ItemStack(Material.X)` (directly or via a simple local variable) all show as a generic diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewPanel.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewPanel.kt index a048b4df..65a47fc4 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewPanel.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewPanel.kt @@ -306,25 +306,36 @@ class InventoryPreviewPanel : JPanel() { // isn't "filled" - we just don't know what's actually rendered there - so it's still // subject to the empty-slot toggle like any other unfilled slot. val isLayoutPlaceholder = !isFilled && layoutChar != null && layoutChar != ' ' + // Only resolves to a real texture if the user has a local Minecraft client jar installed + // (see ItemIconProvider); otherwise null and the colored-square placeholder below is used. + val icon = slot?.material?.let { ItemIconProvider.iconFor(it) } if (isFilled || ((paintEmptyBackground || isLayoutPlaceholder) && showEmptySlots)) { - g.color = when { - slot?.dynamic == true -> JBColor.YELLOW - slot?.material != null -> colorForMaterial(slot.material) - isLayoutPlaceholder -> JBColor.LIGHT_GRAY - else -> JBColor.GRAY + if (icon == null) { + g.color = when { + slot?.dynamic == true -> JBColor.YELLOW + slot?.material != null -> colorForMaterial(slot.material) + isLayoutPlaceholder -> JBColor.LIGHT_GRAY + else -> JBColor.GRAY + } + g.fillRect(x + 1, y + 1, size - 2, size - 2) } - g.fillRect(x + 1, y + 1, size - 2, size - 2) if (paintEmptyBackground) { g.color = JBColor.DARK_GRAY g.drawRect(x, y, size, size) } } + if (icon != null) { + (g as Graphics2D).setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR) + val inset = size / 16 + g.drawImage(icon, x + inset, y + inset, size - inset * 2, size - inset * 2, null) + } + g.color = Color.BLACK when { slot?.dynamic == true -> g.drawString("?", x + size / 2 - 3, y + size / 2 + 5) - slot?.material != null -> g.drawString(abbreviateMaterial(slot.material), x + 3, y + size - 4) + slot?.material != null && icon == null -> g.drawString(abbreviateMaterial(slot.material), x + 3, y + size - 4) } if (index in highlightedSlotIndices) { diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemIconProvider.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemIconProvider.kt new file mode 100644 index 00000000..5d2410f0 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemIconProvider.kt @@ -0,0 +1,152 @@ +package me.devnatan.inventoryframework.intellij + +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.File +import java.util.Locale +import java.util.zip.ZipFile +import javax.imageio.ImageIO + +private const val ICON_SIZE = 16 +private const val TEXTURE_ROOT = "assets/minecraft/textures" +private const val MODEL_ROOT = "assets/minecraft/models" + +// Preference order when a model declares more than one texture layer/face - layer0 is the +// standard flat-icon key ("item/generated" models), the rest are common cube-model face names; +// picking any single one is only ever an approximation of the real (sometimes multi-layered or +// tinted) icon, but it's a much closer one than the placeholder. +private val TEXTURE_KEY_PRIORITY = listOf("layer0", "all", "side", "particle", "texture", "top", "cross") + +// Reads item icons directly from a Minecraft client jar already installed on this machine, on +// demand and entirely locally - the plugin itself never bundles or serves any extracted game +// asset, since Mojang's usage guidelines prohibit that for third-party tools (see +// "No real item icons" in TOOLING_SUPPORT.md). If no client jar can be found, every lookup +// returns null and callers fall back to the existing placeholder rendering. +object ItemIconProvider { + + // Sentinel distinguishing "never resolved a jar yet" from a resolved-but-null result, so a + // client jar that's genuinely missing isn't retried (and re-scanned) on every single lookup. + private object Unresolved + + private var resolvedForSetting: Any? = Unresolved + private var cachedJar: ZipFile? = null + private val cache = mutableMapOf() + + @Synchronized + fun iconFor(material: String): BufferedImage? { + val key = material.lowercase(Locale.ROOT) + return cache.getOrPut(key) { loadIcon(key) } + } + + private fun loadIcon(name: String): BufferedImage? { + val jar = clientJar() ?: return null + val direct = readEntry(jar, "$TEXTURE_ROOT/item/$name.png") ?: readEntry(jar, "$TEXTURE_ROOT/block/$name.png") + val raw = direct ?: modelTexturePath(jar, name)?.let { readEntry(jar, "$TEXTURE_ROOT/$it.png") } + return raw?.let(::normalize) + } + + // Some items have no texture file that matches their own name - a stained glass pane's icon, + // for instance, is just its plain glass block's texture (see models/item/*_pane.json's + // "layer0"), not a "*_pane.png" that doesn't exist. Rather than hardcoding every such case, + // read the reference straight out of the item's own model JSON. Only resolves one model deep + // (no parent-chain walk, no "#variable" texture substitution), so composite block-shaped + // items whose model only inherits a texture from a parent (fences, walls, carpets, stairs...) + // are a known remaining gap - they still fall back to the placeholder, same as before. + private fun modelTexturePath(jar: ZipFile, name: String): String? { + val json = readText(jar, "$MODEL_ROOT/item/$name.json") ?: return null + val texturesBlock = Regex(""""textures"\s*:\s*\{([^}]*)}""").find(json)?.groupValues?.get(1) ?: return null + val entries = Regex(""""(\w+)"\s*:\s*"([^"]+)"""").findAll(texturesBlock) + .associate { it.groupValues[1] to it.groupValues[2] } + val value = TEXTURE_KEY_PRIORITY.firstNotNullOfOrNull { entries[it] } ?: entries.values.firstOrNull() + return value?.removePrefix("minecraft:")?.takeUnless { it.startsWith("#") } + } + + private fun readText(jar: ZipFile, path: String): String? { + val entry = jar.getEntry(path) ?: return null + return jar.getInputStream(entry).use { runCatching { it.readBytes().toString(Charsets.UTF_8) }.getOrNull() } + } + + // Re-resolves whenever the configured override changes (e.g. the dev just saved a new path in + // Settings > Tools > Inventory Framework), rather than caching it for the plugin's whole + // lifetime like a plain `by lazy` would - otherwise editing the setting would need an IDE + // restart to take effect. + @Synchronized + private fun clientJar(): ZipFile? { + val configuredHome = MinecraftIconSettings.getInstance().minecraftHome.ifBlank { null } + if (resolvedForSetting != configuredHome) { + resolvedForSetting = configuredHome + cache.clear() + cachedJar = locateClientJar(configuredHome)?.let { runCatching { ZipFile(it) }.getOrNull() } + } + return cachedJar + } + + private fun readEntry(jar: ZipFile, path: String): BufferedImage? { + val entry = jar.getEntry(path) ?: return null + return jar.getInputStream(entry).use { runCatching { ImageIO.read(it) }.getOrNull() } + } + + // Animated textures are stored as a vertical strip of square frames (width == frame size); + // the first frame is the top width-by-width square. Anything still bigger than one icon cell + // afterwards (e.g. 32x32 items) is then downscaled. + private fun normalize(image: BufferedImage): BufferedImage { + val square = if (image.width != image.height) { + image.getSubimage(0, 0, image.width, minOf(image.width, image.height)) + } else { + image + } + if (square.width == ICON_SIZE && square.height == ICON_SIZE) return square + + val scaled = BufferedImage(ICON_SIZE, ICON_SIZE, BufferedImage.TYPE_INT_ARGB) + val g = scaled.createGraphics() + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + g.drawImage(square, 0, 0, ICON_SIZE, ICON_SIZE, null) + } finally { + g.dispose() + } + return scaled + } + + // Picks the newest installed release-named version whose jar actually contains item + // textures, falling back to whatever else is there (e.g. a modloader profile jar) sorted by + // recency. Modloader profiles set up by the vanilla launcher often "inheritsFrom" a vanilla + // version instead of bundling assets themselves - those are skipped by the texture check + // rather than treated as an error, since the real vanilla version is usually also installed. + // + // `configuredHome` is the dev's override from Settings > Tools > Inventory Framework + // (MinecraftIconSettings); null means it's unset and the platform default guess is used. + private fun locateClientJar(configuredHome: String?): File? { + val home = configuredHome?.let(::File) ?: minecraftHome() ?: return null + val versionsDir = File(home, "versions").takeIf { it.isDirectory } ?: return null + val candidates = versionsDir.listFiles { f -> f.isDirectory } + ?.mapNotNull { dir -> File(dir, "${dir.name}.jar").takeIf { it.isFile } } + ?: return null + + val releasePattern = Regex("""^\d+\.\d+(\.\d+)?$""") + val releases = candidates.filter { releasePattern.matches(it.parentFile.name) } + .sortedByDescending { versionSortKey(it.parentFile.name) } + val rest = candidates.filterNot { it in releases }.sortedByDescending { it.lastModified() } + + return (releases + rest).firstOrNull(::hasItemTextures) + } + + private fun versionSortKey(version: String): Int { + val parts = version.split('.').map { it.toIntOrNull() ?: 0 } + return parts.getOrElse(0) { 0 } * 1_000_000 + parts.getOrElse(1) { 0 } * 1_000 + parts.getOrElse(2) { 0 } + } + + private fun hasItemTextures(jar: File): Boolean = + runCatching { ZipFile(jar).use { it.getEntry("$TEXTURE_ROOT/item/apple.png") != null } }.getOrDefault(false) + + private fun minecraftHome(): File? { + val home = System.getProperty("user.home") ?: return null + val os = System.getProperty("os.name")?.lowercase(Locale.ROOT).orEmpty() + val dir = when { + os.contains("win") -> System.getenv("APPDATA")?.let { File(it, ".minecraft") } ?: File(home, "AppData/Roaming/.minecraft") + os.contains("mac") -> File(home, "Library/Application Support/minecraft") + else -> File(home, ".minecraft") + } + return dir.takeIf { it.isDirectory } + } +} diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettings.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettings.kt new file mode 100644 index 00000000..fceb3462 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettings.kt @@ -0,0 +1,39 @@ +package me.devnatan.inventoryframework.intellij + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.util.xmlb.XmlSerializerUtil + +// Lets a dev override where ItemIconProvider looks for a Minecraft client jar, for setups the +// platform-default guess (see ItemIconProvider.minecraftHome) can't find - a portable/custom +// launcher, an install on another drive, etc. Empty means "auto-detect". +@Service(Service.Level.APP) +@State(name = "InventoryFrameworkMinecraftSettings", storages = [Storage("inventoryframework-minecraft.xml")]) +class MinecraftIconSettings : PersistentStateComponent { + + class State { + var minecraftHome: String = "" + } + + private var state = State() + + var minecraftHome: String + get() = state.minecraftHome + set(value) { + state.minecraftHome = value.trim() + } + + override fun getState(): State = state + + override fun loadState(state: State) { + XmlSerializerUtil.copyBean(state, this.state) + } + + companion object { + fun getInstance(): MinecraftIconSettings = + ApplicationManager.getApplication().getService(MinecraftIconSettings::class.java) + } +} diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettingsConfigurable.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettingsConfigurable.kt new file mode 100644 index 00000000..a8cd791c --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettingsConfigurable.kt @@ -0,0 +1,55 @@ +package me.devnatan.inventoryframework.intellij + +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.util.ui.FormBuilder +import javax.swing.JComponent +import javax.swing.JLabel + +class MinecraftIconSettingsConfigurable : Configurable { + + private var homeField: TextFieldWithBrowseButton? = null + + override fun getDisplayName(): String = "Inventory Framework" + + override fun createComponent(): JComponent { + val field = TextFieldWithBrowseButton() + field.addActionListener { + val descriptor = FileChooserDescriptorFactory.createSingleFolderDescriptor() + .withTitle("Minecraft Home Directory") + .withDescription("Folder containing \"versions\" - usually named .minecraft.") + val chosen = FileChooser.chooseFile(descriptor, null, null) ?: return@addActionListener + field.text = chosen.path + } + homeField = field + + val comment = JLabel( + "Used to render real item icons in the inventory preview by reading textures from an " + + "installed client jar. Leave empty to auto-detect the platform default " + + "(%APPDATA%/.minecraft, ~/.minecraft, or ~/Library/Application Support/minecraft).", + ) + + return FormBuilder.createFormBuilder() + .addLabeledComponent("Minecraft home (.minecraft) directory:", field) + .addComponentToRightColumn(comment) + .addComponentFillVertically(JLabel(), 0) + .panel + } + + override fun isModified(): Boolean = + homeField?.text?.trim().orEmpty() != MinecraftIconSettings.getInstance().minecraftHome + + override fun apply() { + MinecraftIconSettings.getInstance().minecraftHome = homeField?.text.orEmpty() + } + + override fun reset() { + homeField?.text = MinecraftIconSettings.getInstance().minecraftHome + } + + override fun disposeUIResources() { + homeField = null + } +} diff --git a/intellij-plugin/src/main/resources/META-INF/plugin.xml b/intellij-plugin/src/main/resources/META-INF/plugin.xml index 29bf400f..e96ef73f 100644 --- a/intellij-plugin/src/main/resources/META-INF/plugin.xml +++ b/intellij-plugin/src/main/resources/META-INF/plugin.xml @@ -13,6 +13,11 @@ + Date: Mon, 3 Aug 2026 15:13:12 -0300 Subject: [PATCH 2/2] feat: render block models via their real element geometry in the IntelliJ preview --- .gitignore | 3 +- intellij-plugin/TOOLING_SUPPORT.md | 52 ++- .../intellij/ItemExtractor.kt | 28 ++ .../intellij/ItemIconProvider.kt | 351 ++++++++++++++++-- .../intellij/MinecraftIconSettings.kt | 3 - 5 files changed, 380 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index c6b1ad69..0cf68e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .kotlin/ build/ libs/ -.intellijPlatform/ \ No newline at end of file +.intellijPlatform/ +CLAUDE.md \ No newline at end of file diff --git a/intellij-plugin/TOOLING_SUPPORT.md b/intellij-plugin/TOOLING_SUPPORT.md index f210fa61..dfedabc7 100644 --- a/intellij-plugin/TOOLING_SUPPORT.md +++ b/intellij-plugin/TOOLING_SUPPORT.md @@ -33,6 +33,12 @@ the user's code, so anything that depends on runtime state can only ever be appr - `withItem(new ItemStack(Material.X))`, resolved directly or through a local variable/parameter initializer, for: `slot(index, item)`, `slot(index).withItem(item)`, `firstSlot(item)`, `lastSlot(item)`, `layoutSlot(char, item)` (both the direct and chained-builder forms). +- Falls back to any `Material.X` passed directly as an argument to a helper call when there's no + `new ItemStack(...)` at all - e.g. `withItem(ExampleUtil.displayItem(Material.STONE, "Label"))`. + The helper's body is never evaluated (this is static analysis, not execution); the Material + argument is just a strong enough signal on its own to use as a best-effort guess. Only looks at + the call's own arguments, or (through a local variable) its initializer's - not into further + nested calls. - `row(n)` / `firstRow()` / `lastRow()` / `column(n)` / `firstColumn()` / `lastColumn()`, both the chained (`.withItem(item)`) and `BiConsumer` factory (`(pos, slot) -> slot.withItem(item)`) forms — see limitations below for the heuristic these rely on. @@ -46,22 +52,36 @@ the user's code, so anything that depends on runtime state can only ever be appr dynamic marker, layout fill) is overlaid at the sprite's real slot positions. - Every other view type still renders as a plain drawn grid — there's no sprite for them. - **Real item icons** (`ItemIconProvider`), opportunistically: if the machine running the IDE has - a vanilla Minecraft client installed, icons are read directly from that client jar's - `assets/minecraft/textures/{item,block}/.png` at render time — nothing is bundled or - redistributed by the plugin itself, since Mojang's usage guidelines prohibit that for - third-party tools. Animated textures are cropped to their first frame and anything above 16x16 - is downscaled. If a material has no same-named texture file (e.g. a stained glass pane's icon is - really just its plain glass block's texture), the reference is read out of the item's own - `assets/minecraft/models/item/.json` instead - one model deep, without following - parent chains or resolving `"#variable"` texture substitution, so composite block-shaped items - whose icon only inherits a texture from a parent model (fences, walls, carpets, stairs, ...) - still fall back to the placeholder. When no client jar can be found, slots fall back to the - original colored square + 3-letter material abbreviation. (The bundled chest frame sprites are - original/generic art, not extracted Mojang textures, so they don't carry the same restriction and - are unaffected either way.) The `.minecraft` directory used for auto-detection can be overridden - per-machine in **Settings > Tools > Inventory Framework** (`MinecraftIconSettings`), for setups - the platform default guess can't find (portable/custom launchers, an install on another drive, - etc.). + a vanilla Minecraft client installed, icons are read directly from that client jar at render + time — nothing is bundled or redistributed by the plugin itself, since Mojang's usage guidelines + prohibit that for third-party tools. Rather than recognizing a fixed set of shapes, a material's + actual model geometry (`"elements"`, each a cuboid with per-face textures) is read and rendered: + the model chain is walked from the material's leaf model — found directly, or, for materials + like fences/walls whose real icon model is only reachable through the newer + `assets/minecraft/items/.json` indirection, through that — up through `"parent"`, + merging each level's `"textures"` until one with `"elements"` is found. Every element's three + camera-visible faces (top, and the two visible sides) are projected through a fixed dimetric + camera and composited depth-sorted (nearer elements drawn over farther ones), rasterized + per-pixel at 4x supersampling and box-filtered back down for a clean antialiased silhouette + instead of jagged or blurred seams. This is what makes a stair render as an actual step shape (a + slab plus a raised quarter-block, exactly per its own model), a fence or wall show its posts and + bars, and a torch show as a thin stick with a flame top — not just a fixed cube or a flat square. + Materials whose model never resolves to any elements (flat tool/food/"item/generated" icons, or a + model type this doesn't follow — see below) render as a flat square using the same texture + resolution instead. Animated textures are cropped to their first frame. When no client jar can be + found, slots fall back to the original colored square + 3-letter material abbreviation. (The + bundled chest frame sprites are original/generic art, not extracted Mojang textures, so they + don't carry the same restriction and are unaffected either way.) The `.minecraft` directory used + for auto-detection can be overridden per-machine in **Settings > Tools > Inventory Framework** + (`MinecraftIconSettings`), for setups the platform default guess can't find (portable/custom + launchers, an install on another drive, etc.). + - Known gaps in this renderer specifically: biome tinting (grass/leaves/water show their + texture's own base color, not the tinted one — no biome context exists to tint with); items + whose model uses a `select`/`special`/`condition` type in `items/.json` (chests, + compasses, spawn eggs, ...) rather than a plain `"minecraft:model"` reference; per-face texture + `"rotation"` hints (ignored, so a rotated face's texture shows unrotated); and multi-layer flat + icons (dyed leather armor's `layer1`, potion overlay colors, etc. - only the first resolvable + layer is used). All of these fall back to a flat texture or the placeholder, never a crash. ## Known limitations / not supported diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemExtractor.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemExtractor.kt index 812e7d6a..f3b2f884 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemExtractor.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemExtractor.kt @@ -216,6 +216,7 @@ object ItemExtractor { val expr = rawExpr.skipParenthesizedExprDown() if (isNullLiteral(expr)) return null val material = findItemStackConstructorCall(expr)?.let { extractMaterialName(it) } + ?: findMaterialArgument(expr) return PreviewSlot(material = material, dynamic = material == null) } @@ -242,4 +243,31 @@ object ItemExtractor { if (field.containingClass?.qualifiedName != MATERIAL_FQN) return null return field.name } + + // Not every item comes from a bare `new ItemStack(Material.X)` - a common idiom is a helper + // method that builds one from a Material, e.g. `ExampleUtil.displayItem(Material.STONE, + // "Label")`. There's no way to evaluate what such a method actually returns (this is static + // analysis, not execution), but the Material passed in is still a strong, deterministic signal + // of what the item will be, so it's used directly as a best-effort guess. Only looks at the + // call's own arguments (or, through a local variable, its initializer's) - not into nested + // calls - mirroring findItemStackConstructorCall's one-level indirection. + private fun findMaterialArgument(rawExpr: UExpression): String? { + val call = asCallExpression(rawExpr) + if (call != null) { + return call.valueArguments.firstNotNullOfOrNull(::materialFieldName) + } + + val expr = rawExpr.skipParenthesizedExprDown() + val ref = expr as? UReferenceExpression ?: return null + val variable = ref.resolve()?.toUElementOfType() ?: return null + val initializer = variable.uastInitializer ?: return null + return findMaterialArgument(initializer) + } + + private fun materialFieldName(rawExpr: UExpression): String? { + val expr = rawExpr.skipParenthesizedExprDown() + val field = (expr as? UReferenceExpression)?.resolve() as? PsiField ?: return null + if (field.containingClass?.qualifiedName != MATERIAL_FQN) return null + return field.name + } } diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemIconProvider.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemIconProvider.kt index 5d2410f0..e99f092a 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemIconProvider.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemIconProvider.kt @@ -1,6 +1,10 @@ package me.devnatan.inventoryframework.intellij +import com.google.gson.JsonObject +import com.google.gson.JsonParser import java.awt.RenderingHints +import java.awt.geom.AffineTransform +import java.awt.geom.Point2D import java.awt.image.BufferedImage import java.io.File import java.util.Locale @@ -10,12 +14,45 @@ import javax.imageio.ImageIO private const val ICON_SIZE = 16 private const val TEXTURE_ROOT = "assets/minecraft/textures" private const val MODEL_ROOT = "assets/minecraft/models" +private const val ITEMS_ROOT = "assets/minecraft/items" -// Preference order when a model declares more than one texture layer/face - layer0 is the -// standard flat-icon key ("item/generated" models), the rest are common cube-model face names; -// picking any single one is only ever an approximation of the real (sometimes multi-layered or -// tinted) icon, but it's a much closer one than the placeholder. -private val TEXTURE_KEY_PRIORITY = listOf("layer0", "all", "side", "particle", "texture", "top", "cross") +// Every model is rendered at RENDER_SIZE * SUPERSAMPLE resolution, sampling source textures with +// hard nearest-neighbor per output pixel, then box-filtered back down to RENDER_SIZE. Nearest- +// neighbor sampling alone gives every face a jagged, aliased silhouette at any resolution that's +// only a small multiple of the source texture; supersampling first is what turns that into a +// smooth diagonal edge once downscaled. RENDER_SIZE itself is well above the panel's normal slot +// size (see SLOT_SIZE/SPRITE_SLOT_SIZE in InventoryPreviewPanel) so the extra detail survives the +// panel's own zoom levels (up to 3x) instead of visibly pixelating - the render only happens once +// per material and is cached, so the larger size costs nothing after the first paint. +private const val RENDER_SIZE = ICON_SIZE * 8 +private const val SUPERSAMPLE = 4 +private const val RENDER_GRID = RENDER_SIZE * SUPERSAMPLE + +// Preference order for a flat (non-3D) model's texture - layer0 is the standard key for +// "item/generated" models; the rest cover models that turned out to have no usable element +// geometry, so at least one face is still shown instead of nothing. +private val FLAT_TEXTURE_KEYS = listOf("layer0", "all", "side", "particle", "texture", "top", "cross") + +// Face directions visible from the fixed camera this renderer uses (see project()) - "down", +// "north" and "west" always face away from it, so parsing/rendering never needs to consider them. +private val VISIBLE_FACES = listOf("up" to 1.0, "south" to 0.8, "east" to 0.6) + +private class ModelFace(val textureVariable: String, val uv: DoubleArray?) + +private class ModelElement( + val x0: Double, + val y0: Double, + val z0: Double, + val x1: Double, + val y1: Double, + val z1: Double, + val faces: Map, +) { + // Used only to order faces so nearer elements are tried first per pixel (see renderElements) - + // the max corner (nearest to camera) of a whole element is precise enough for the simple, + // non-interpenetrating shapes vanilla block models are built from. + val depth: Double get() = x1 + y1 + z1 +} // Reads item icons directly from a Minecraft client jar already installed on this machine, on // demand and entirely locally - the plugin itself never bundles or serves any extracted game @@ -40,25 +77,117 @@ object ItemIconProvider { private fun loadIcon(name: String): BufferedImage? { val jar = clientJar() ?: return null + val elements = resolveElements(jar, name) + if (elements != null) { + renderElements(jar, elements.first, elements.second)?.let { return it } + } + val direct = readEntry(jar, "$TEXTURE_ROOT/item/$name.png") ?: readEntry(jar, "$TEXTURE_ROOT/block/$name.png") - val raw = direct ?: modelTexturePath(jar, name)?.let { readEntry(jar, "$TEXTURE_ROOT/$it.png") } - return raw?.let(::normalize) - } - - // Some items have no texture file that matches their own name - a stained glass pane's icon, - // for instance, is just its plain glass block's texture (see models/item/*_pane.json's - // "layer0"), not a "*_pane.png" that doesn't exist. Rather than hardcoding every such case, - // read the reference straight out of the item's own model JSON. Only resolves one model deep - // (no parent-chain walk, no "#variable" texture substitution), so composite block-shaped - // items whose model only inherits a texture from a parent (fences, walls, carpets, stairs...) - // are a known remaining gap - they still fall back to the placeholder, same as before. - private fun modelTexturePath(jar: ZipFile, name: String): String? { - val json = readText(jar, "$MODEL_ROOT/item/$name.json") ?: return null - val texturesBlock = Regex(""""textures"\s*:\s*\{([^}]*)}""").find(json)?.groupValues?.get(1) ?: return null - val entries = Regex(""""(\w+)"\s*:\s*"([^"]+)"""").findAll(texturesBlock) - .associate { it.groupValues[1] to it.groupValues[2] } - val value = TEXTURE_KEY_PRIORITY.firstNotNullOfOrNull { entries[it] } ?: entries.values.firstOrNull() - return value?.removePrefix("minecraft:")?.takeUnless { it.startsWith("#") } + val fromModel = elements?.second?.let { pickTexture(jar, it, FLAT_TEXTURE_KEYS) } + return (direct ?: fromModel)?.let(::normalize) + } + + // Finds the real geometry behind a material's icon, however deep its model chain is, instead + // of hardcoding a fixed set of recognized shapes: starts from the material's leaf model (which + // may itself only be reachable through the newer assets/minecraft/items/.json + // indirection - e.g. a fence's icon is a dedicated "..._inventory" model, not "block/"), + // then walks "parent" upward, merging each level's "textures" (a child's own values win) until + // a model with "elements" is found. Stops there rather than continuing further up, since every + // vanilla model that defines shape also defines (or inherits from what's already been merged) + // concrete texture values for it. Returns null if the chain ends without ever finding elements + // (flat items like tools/food terminate at item/generated, which has neither) - the merged + // texture map is still returned in that case, letting the caller fall back to a flat texture. + private fun resolveElements(jar: ZipFile, name: String): Pair, Map>? { + var path: String? = findLeafModelPath(jar, name) ?: return null + val textureLayers = mutableListOf>() + var elements: List? = null + var guard = 0 + while (path != null && guard++ < 12) { + val json = readText(jar, path) ?: break + val obj = runCatching { JsonParser.parseString(json).asJsonObject }.getOrNull() ?: break + textureLayers.add(parseTextures(obj)) + if (elements == null && obj.has("elements")) { + elements = runCatching { parseElements(obj) }.getOrNull() + } + if (elements != null) break + val parent = obj.takeIf { it.has("parent") }?.get("parent")?.asString + path = parent?.let { "$MODEL_ROOT/${stripNamespace(it)}.json" } + } + + val merged = mutableMapOf() + for (layer in textureLayers.asReversed()) merged.putAll(layer) + return elements?.let { it to merged } + } + + // A material's displayed model isn't always reachable by guessing "models/item/.json" + // or "models/block/.json" directly - some materials (fences, walls, ...) only have a + // dedicated icon model reachable through assets/minecraft/items/.json's own "model" + // reference. Only the simple, common "minecraft:model" reference type is followed; other + // types (select/special/condition - used for e.g. chests or compasses to pick a model based on + // world/item state) need real per-case handling this doesn't attempt, so those fall through to + // the direct-path guess same as before. + private fun findLeafModelPath(jar: ZipFile, name: String): String? { + readText(jar, "$ITEMS_ROOT/$name.json")?.let { json -> + val root = runCatching { JsonParser.parseString(json).asJsonObject }.getOrNull() + val modelObj = root?.takeIf { it.has("model") }?.getAsJsonObject("model") + val type = modelObj?.takeIf { it.has("type") }?.get("type")?.asString + if (type == "minecraft:model" && modelObj.has("model")) { + val target = "$MODEL_ROOT/${stripNamespace(modelObj.get("model").asString)}.json" + if (jar.getEntry(target) != null) return target + } + } + val itemPath = "$MODEL_ROOT/item/$name.json" + if (jar.getEntry(itemPath) != null) return itemPath + val blockPath = "$MODEL_ROOT/block/$name.json" + if (jar.getEntry(blockPath) != null) return blockPath + return null + } + + private fun stripNamespace(value: String): String = value.substringAfter(':') + + private fun parseTextures(obj: JsonObject): Map { + if (!obj.has("textures")) return emptyMap() + return obj.getAsJsonObject("textures").entrySet().associate { it.key to it.value.asString } + } + + private fun parseElements(obj: JsonObject): List = + obj.getAsJsonArray("elements").map { el -> + val element = el.asJsonObject + val from = element.getAsJsonArray("from") + val to = element.getAsJsonArray("to") + val faces = mutableMapOf() + if (element.has("faces")) { + val facesObj = element.getAsJsonObject("faces") + for ((faceName, _) in VISIBLE_FACES) { + if (!facesObj.has(faceName)) continue + val faceObj = facesObj.getAsJsonObject(faceName) + if (!faceObj.has("texture")) continue + val uv = faceObj.takeIf { it.has("uv") }?.getAsJsonArray("uv") + ?.map { it.asDouble }?.toDoubleArray() + faces[faceName] = ModelFace(faceObj.get("texture").asString, uv) + } + } + ModelElement( + from[0].asDouble, from[1].asDouble, from[2].asDouble, + to[0].asDouble, to[1].asDouble, to[2].asDouble, + faces, + ) + } + + // Resolves a "#variable" chain (a model's texture value can itself point at another variable, + // e.g. cube_all's "#up" resolving to "#all") to a concrete texture path, or null if it dangles + // (undefined variable, or still unresolved after the hop limit - a cycle, or a variable that's + // genuinely never given a concrete value in this chain). + private fun resolveTextureVariable(textures: Map, ref: String?, hops: Int = 0): String? { + if (ref == null || hops > 6) return null + if (!ref.startsWith("#")) return stripNamespace(ref) + return resolveTextureVariable(textures, textures[ref.removePrefix("#")], hops + 1) + } + + private fun pickTexture(jar: ZipFile, textures: Map, keyPriority: List): BufferedImage? { + val value = keyPriority.firstNotNullOfOrNull { textures[it] } ?: textures.values.firstOrNull() + val path = value?.let { resolveTextureVariable(textures, it) } ?: return null + return readEntry(jar, "$TEXTURE_ROOT/$path.png") } private fun readText(jar: ZipFile, path: String): String? { @@ -66,10 +195,6 @@ object ItemIconProvider { return jar.getInputStream(entry).use { runCatching { it.readBytes().toString(Charsets.UTF_8) }.getOrNull() } } - // Re-resolves whenever the configured override changes (e.g. the dev just saved a new path in - // Settings > Tools > Inventory Framework), rather than caching it for the plugin's whole - // lifetime like a plain `by lazy` would - otherwise editing the setting would need an IDE - // restart to take effect. @Synchronized private fun clientJar(): ZipFile? { val configuredHome = MinecraftIconSettings.getInstance().minecraftHome.ifBlank { null } @@ -87,14 +212,15 @@ object ItemIconProvider { } // Animated textures are stored as a vertical strip of square frames (width == frame size); - // the first frame is the top width-by-width square. Anything still bigger than one icon cell - // afterwards (e.g. 32x32 items) is then downscaled. + // the first frame is the top width-by-width square. + private fun cropToSquare(image: BufferedImage): BufferedImage { + if (image.width == image.height) return image + return image.getSubimage(0, 0, image.width, minOf(image.width, image.height)) + } + + // Scales a square texture to the fixed icon cell size used everywhere else (e.g. 32x32 items). private fun normalize(image: BufferedImage): BufferedImage { - val square = if (image.width != image.height) { - image.getSubimage(0, 0, image.width, minOf(image.width, image.height)) - } else { - image - } + val square = cropToSquare(image) if (square.width == ICON_SIZE && square.height == ICON_SIZE) return square val scaled = BufferedImage(ICON_SIZE, ICON_SIZE, BufferedImage.TYPE_INT_ARGB) @@ -108,14 +234,165 @@ object ItemIconProvider { return scaled } + // Composites every element's visible faces into one icon, matching how Minecraft itself + // renders a block-shaped item: not by hardcoding a handful of recognized shapes, but by + // actually projecting whatever cuboids the model is built from (a stair is a slab plus a + // raised quarter-block, a fence is a post plus crossbars, a torch is a single thin stick). + // Faces are depth-sorted once (nearer elements first) and, per pixel, the first face whose + // projected parallelogram contains that pixel wins - this correctly layers e.g. a stair's + // raised step over the slab beneath it without needing a full z-buffer, since vanilla's simple + // architectural shapes don't have elements that interpenetrate in more complex ways. + private fun renderElements(jar: ZipFile, elements: List, textures: Map): BufferedImage? { + val jobs = elements + .flatMap { element -> VISIBLE_FACES.mapNotNull { (faceName, shade) -> renderJob(jar, textures, element, faceName, shade) } } + .sortedByDescending { it.depth } + if (jobs.isEmpty()) return null + + val rendered = BufferedImage(RENDER_GRID, RENDER_GRID, BufferedImage.TYPE_INT_ARGB) + for (y in 0 until RENDER_GRID) { + for (x in 0 until RENDER_GRID) { + val px = x + 0.5 + val py = y + 0.5 + val argb = jobs.firstNotNullOfOrNull { it.sample(px, py) } ?: 0 + rendered.setRGB(x, y, argb) + } + } + return boxDownscale(rendered, RENDER_SIZE) + } + + private fun renderJob(jar: ZipFile, textures: Map, element: ModelElement, faceName: String, shade: Double): RenderJob? { + val face = element.faces[faceName] ?: return null + val texturePath = resolveTextureVariable(textures, face.textureVariable) ?: return null + val texture = readEntry(jar, "$TEXTURE_ROOT/$texturePath.png") ?: return null + + val corners = projectedFaceCorners(element, faceName) + val uv = face.uv ?: autoUv(element, faceName) + val scaleX = texture.width / 16.0 + val scaleY = texture.height / 16.0 + val u0 = uv[0] * scaleX + val v0 = uv[1] * scaleY + val u1 = uv[2] * scaleX + val v1 = uv[3] * scaleY + val w = u1 - u0 + val h = v1 - v0 + if (w == 0.0 || h == 0.0) return null + + // Maps a texture rect's own local (0,0)/(w,0)/(0,h) corners to the 3 projected destination + // points, inverted for per-pixel sampling; (u0,v0) is added back in RenderJob.sample since + // this only knows the rect's local origin, not its absolute position in the source texture. + val local = AffineTransform( + (corners[2] - corners[0]) / w, (corners[3] - corners[1]) / w, + (corners[4] - corners[0]) / h, (corners[5] - corners[1]) / h, + corners[0], corners[1], + ) + val inverse = runCatching { local.createInverse() }.getOrNull() ?: return null + return RenderJob(texture, inverse, shade, element.depth, minOf(u0, u1), minOf(v0, v1), maxOf(u0, u1), maxOf(v0, v1)) + } + + // Returns [p0x,p0y, p1x,p1y, p2x,p2y]: the destination points for the face's own (u0,v0), + // (u1,v0) and (u0,v1) UV corners (in that order), given the fixed camera projection. Verified + // against real model UVs (e.g. fence_inventory.json): "up" maps u/v directly to x/z, while the + // vertical side faces ("south"/"east") map v to 16-y, since texture v grows downward while + // world y grows upward. + private fun projectedFaceCorners(el: ModelElement, faceName: String): DoubleArray = when (faceName) { + "up" -> pointsToArray(project(el.x0, el.y1, el.z0), project(el.x1, el.y1, el.z0), project(el.x0, el.y1, el.z1)) + "south" -> pointsToArray(project(el.x0, el.y1, el.z1), project(el.x1, el.y1, el.z1), project(el.x0, el.y0, el.z1)) + "east" -> pointsToArray(project(el.x1, el.y1, el.z0), project(el.x1, el.y1, el.z1), project(el.x1, el.y0, el.z0)) + else -> error("unsupported face $faceName") + } + + private fun pointsToArray(a: Point2D, b: Point2D, c: Point2D) = doubleArrayOf(a.x, a.y, b.x, b.y, c.x, c.y) + + // Used only when a face has no explicit "uv" (implicit UV = matches the element's own position + // and size on the relevant two axes), which holds for every vanilla model observed so far. + private fun autoUv(el: ModelElement, faceName: String): DoubleArray = when (faceName) { + "up" -> doubleArrayOf(el.x0, el.z0, el.x1, el.z1) + "south" -> doubleArrayOf(el.x0, 16 - el.y1, el.x1, 16 - el.y0) + "east" -> doubleArrayOf(el.z0, 16 - el.y1, el.z1, 16 - el.y0) + else -> error("unsupported face $faceName") + } + + // Fixed dimetric camera projection, derived from and matching the hand-tuned, user-verified + // full-cube/slab geometry this replaces: independent of y for screen-x, and a linear blend of + // x/y/z for screen-y. Coordinates are 0-16 Minecraft model units; output is in render-grid + // pixels. + private fun project(x: Double, y: Double, z: Double): Point2D { + val nx = x / 16.0 + val ny = y / 16.0 + val nz = z / 16.0 + val sx = 0.5 * (nx - nz + 1) + val sy = 0.25 * (nx + nz) - 0.5 * ny + 0.5 + return Point2D.Double(sx * RENDER_GRID, sy * RENDER_GRID) + } + + private class RenderJob( + val texture: BufferedImage, + val inverse: AffineTransform, + val shade: Double, + val depth: Double, + val u0: Double, + val v0: Double, + val u1: Double, + val v1: Double, + ) { + // Null means the render-grid point (px, py) falls outside this face's own rect once + // mapped back to local texture-rect coordinates - the caller tries the next face, or + // leaves the pixel transparent if none hit. + fun sample(px: Double, py: Double): Int? { + val local = inverse.transform(Point2D.Double(px, py), null) + if (local.x < 0 || local.y < 0 || local.x >= (u1 - u0) || local.y >= (v1 - v0)) return null + val u = Math.floor(local.x + u0).toInt() + val v = Math.floor(local.y + v0).toInt() + if (u < 0 || v < 0 || u >= texture.width || v >= texture.height) return null + val argb = texture.getRGB(u, v) + val a = (argb shr 24) and 0xFF + if (a == 0) return null + val r = (((argb shr 16) and 0xFF) * shade).toInt().coerceIn(0, 255) + val g = (((argb shr 8) and 0xFF) * shade).toInt().coerceIn(0, 255) + val b = ((argb and 0xFF) * shade).toInt().coerceIn(0, 255) + return (a shl 24) or (r shl 16) or (g shl 8) or b + } + } + + // Averages every (factor x factor) block of the supersampled render into one output pixel, + // alpha-weighted so a face's real edge color doesn't get diluted by the transparent pixels + // just outside it. This is what turns the hard, jagged per-pixel face boundaries above into a + // smooth antialiased silhouette. + private fun boxDownscale(source: BufferedImage, targetSize: Int): BufferedImage { + val factor = source.width / targetSize + val out = BufferedImage(targetSize, targetSize, BufferedImage.TYPE_INT_ARGB) + for (y in 0 until targetSize) { + for (x in 0 until targetSize) { + var alphaSum = 0L + var r = 0L + var g = 0L + var b = 0L + for (sy in 0 until factor) { + for (sx in 0 until factor) { + val argb = source.getRGB(x * factor + sx, y * factor + sy) + val a = (argb shr 24) and 0xFF + alphaSum += a + r += ((argb shr 16) and 0xFF) * a + g += ((argb shr 8) and 0xFF) * a + b += (argb and 0xFF) * a + } + } + val sampleCount = (factor * factor).toLong() + val outAlpha = (alphaSum / sampleCount).toInt() + val outR = if (alphaSum == 0L) 0 else (r / alphaSum).toInt() + val outG = if (alphaSum == 0L) 0 else (g / alphaSum).toInt() + val outB = if (alphaSum == 0L) 0 else (b / alphaSum).toInt() + out.setRGB(x, y, (outAlpha shl 24) or (outR shl 16) or (outG shl 8) or outB) + } + } + return out + } + // Picks the newest installed release-named version whose jar actually contains item // textures, falling back to whatever else is there (e.g. a modloader profile jar) sorted by // recency. Modloader profiles set up by the vanilla launcher often "inheritsFrom" a vanilla // version instead of bundling assets themselves - those are skipped by the texture check // rather than treated as an error, since the real vanilla version is usually also installed. - // - // `configuredHome` is the dev's override from Settings > Tools > Inventory Framework - // (MinecraftIconSettings); null means it's unset and the platform default guess is used. private fun locateClientJar(configuredHome: String?): File? { val home = configuredHome?.let(::File) ?: minecraftHome() ?: return null val versionsDir = File(home, "versions").takeIf { it.isDirectory } ?: return null diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettings.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettings.kt index fceb3462..1619bd5d 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettings.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/MinecraftIconSettings.kt @@ -7,9 +7,6 @@ import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage import com.intellij.util.xmlb.XmlSerializerUtil -// Lets a dev override where ItemIconProvider looks for a Minecraft client jar, for setups the -// platform-default guess (see ItemIconProvider.minecraftHome) can't find - a portable/custom -// launcher, an install on another drive, etc. Empty means "auto-detect". @Service(Service.Level.APP) @State(name = "InventoryFrameworkMinecraftSettings", storages = [Storage("inventoryframework-minecraft.xml")]) class MinecraftIconSettings : PersistentStateComponent {