hexdoc.minecraft.assets
1__all__ = [ 2 "AnimatedTexture", 3 "AnimationMeta", 4 "HexdocAssetLoader", 5 "ImageTexture", 6 "ItemTexture", 7 "ItemWithTexture", 8 "ModelItem", 9 "MultiItemTexture", 10 "NamedTexture", 11 "PNGTexture", 12 "SingleItemTexture", 13 "TagWithTexture", 14 "Texture", 15 "TextureContext", 16 "TextureLookup", 17 "TextureLookups", 18 "validate_texture", 19] 20 21from .animated import ( 22 AnimatedTexture, 23 AnimationMeta, 24) 25from .items import ( 26 ImageTexture, 27 ItemTexture, 28 MultiItemTexture, 29 SingleItemTexture, 30) 31from .load_assets import ( 32 HexdocAssetLoader, 33 Texture, 34 validate_texture, 35) 36from .models import ModelItem 37from .textures import ( 38 PNGTexture, 39 TextureContext, 40 TextureLookup, 41 TextureLookups, 42) 43from .with_texture import ( 44 ItemWithTexture, 45 NamedTexture, 46 TagWithTexture, 47) 48 49HexdocPythonResourceLoader = None 50"""PLACEHOLDER - DO NOT USE 51 52This class has been removed from hexdoc, but this variable is required to fix an import 53error with old versions of `hexdoc_minecraft`. 54"""
51class AnimatedTexture(BaseTexture): 52 url: PydanticURL | None 53 pixelated: bool 54 css_class: str 55 meta: AnimationMeta 56 57 @classmethod 58 def from_url(cls, *args: Any, **kwargs: Any) -> Self: 59 raise NotImplementedError("AnimatedTexture does not support from_url()") 60 61 @property 62 def time_seconds(self): 63 return self.time / 20 64 65 @cached_property 66 def time(self): 67 return sum(time for _, time in self._normalized_frames) 68 69 @property 70 def frames(self): 71 start = 0 72 for index, time in self._normalized_frames: 73 yield AnimatedTextureFrame( 74 index=index, 75 start=start, 76 time=time, 77 animation_time=self.time, 78 ) 79 start += time 80 81 @property 82 def _normalized_frames(self): 83 """index, time""" 84 animation = self.meta.animation 85 86 for i, frame in enumerate(animation.frames): 87 match frame: 88 case int(index): 89 time = None 90 case AnimationMetaFrame(index=index, time=time): 91 pass 92 93 if index is None: 94 index = i 95 if time is None: 96 time = animation.frametime 97 98 yield index, time
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context
context manager to set validation context for constructors.
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context
context manager to set validation context for constructors.
60@dataclass(kw_only=True) 61class HexdocAssetLoader: 62 loader: ModResourceLoader 63 site_url: PydanticURL 64 asset_url: PydanticURL 65 render_dir: Path 66 67 @cached_property 68 def gaslighting_items(self): 69 return Tag.GASLIGHTING_ITEMS.load(self.loader).value_ids_set 70 71 @property 72 def texture_props(self): 73 return self.loader.props.textures 74 75 def can_be_missing(self, id: ResourceLocation): 76 if self.texture_props.missing == "*": 77 return True 78 return any(id.match(pattern) for pattern in self.texture_props.missing) 79 80 def get_override( 81 self, 82 id: ResourceLocation, 83 image_textures: dict[ResourceLocation, ImageTexture], 84 ) -> Texture | None: 85 match self.texture_props.override.get(id): 86 case PNGTextureOverride(url=url, pixelated=pixelated): 87 return PNGTexture(url=url, pixelated=pixelated) 88 case TextureTextureOverride(texture=texture): 89 return image_textures[texture] 90 case None: 91 return None 92 93 def find_image_textures( 94 self, 95 ) -> Iterable[tuple[ResourceLocation, Path | ImageTexture]]: 96 for resource_dir, texture_id, path in self.loader.find_resources( 97 "assets", 98 namespace="*", 99 folder="textures", 100 glob="**/*.png", 101 internal_only=True, 102 allow_missing=True, 103 ): 104 if resource_dir: 105 self.loader.export_raw( 106 path=path.relative_to(resource_dir.path), 107 data=path.read_bytes(), 108 ) 109 yield texture_id, path 110 111 def load_item_models(self) -> Iterable[tuple[ResourceLocation, ModelItem]]: 112 for _, item_id, data in self.loader.load_resources( 113 "assets", 114 namespace="*", 115 folder="models/item", 116 internal_only=True, 117 allow_missing=True, 118 ): 119 model = ModelItem.load_data("item" / item_id, data) 120 yield item_id, model 121 122 @cached_property 123 def renderer(self): 124 return BlockRenderer( 125 loader=self.loader, 126 output_dir=self.render_dir, 127 ) 128 129 def fallback_texture(self, item_id: ResourceLocation) -> ItemTexture | None: 130 return None 131 132 def load_and_render_internal_textures( 133 self, 134 image_textures: dict[ResourceLocation, ImageTexture], 135 ) -> Iterator[tuple[ResourceLocation, Texture]]: 136 """For all item/block models in all internal resource dirs, yields the item id 137 (eg. `hexcasting:focus`) and some kind of texture that we can use in the book.""" 138 139 # images 140 for texture_id, value in self.find_image_textures(): 141 if not texture_id.path.startswith("textures"): 142 texture_id = "textures" / texture_id 143 144 match value: 145 case Path() as path: 146 texture = load_texture( 147 texture_id, 148 path=path, 149 repo_root=self.loader.props.repo_root, 150 asset_url=self.asset_url, 151 strict=self.texture_props.strict, 152 ) 153 154 case PNGTexture() | AnimatedTexture() as texture: 155 pass 156 157 image_textures[texture_id] = texture 158 yield texture_id, texture 159 160 found_items_from_models = set[ResourceLocation]() 161 missing_items = set[ResourceLocation]() 162 163 missing_item_texture = SingleItemTexture.from_url( 164 MISSING_TEXTURE_URL, pixelated=True 165 ) 166 167 # items 168 for item_id, model in self.load_item_models(): 169 if result := self.get_override(item_id, image_textures): 170 yield item_id, result 171 elif result := load_and_render_item( 172 model, 173 self.loader, 174 self.renderer, 175 self.gaslighting_items, 176 image_textures, 177 self.site_url, 178 ): 179 found_items_from_models.add(item_id) 180 yield item_id, result 181 else: 182 missing_items.add(item_id) 183 184 for item_id in list(missing_items): 185 if result := self.fallback_texture(item_id): 186 logger.warning(f"Using fallback texture for item: {item_id}") 187 elif self.can_be_missing(item_id): 188 logger.warning(f"Using missing texture for item: {item_id}") 189 result = missing_item_texture 190 else: 191 continue 192 missing_items.remove(item_id) 193 yield item_id, result 194 195 # oopsies 196 if missing_items: 197 raise FileNotFoundError( 198 "Failed to find a texture for some items: " 199 + ", ".join(sorted(str(item) for item in missing_items)) 200 )
80 def get_override( 81 self, 82 id: ResourceLocation, 83 image_textures: dict[ResourceLocation, ImageTexture], 84 ) -> Texture | None: 85 match self.texture_props.override.get(id): 86 case PNGTextureOverride(url=url, pixelated=pixelated): 87 return PNGTexture(url=url, pixelated=pixelated) 88 case TextureTextureOverride(texture=texture): 89 return image_textures[texture] 90 case None: 91 return None
93 def find_image_textures( 94 self, 95 ) -> Iterable[tuple[ResourceLocation, Path | ImageTexture]]: 96 for resource_dir, texture_id, path in self.loader.find_resources( 97 "assets", 98 namespace="*", 99 folder="textures", 100 glob="**/*.png", 101 internal_only=True, 102 allow_missing=True, 103 ): 104 if resource_dir: 105 self.loader.export_raw( 106 path=path.relative_to(resource_dir.path), 107 data=path.read_bytes(), 108 ) 109 yield texture_id, path
111 def load_item_models(self) -> Iterable[tuple[ResourceLocation, ModelItem]]: 112 for _, item_id, data in self.loader.load_resources( 113 "assets", 114 namespace="*", 115 folder="models/item", 116 internal_only=True, 117 allow_missing=True, 118 ): 119 model = ModelItem.load_data("item" / item_id, data) 120 yield item_id, model
132 def load_and_render_internal_textures( 133 self, 134 image_textures: dict[ResourceLocation, ImageTexture], 135 ) -> Iterator[tuple[ResourceLocation, Texture]]: 136 """For all item/block models in all internal resource dirs, yields the item id 137 (eg. `hexcasting:focus`) and some kind of texture that we can use in the book.""" 138 139 # images 140 for texture_id, value in self.find_image_textures(): 141 if not texture_id.path.startswith("textures"): 142 texture_id = "textures" / texture_id 143 144 match value: 145 case Path() as path: 146 texture = load_texture( 147 texture_id, 148 path=path, 149 repo_root=self.loader.props.repo_root, 150 asset_url=self.asset_url, 151 strict=self.texture_props.strict, 152 ) 153 154 case PNGTexture() | AnimatedTexture() as texture: 155 pass 156 157 image_textures[texture_id] = texture 158 yield texture_id, texture 159 160 found_items_from_models = set[ResourceLocation]() 161 missing_items = set[ResourceLocation]() 162 163 missing_item_texture = SingleItemTexture.from_url( 164 MISSING_TEXTURE_URL, pixelated=True 165 ) 166 167 # items 168 for item_id, model in self.load_item_models(): 169 if result := self.get_override(item_id, image_textures): 170 yield item_id, result 171 elif result := load_and_render_item( 172 model, 173 self.loader, 174 self.renderer, 175 self.gaslighting_items, 176 image_textures, 177 self.site_url, 178 ): 179 found_items_from_models.add(item_id) 180 yield item_id, result 181 else: 182 missing_items.add(item_id) 183 184 for item_id in list(missing_items): 185 if result := self.fallback_texture(item_id): 186 logger.warning(f"Using fallback texture for item: {item_id}") 187 elif self.can_be_missing(item_id): 188 logger.warning(f"Using missing texture for item: {item_id}") 189 result = missing_item_texture 190 else: 191 continue 192 missing_items.remove(item_id) 193 yield item_id, result 194 195 # oopsies 196 if missing_items: 197 raise FileNotFoundError( 198 "Failed to find a texture for some items: " 199 + ", ".join(sorted(str(item) for item in missing_items)) 200 )
For all item/block models in all internal resource dirs, yields the item id
(eg. hexcasting:focus
) and some kind of texture that we can use in the book.
67class ItemWithTexture(InlineItemModel, BaseWithTexture[ItemStack, ItemTexture]): 68 @classmethod 69 def load_id(cls, item: ItemStack, context: ContextSource): 70 """Implements InlineModel.""" 71 72 i18n = I18n.of(context) 73 if (name := item.get_name()) is not None: 74 pass 75 elif item.path.startswith("texture"): 76 name = i18n.localize_texture(item.id) 77 else: 78 name = i18n.localize_item(item) 79 80 return { 81 "id": item, 82 "name": name, 83 "texture": item.id, # TODO: fix InlineModel (ItemTexture), then remove .id 84 }
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context
context manager to set validation context for constructors.
68 @classmethod 69 def load_id(cls, item: ItemStack, context: ContextSource): 70 """Implements InlineModel.""" 71 72 i18n = I18n.of(context) 73 if (name := item.get_name()) is not None: 74 pass 75 elif item.path.startswith("texture"): 76 name = i18n.localize_texture(item.id) 77 else: 78 name = i18n.localize_item(item) 79 80 return { 81 "id": item, 82 "name": name, 83 "texture": item.id, # TODO: fix InlineModel (ItemTexture), then remove .id 84 }
Implements InlineModel.
47class ModelItem(HexdocModel, extra="allow"): 48 """https://minecraft.wiki/w/Tutorials/Models#Item_models 49 50 This is called BaseModelItem instead of BaseItemModel because SomethingModel is our 51 naming convention for abstract Pydantic models. 52 """ 53 54 id: ResourceLocation 55 """Not in the actual file.""" 56 57 parent: ResourceLocation | None = None 58 """Loads a different model with the given id.""" 59 display: dict[ItemDisplayPosition, ItemDisplay] | None = None 60 gui_light: Literal["front", "side"] = "side" 61 overrides: list[ModelOverride] | None = None 62 # TODO: minecraft_render would need to support this 63 elements: Any | None = None 64 # TODO: support texture variables etc? 65 textures: dict[str, ResourceLocation] | None = None 66 """Texture ids. For example, `{"layer0": "item/red_bed"}` refers to the resource 67 `assets/minecraft/textures/item/red_bed.png`. 68 69 Technically this is only allowed for `minecraft:item/generated`, but we're currently 70 not loading Minecraft's item models, so there's lots of other parent ids that this 71 field can show up for. 72 """ 73 74 @classmethod 75 def load_resource(cls, id: ResourceLocation, loader: ModResourceLoader): 76 _, data = loader.load_resource("assets", "models", id, export=False) 77 return cls.load_data(id, data) 78 79 @classmethod 80 def load_data(cls, id: ResourceLocation, data: JSONDict): 81 return cls.model_validate(data | {"id": id}) 82 83 @property 84 def item_id(self): 85 if "/" not in self.id.path: 86 return self.id 87 path_without_prefix = "/".join(self.id.path.split("/")[1:]) 88 return self.id.with_path(path_without_prefix) 89 90 @property 91 def layer0(self): 92 if self.textures: 93 return self.textures.get("layer0") 94 95 def find_texture( 96 self, 97 loader: ModResourceLoader, 98 gaslighting_items: Set[ResourceLocation], 99 checked_overrides: defaultdict[ResourceLocation, set[int]] | None = None, 100 ) -> FoundTexture | None: 101 """May return a texture **or** a model. Texture ids will start with `textures/`.""" 102 if checked_overrides is None: 103 checked_overrides = defaultdict(set) 104 105 # gaslighting 106 # as of 0.11.1-7, all gaslighting item models are implemented with overrides 107 if self.item_id in gaslighting_items: 108 if not self.overrides: 109 raise ValueError( 110 f"Model {self.id} for item {self.item_id} marked as gaslighting but" 111 " does not have overrides" 112 ) 113 114 gaslighting_textures = list[FoundNormalTexture]() 115 for i, override in enumerate(self.overrides): 116 match self._find_override_texture( 117 i, override, loader, gaslighting_items, checked_overrides 118 ): 119 case "gaslighting", _: 120 raise ValueError( 121 f"Model {self.id} for item {self.item_id} marked as" 122 f" gaslighting but override {i} resolves to another gaslighting texture" 123 ) 124 case None: 125 break 126 case result: 127 gaslighting_textures.append(result) 128 else: 129 return "gaslighting", gaslighting_textures 130 131 # if it exists, the layer0 texture is *probably* representative 132 # TODO: impl multi-layer textures for Sam 133 if self.layer0: 134 texture_id = "textures" / self.layer0 + ".png" 135 return "texture", texture_id 136 137 # first resolvable override, if any 138 for i, override in enumerate(self.overrides or []): 139 if result := self._find_override_texture( 140 i, override, loader, gaslighting_items, checked_overrides 141 ): 142 return result 143 144 if self.parent and self.parent.path.startswith("block/"): 145 # try the parent id 146 # we only do this for blocks in the same namespace because most other 147 # parents are generic "base class"-type models which won't actually 148 # represent the item 149 if self.parent.namespace == self.id.namespace: 150 return "block_model", self.parent 151 152 # FIXME: hack 153 # this entire selection process needs to be redone, but the idea here is to 154 # try rendering item models as blocks in certain cases (eg. edified button) 155 return "block_model", self.id 156 157 return None 158 159 def _find_override_texture( 160 self, 161 index: int, 162 override: ModelOverride, 163 loader: ModResourceLoader, 164 gaslighting_items: Set[ResourceLocation], 165 checked_overrides: defaultdict[ResourceLocation, set[int]], 166 ) -> FoundTexture | None: 167 if override.model.path.startswith("block/"): 168 return "block_model", override.model 169 170 if index in checked_overrides[self.id]: 171 logger.debug(f"Ignoring recursive override: {override.model}") 172 return None 173 174 checked_overrides[self.id].add(index) 175 return ( 176 (ModelItem) 177 .load_resource(override.model, loader) 178 .find_texture(loader, gaslighting_items, checked_overrides) 179 )
https://minecraft.wiki/w/Tutorials/Models#Item_models
This is called BaseModelItem instead of BaseItemModel because SomethingModel is our naming convention for abstract Pydantic models.
Texture ids. For example, {"layer0": "item/red_bed"}
refers to the resource
assets/minecraft/textures/item/red_bed.png
.
Technically this is only allowed for minecraft:item/generated
, but we're currently
not loading Minecraft's item models, so there's lots of other parent ids that this
field can show up for.
95 def find_texture( 96 self, 97 loader: ModResourceLoader, 98 gaslighting_items: Set[ResourceLocation], 99 checked_overrides: defaultdict[ResourceLocation, set[int]] | None = None, 100 ) -> FoundTexture | None: 101 """May return a texture **or** a model. Texture ids will start with `textures/`.""" 102 if checked_overrides is None: 103 checked_overrides = defaultdict(set) 104 105 # gaslighting 106 # as of 0.11.1-7, all gaslighting item models are implemented with overrides 107 if self.item_id in gaslighting_items: 108 if not self.overrides: 109 raise ValueError( 110 f"Model {self.id} for item {self.item_id} marked as gaslighting but" 111 " does not have overrides" 112 ) 113 114 gaslighting_textures = list[FoundNormalTexture]() 115 for i, override in enumerate(self.overrides): 116 match self._find_override_texture( 117 i, override, loader, gaslighting_items, checked_overrides 118 ): 119 case "gaslighting", _: 120 raise ValueError( 121 f"Model {self.id} for item {self.item_id} marked as" 122 f" gaslighting but override {i} resolves to another gaslighting texture" 123 ) 124 case None: 125 break 126 case result: 127 gaslighting_textures.append(result) 128 else: 129 return "gaslighting", gaslighting_textures 130 131 # if it exists, the layer0 texture is *probably* representative 132 # TODO: impl multi-layer textures for Sam 133 if self.layer0: 134 texture_id = "textures" / self.layer0 + ".png" 135 return "texture", texture_id 136 137 # first resolvable override, if any 138 for i, override in enumerate(self.overrides or []): 139 if result := self._find_override_texture( 140 i, override, loader, gaslighting_items, checked_overrides 141 ): 142 return result 143 144 if self.parent and self.parent.path.startswith("block/"): 145 # try the parent id 146 # we only do this for blocks in the same namespace because most other 147 # parents are generic "base class"-type models which won't actually 148 # represent the item 149 if self.parent.namespace == self.id.namespace: 150 return "block_model", self.parent 151 152 # FIXME: hack 153 # this entire selection process needs to be redone, but the idea here is to 154 # try rendering item models as blocks in certain cases (eg. edified button) 155 return "block_model", self.id 156 157 return None
May return a texture or a model. Texture ids will start with textures/
.
37class MultiItemTexture(BaseTexture): 38 inner: list[ImageTexture] 39 gaslighting: bool 40 41 @classmethod 42 def from_url(cls, url: URL, pixelated: bool) -> Self: 43 return cls( 44 inner=[PNGTexture(url=url, pixelated=pixelated)], 45 gaslighting=False, 46 ) 47 48 @classmethod 49 def load_id(cls, id: ResourceLocation | ItemStack, context: ContextSource): 50 return super().load_id(id.id, context)
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context
context manager to set validation context for constructors.
104class NamedTexture(InlineModel, BaseWithTexture[ResourceLocation, ImageTexture]): 105 @classmethod 106 def load_id(cls, id: ResourceLocation, context: ContextSource): 107 i18n = I18n.of(context) 108 return { 109 "id": id, 110 "name": i18n.localize_texture(id, silent=True), 111 "texture": id, 112 }
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context
context manager to set validation context for constructors.
81class PNGTexture(BaseTexture): 82 url: PydanticURL | None 83 pixelated: bool 84 85 @classmethod 86 def from_url(cls, url: URL, pixelated: bool) -> Self: 87 return cls(url=url, pixelated=pixelated)
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context
context manager to set validation context for constructors.
19class SingleItemTexture(BaseTexture): 20 inner: ImageTexture 21 22 @classmethod 23 def from_url(cls, url: URL, pixelated: bool) -> Self: 24 return cls( 25 inner=PNGTexture(url=url, pixelated=pixelated), 26 ) 27 28 @classmethod 29 def load_id(cls, id: ResourceLocation | ItemStack, context: ContextSource): 30 return super().load_id(id.id, context) 31 32 @property 33 def url(self): 34 return self.inner.url
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context
context manager to set validation context for constructors.
87class TagWithTexture(InlineModel, BaseWithTexture[ResourceLocation, Texture]): 88 @classmethod 89 def load_id(cls, id: ResourceLocation, context: ContextSource): 90 i18n = I18n.of(context) 91 return cls( 92 id=id, 93 name=i18n.localize_item_tag(id), 94 texture=PNGTexture.from_url(TAG_TEXTURE_URL, pixelated=True), 95 ) 96 97 @field_validator("id", mode="after") 98 @classmethod 99 def _validate_id(cls, id: ResourceLocation): 100 assert id.is_tag, f"Expected tag id, got {id}" 101 return id
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context
context manager to set validation context for constructors.
102class TextureContext(ValidationContextModel): 103 textures: TextureLookups[Any] 104 allowed_missing_textures: set[ResourceLocation] | Literal["*"]
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context
context manager to set validation context for constructors.