using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using WeaveLoader.API.Item; namespace WeaveLoader.API.Assets; internal static class ModelResolver { private sealed class ModelData { public string IconName = ""; public List Boxes = new(); public Dictionary DisplayTransforms = new(); } private enum ModelKind { Block, Item } internal static void ApplyBlockModel(Identifier id, Block.BlockProperties properties) { if (!ShouldResolveModel(properties.ModelValue, properties.IconValue, ModelKind.Block)) return; if (TryLoadModel(id, properties.ModelValue, ModelKind.Block, out ModelData? model)) { if (!string.IsNullOrWhiteSpace(model.IconName)) { properties.IconValue = model.IconName; Logger.Debug($"Model resolved for block '{id}' -> icon '{model.IconName}'"); } if (model.Boxes.Count > 0) { properties.ModelBoxes = model.Boxes; properties.ModelIsFullCube = IsFullCube(model.Boxes); } } else if (!string.IsNullOrWhiteSpace(properties.ModelValue)) { Logger.Warning($"Model not found for block '{id}' (model '{properties.ModelValue}')"); } } internal static void ApplyBlockStates(Identifier id, Block.BlockProperties properties) { if (string.IsNullOrWhiteSpace(ModContext.ModFolder)) return; if (!TryGetBlockStateFilePath(id, properties.BlockStateValue, out string blockStatePath)) return; if (!File.Exists(blockStatePath)) return; try { using var doc = JsonDocument.Parse(File.ReadAllText(blockStatePath)); if (!doc.RootElement.TryGetProperty("variants", out JsonElement variants) || variants.ValueKind != JsonValueKind.Object) { return; } var variantProps = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var variant in variants.EnumerateObject()) { string key = NormalizeVariantKey(variant.Name); CollectVariantProps(variant.Name, variantProps); JsonElement entry = variant.Value; if (entry.ValueKind == JsonValueKind.Array) { if (entry.GetArrayLength() == 0) continue; entry = entry[0]; } if (entry.ValueKind != JsonValueKind.Object) continue; if (!entry.TryGetProperty("model", out JsonElement modelEl) || modelEl.ValueKind != JsonValueKind.String) continue; string? modelName = modelEl.GetString(); if (string.IsNullOrWhiteSpace(modelName)) continue; int xRot = 0; int yRot = 0; if (entry.TryGetProperty("x", out JsonElement xEl) && xEl.ValueKind == JsonValueKind.Number) xRot = xEl.GetInt32(); if (entry.TryGetProperty("y", out JsonElement yEl) && yEl.ValueKind == JsonValueKind.Number) yRot = yEl.GetInt32(); if (!TryLoadModelFromValue(id, modelName!, ModelKind.Block, out ModelData? model) || model == null) continue; if (string.IsNullOrWhiteSpace(properties.IconValue) && !string.IsNullOrWhiteSpace(model.IconName)) properties.IconValue = model.IconName; var boxes = model.Boxes.Count > 0 ? RotateBoxes(model.Boxes, xRot, yRot) : new List(); properties.ModelVariants ??= new Dictionary>(StringComparer.OrdinalIgnoreCase); properties.ModelVariants[key] = boxes; } if (properties.RotationProfileValue == Block.BlockRotationProfile.None) { var inferred = InferRotationProfile(variantProps, blockStatePath); if (inferred != Block.BlockRotationProfile.None) properties.RotationProfileValue = inferred; } } catch (Exception ex) { Logger.Warning($"Failed to parse blockstate JSON '{blockStatePath}': {ex.Message}"); } } internal static void ApplyItemModel(Identifier id, Item.ItemProperties properties) { if (!ShouldResolveModel(properties.ModelValue, properties.IconValue, ModelKind.Item)) return; if (TryLoadModel(id, properties.ModelValue, ModelKind.Item, out ModelData? model)) { if (!string.IsNullOrWhiteSpace(model.IconName)) { properties.IconValue = model.IconName; Logger.Debug($"Model resolved for item '{id}' -> icon '{model.IconName}'"); } if (model.DisplayTransforms.Count > 0) { properties.DisplayTransforms ??= new Dictionary(); foreach (var entry in model.DisplayTransforms) { if (!properties.DisplayTransforms.ContainsKey(entry.Key)) properties.DisplayTransforms[entry.Key] = entry.Value; } Logger.Info($"Item display transforms loaded for '{id}': {model.DisplayTransforms.Count}"); if (!properties.HandEquippedValue.HasValue && HasHandTransforms(model.DisplayTransforms)) properties.HandEquippedValue = true; } } else if (!string.IsNullOrWhiteSpace(properties.ModelValue)) { Logger.Warning($"Model not found for item '{id}' (model '{properties.ModelValue}')"); } } private static bool ShouldResolveModel(string? modelValue, string iconValue, ModelKind kind) { if (!string.IsNullOrWhiteSpace(modelValue)) return true; // Only auto-resolve if icon was not provided. if (kind == ModelKind.Item && string.IsNullOrWhiteSpace(iconValue)) return true; return false; } private static bool TryLoadModel(Identifier id, string? modelValue, ModelKind kind, out ModelData? model) { model = null; if (string.IsNullOrWhiteSpace(ModContext.ModFolder)) return false; string modelPath; string modelNamespace; ModelKind effectiveKind = kind; if (kind == ModelKind.Item) { if (TryGetModelFilePath(id, modelValue, ModelKind.Block, out string blockModelPath, out string blockNamespace) && File.Exists(blockModelPath)) { modelPath = blockModelPath; modelNamespace = blockNamespace; effectiveKind = ModelKind.Block; } else if (TryGetModelFilePath(id, modelValue, ModelKind.Item, out string itemModelPath, out string itemNamespace) && File.Exists(itemModelPath)) { modelPath = itemModelPath; modelNamespace = itemNamespace; } else { return false; } } else { if (!TryGetModelFilePath(id, modelValue, kind, out modelPath, out modelNamespace)) return false; if (!File.Exists(modelPath)) return false; } if (!TryParseTextures(modelPath, out Dictionary textures)) return false; var data = new ModelData(); string? texture = SelectTexture(textures, effectiveKind); if (!string.IsNullOrWhiteSpace(texture)) { texture = ResolveTextureReference(texture, textures); if (!string.IsNullOrWhiteSpace(texture)) data.IconName = NormalizeTextureName(texture, modelNamespace); } TryParseElements(modelPath, data.Boxes); if (kind == ModelKind.Item) TryParseDisplay(modelPath, modelNamespace, data.DisplayTransforms, new HashSet(StringComparer.OrdinalIgnoreCase)); model = data; return !string.IsNullOrWhiteSpace(data.IconName) || data.Boxes.Count > 0; } private static bool TryLoadModelFromValue(Identifier id, string modelValue, ModelKind kind, out ModelData? model) { return TryLoadModel(id, modelValue, kind, out model); } private static bool TryGetModelFilePath(Identifier id, string? modelValue, ModelKind kind, out string modelPath, out string modelNamespace) { modelPath = ""; modelNamespace = ""; string ns = id.Namespace; string rel = id.Path; if (!string.IsNullOrWhiteSpace(modelValue)) { string raw = modelValue!; int colon = raw.IndexOf(':'); if (colon >= 0) { ns = raw[..colon]; rel = raw[(colon + 1)..]; } else { rel = raw; } } rel = rel.Replace('\\', '/'); if (rel.StartsWith("models/", StringComparison.OrdinalIgnoreCase)) rel = rel["models/".Length..]; if (!rel.StartsWith("block/", StringComparison.OrdinalIgnoreCase) && !rel.StartsWith("item/", StringComparison.OrdinalIgnoreCase)) { rel = (kind == ModelKind.Block ? "block/" : "item/") + rel; } string modRoot = ModContext.ModFolder ?? ""; if (!string.IsNullOrWhiteSpace(modRoot)) { string file = Path.Combine(modRoot, "assets", ns, "models", rel.Replace('/', Path.DirectorySeparatorChar) + ".json"); if (File.Exists(file)) { modelPath = file; modelNamespace = ns; return true; } if (TryGetSharedModelFilePath(ns, rel, out string sharedPath)) { modelPath = sharedPath; modelNamespace = ns; return true; } modelPath = file; modelNamespace = ns; return true; } if (TryGetSharedModelFilePath(ns, rel, out string fallbackPath)) { modelPath = fallbackPath; modelNamespace = ns; return true; } return false; } private static bool TryGetBlockStateFilePath(Identifier id, string? blockStateValue, out string blockStatePath) { blockStatePath = ""; string ns = id.Namespace; string rel = id.Path; if (!string.IsNullOrWhiteSpace(blockStateValue)) { string raw = blockStateValue!; int colon = raw.IndexOf(':'); if (colon >= 0) { ns = raw[..colon]; rel = raw[(colon + 1)..]; } else { rel = raw; } } rel = rel.Replace('\\', '/'); if (rel.StartsWith("blockstates/", StringComparison.OrdinalIgnoreCase)) rel = rel["blockstates/".Length..]; string modRoot = ModContext.ModFolder ?? ""; if (string.IsNullOrWhiteSpace(modRoot)) return false; blockStatePath = Path.Combine(modRoot, "assets", ns, "blockstates", rel.Replace('/', Path.DirectorySeparatorChar) + ".json"); return true; } private static void CollectVariantProps(string rawKey, HashSet props) { if (string.IsNullOrWhiteSpace(rawKey)) return; foreach (string part in rawKey.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { int eq = part.IndexOf('='); if (eq <= 0) continue; props.Add(part[..eq]); } } private static Block.BlockRotationProfile InferRotationProfile(HashSet props, string blockStatePath) { bool hasFacing = props.Contains("facing"); bool hasHalf = props.Contains("half"); bool hasOpen = props.Contains("open"); bool hasHinge = props.Contains("hinge"); bool hasRotation = props.Contains("rotation"); if (hasFacing && hasHalf && hasOpen && hasHinge) return Block.BlockRotationProfile.Door; if (hasFacing && hasHalf && hasOpen) return Block.BlockRotationProfile.Trapdoor; if (hasRotation) return Block.BlockRotationProfile.StandingSign; if (hasFacing) return Block.BlockRotationProfile.Facing; string file = Path.GetFileNameWithoutExtension(blockStatePath); if (file.EndsWith("wall_sign", StringComparison.OrdinalIgnoreCase) || file.EndsWith("wall_hanging_sign", StringComparison.OrdinalIgnoreCase)) return Block.BlockRotationProfile.WallSign; if (file.EndsWith("sign", StringComparison.OrdinalIgnoreCase) || file.EndsWith("hanging_sign", StringComparison.OrdinalIgnoreCase)) return Block.BlockRotationProfile.StandingSign; return Block.BlockRotationProfile.None; } private static bool TryParseTextures(string modelPath, out Dictionary textures) { textures = new Dictionary(StringComparer.OrdinalIgnoreCase); try { using var doc = JsonDocument.Parse(File.ReadAllText(modelPath)); if (!doc.RootElement.TryGetProperty("textures", out JsonElement texElem)) return false; if (texElem.ValueKind != JsonValueKind.Object) return false; foreach (var prop in texElem.EnumerateObject()) { if (prop.Value.ValueKind != JsonValueKind.String) continue; string? value = prop.Value.GetString(); if (string.IsNullOrWhiteSpace(value)) continue; textures[prop.Name] = value!; } } catch (Exception ex) { Logger.Warning($"Failed to parse model JSON '{modelPath}': {ex.Message}"); return false; } return textures.Count > 0; } private static void TryParseDisplay(string modelPath, string modelNamespace, Dictionary display, HashSet visited) { if (string.IsNullOrWhiteSpace(modelPath) || string.IsNullOrWhiteSpace(modelNamespace)) return; if (visited.Contains(modelPath)) return; visited.Add(modelPath); try { using var doc = JsonDocument.Parse(File.ReadAllText(modelPath)); JsonElement root = doc.RootElement; if (root.TryGetProperty("parent", out JsonElement parentEl) && parentEl.ValueKind == JsonValueKind.String) { string? parentValue = parentEl.GetString(); if (!string.IsNullOrWhiteSpace(parentValue) && TryGetParentModelFilePath(parentValue!, modelNamespace, out string parentPath, out string parentNamespace) && File.Exists(parentPath)) { TryParseDisplay(parentPath, parentNamespace, display, visited); } } if (root.TryGetProperty("display", out JsonElement displayEl) && displayEl.ValueKind == JsonValueKind.Object) { foreach (var entry in displayEl.EnumerateObject()) { if (!TryMapDisplayContext(entry.Name, out ItemDisplayContext context)) continue; if (entry.Value.ValueKind != JsonValueKind.Object) continue; ItemDisplayTransform transform = ParseDisplayTransform(entry.Value); display[context] = transform; } } } catch (Exception ex) { Logger.Warning($"Failed to parse item display data '{modelPath}': {ex.Message}"); } } private static bool TryGetParentModelFilePath(string parentValue, string currentNamespace, out string modelPath, out string modelNamespace) { modelPath = ""; modelNamespace = ""; if (string.IsNullOrWhiteSpace(ModContext.ModFolder)) return false; string value = parentValue.Replace('\\', '/'); string ns = currentNamespace; int colon = value.IndexOf(':'); if (colon >= 0) { ns = value.Substring(0, colon); value = value.Substring(colon + 1); } if (string.IsNullOrWhiteSpace(value)) return false; string relative = value.Replace('/', Path.DirectorySeparatorChar); string? modRoot = ModContext.ModFolder; if (!string.IsNullOrWhiteSpace(modRoot)) { string fullPath = Path.Combine(modRoot!, "assets", ns, "models", relative + ".json"); if (File.Exists(fullPath)) { modelPath = fullPath; modelNamespace = ns; return true; } if (TryGetSharedModelFilePath(ns, relative, out string sharedPath)) { modelPath = sharedPath; modelNamespace = ns; return true; } modelPath = fullPath; modelNamespace = ns; return true; } if (TryGetSharedModelFilePath(ns, relative, out string fallbackPath)) { modelPath = fallbackPath; modelNamespace = ns; return true; } return false; } private static bool TryGetSharedModelFilePath(string ns, string relative, out string modelPath) { modelPath = ""; if (!ns.Equals("minecraft", StringComparison.OrdinalIgnoreCase)) return false; string? apiRoot = ModContext.ApiModFolder; if (string.IsNullOrWhiteSpace(apiRoot)) return false; string file = Path.Combine(apiRoot!, "assets", ns, "models", relative.Replace('/', Path.DirectorySeparatorChar) + ".json"); if (!File.Exists(file)) return false; modelPath = file; return true; } private static bool TryMapDisplayContext(string name, out ItemDisplayContext context) { switch (name.Trim().ToLowerInvariant()) { case "gui": context = ItemDisplayContext.Gui; return true; case "ground": context = ItemDisplayContext.Ground; return true; case "fixed": context = ItemDisplayContext.Fixed; return true; case "head": context = ItemDisplayContext.Head; return true; case "firstperson_righthand": context = ItemDisplayContext.FirstPersonRightHand; return true; case "firstperson_lefthand": context = ItemDisplayContext.FirstPersonLeftHand; return true; case "thirdperson_righthand": context = ItemDisplayContext.ThirdPersonRightHand; return true; case "thirdperson_lefthand": context = ItemDisplayContext.ThirdPersonLeftHand; return true; default: context = ItemDisplayContext.Gui; return false; } } private static ItemDisplayTransform ParseDisplayTransform(JsonElement element) { float rX = 0.0f; float rY = 0.0f; float rZ = 0.0f; float tX = 0.0f; float tY = 0.0f; float tZ = 0.0f; float sX = 1.0f; float sY = 1.0f; float sZ = 1.0f; ReadVec3(element, "rotation", ref rX, ref rY, ref rZ); ReadVec3(element, "translation", ref tX, ref tY, ref tZ); ReadVec3(element, "scale", ref sX, ref sY, ref sZ); return new ItemDisplayTransform(rX, rY, rZ, tX, tY, tZ, sX, sY, sZ); } private static void ReadVec3(JsonElement parent, string name, ref float x, ref float y, ref float z) { if (!parent.TryGetProperty(name, out JsonElement el) || el.ValueKind != JsonValueKind.Array) return; int count = el.GetArrayLength(); if (count > 0 && el[0].ValueKind == JsonValueKind.Number) x = (float)el[0].GetDouble(); if (count > 1 && el[1].ValueKind == JsonValueKind.Number) y = (float)el[1].GetDouble(); if (count > 2 && el[2].ValueKind == JsonValueKind.Number) z = (float)el[2].GetDouble(); } private static bool HasHandTransforms(Dictionary display) { return display.ContainsKey(ItemDisplayContext.FirstPersonRightHand) || display.ContainsKey(ItemDisplayContext.FirstPersonLeftHand) || display.ContainsKey(ItemDisplayContext.ThirdPersonRightHand) || display.ContainsKey(ItemDisplayContext.ThirdPersonLeftHand); } private static void TryParseElements(string modelPath, List boxes) { try { using var doc = JsonDocument.Parse(File.ReadAllText(modelPath)); if (!doc.RootElement.TryGetProperty("elements", out JsonElement elements) || elements.ValueKind != JsonValueKind.Array) return; foreach (var elem in elements.EnumerateArray()) { if (!elem.TryGetProperty("from", out JsonElement fromEl) || !elem.TryGetProperty("to", out JsonElement toEl)) continue; if (fromEl.ValueKind != JsonValueKind.Array || toEl.ValueKind != JsonValueKind.Array) continue; if (fromEl.GetArrayLength() < 3 || toEl.GetArrayLength() < 3) continue; float fx0 = (float)fromEl[0].GetDouble(); float fy0 = (float)fromEl[1].GetDouble(); float fz0 = (float)fromEl[2].GetDouble(); float fx1 = (float)toEl[0].GetDouble(); float fy1 = (float)toEl[1].GetDouble(); float fz1 = (float)toEl[2].GetDouble(); float x0 = MathF.Min(fx0, fx1) / 16.0f; float y0 = MathF.Min(fy0, fy1) / 16.0f; float z0 = MathF.Min(fz0, fz1) / 16.0f; float x1 = MathF.Max(fx0, fx1) / 16.0f; float y1 = MathF.Max(fy0, fy1) / 16.0f; float z1 = MathF.Max(fz0, fz1) / 16.0f; if (x1 <= x0 || y1 <= y0 || z1 <= z0) continue; boxes.Add(new ModelBox { X0 = x0, Y0 = y0, Z0 = z0, X1 = x1, Y1 = y1, Z1 = z1 }); } } catch (Exception ex) { Logger.Warning($"Failed to parse model elements in '{modelPath}': {ex.Message}"); } } private static bool IsFullCube(List boxes) { if (boxes.Count != 1) return false; ModelBox box = boxes[0]; const float eps = 0.0001f; return box.X0 <= 0.0f + eps && box.Y0 <= 0.0f + eps && box.Z0 <= 0.0f + eps && box.X1 >= 1.0f - eps && box.Y1 >= 1.0f - eps && box.Z1 >= 1.0f - eps; } private static string NormalizeVariantKey(string raw) { if (string.IsNullOrWhiteSpace(raw)) return ""; string[] parts = raw.Split(',', StringSplitOptions.RemoveEmptyEntries); var pairs = new List<(string key, string value)>(); foreach (string part in parts) { int eq = part.IndexOf('='); if (eq <= 0 || eq >= part.Length - 1) continue; string key = part[..eq].Trim().ToLowerInvariant(); string value = part[(eq + 1)..].Trim().ToLowerInvariant(); if (key.Length == 0 || value.Length == 0) continue; pairs.Add((key, value)); } if (pairs.Count == 0) return ""; pairs.Sort((a, b) => string.CompareOrdinal(a.key, b.key)); return string.Join(",", pairs.Select(p => $"{p.key}={p.value}")); } private static List RotateBoxes(List boxes, int xRot, int yRot) { int xr = ((xRot % 360) + 360) % 360; int yr = ((yRot % 360) + 360) % 360; if (xr == 0 && yr == 0) return new List(boxes); var result = new List(boxes.Count); foreach (var box in boxes) { float x0 = MathF.Min(box.X0, box.X1); float y0 = MathF.Min(box.Y0, box.Y1); float z0 = MathF.Min(box.Z0, box.Z1); float x1 = MathF.Max(box.X0, box.X1); float y1 = MathF.Max(box.Y0, box.Y1); float z1 = MathF.Max(box.Z0, box.Z1); Span<(float x, float y, float z)> pts = stackalloc (float, float, float)[8]; int i = 0; for (int xi = 0; xi < 2; xi++) for (int yi = 0; yi < 2; yi++) for (int zi = 0; zi < 2; zi++) { float x = xi == 0 ? x0 : x1; float y = yi == 0 ? y0 : y1; float z = zi == 0 ? z0 : z1; (x, y, z) = RotatePoint(x, y, z, xr, yr); pts[i++] = (x, y, z); } float rx0 = pts[0].x, ry0 = pts[0].y, rz0 = pts[0].z; float rx1 = pts[0].x, ry1 = pts[0].y, rz1 = pts[0].z; for (int j = 1; j < pts.Length; j++) { var p = pts[j]; if (p.x < rx0) rx0 = p.x; if (p.y < ry0) ry0 = p.y; if (p.z < rz0) rz0 = p.z; if (p.x > rx1) rx1 = p.x; if (p.y > ry1) ry1 = p.y; if (p.z > rz1) rz1 = p.z; } result.Add(new ModelBox { X0 = rx0, Y0 = ry0, Z0 = rz0, X1 = rx1, Y1 = ry1, Z1 = rz1 }); } return result; } private static (float x, float y, float z) RotatePoint(float x, float y, float z, int xRot, int yRot) { float px = x; float py = y; float pz = z; switch (xRot) { case 90: (py, pz) = (1.0f - pz, py); break; case 180: py = 1.0f - py; pz = 1.0f - pz; break; case 270: (py, pz) = (pz, 1.0f - py); break; } switch (yRot) { case 90: (px, pz) = (pz, 1.0f - px); break; case 180: px = 1.0f - px; pz = 1.0f - pz; break; case 270: (px, pz) = (1.0f - pz, px); break; } return (px, py, pz); } private static string? SelectTexture(Dictionary textures, ModelKind kind) { string[] keys = kind == ModelKind.Item ? new[] { "layer0", "layer1" } : new[] { "all", "side", "top", "bottom", "particle" }; foreach (string key in keys) { if (textures.TryGetValue(key, out string value)) return value; } foreach (var kvp in textures) return kvp.Value; return null; } private static string ResolveTextureReference(string texture, Dictionary textures) { string current = texture; for (int i = 0; i < 8; i++) { if (!current.StartsWith("#", StringComparison.Ordinal)) return current; string key = current[1..]; if (!textures.TryGetValue(key, out string next) || string.IsNullOrWhiteSpace(next)) return ""; current = next; } return ""; } private static string NormalizeTextureName(string texture, string modelNamespace) { string value = texture.Replace('\\', '/'); if (value.Contains(':')) return value; if (string.IsNullOrWhiteSpace(modelNamespace)) return value; return $"{modelNamespace}:{value}"; } }