diff --git a/src/hexdoc/core/properties/__init__.py b/src/hexdoc/core/properties/__init__.py index e3b63cc0..be9bcd98 100644 --- a/src/hexdoc/core/properties/__init__.py +++ b/src/hexdoc/core/properties/__init__.py @@ -8,6 +8,7 @@ "PNGTextureOverride", "Properties", "TemplateProps", + "TextureOverrides", "TextureTextureOverride", "TexturesProps", "env", @@ -25,6 +26,7 @@ AnimatedTexturesProps, AnimationFormat, PNGTextureOverride, + TextureOverrides, TexturesProps, TextureTextureOverride, ) diff --git a/src/hexdoc/core/properties/textures.py b/src/hexdoc/core/properties/textures.py index 72bd2062..5658ab97 100644 --- a/src/hexdoc/core/properties/textures.py +++ b/src/hexdoc/core/properties/textures.py @@ -5,6 +5,7 @@ from typing import Annotated, Literal from pydantic import Field +from typing_extensions import deprecated from hexdoc.model.strip_hidden import StripHiddenModel from hexdoc.utils.types import PydanticURL @@ -29,6 +30,14 @@ class AnimationFormat(StrEnum): APNG = "apng" GIF = "gif" + @property + def suffix(self): + match self: + case AnimationFormat.APNG: + return ".png" + case AnimationFormat.GIF: + return ".gif" + class AnimatedTexturesProps(StripHiddenModel): enabled: bool = True @@ -56,8 +65,16 @@ class AnimatedTexturesProps(StripHiddenModel): """ -class OverridesProps(StripHiddenModel): - pass +class TextureOverrides(StripHiddenModel): + models: dict[ResourceLocation, ResourceLocation | PydanticURL] = Field( + default_factory=dict + ) + """Model overrides. + + Key: model id (eg. `minecraft:item/stick`). + + Value: texture id (eg. `minecraft:textures/item/stick.png`) or image URL. + """ class TexturesProps(StripHiddenModel): @@ -73,7 +90,13 @@ class TexturesProps(StripHiddenModel): animated: AnimatedTexturesProps = Field(default_factory=AnimatedTexturesProps) + overrides: TextureOverrides = Field(default_factory=TextureOverrides) + override: dict[ ResourceLocation, PNGTextureOverride | TextureTextureOverride, - ] = Field(default_factory=dict) + ] = Field( + default_factory=dict, + deprecated=deprecated("Use textures.overrides.model instead"), + ) + """DEPRECATED.""" diff --git a/src/hexdoc/graphics/renderer.py b/src/hexdoc/graphics/renderer.py index 54214408..48a2f0bf 100644 --- a/src/hexdoc/graphics/renderer.py +++ b/src/hexdoc/graphics/renderer.py @@ -135,27 +135,25 @@ def _load_layers(self, model: BlockModel): def _save_animation(self, output_path: Path, frames: list[Image.Image]): kwargs: dict[str, Any] - match self.texture_props.animated.format: + match output_format := self.texture_props.animated.format: case AnimationFormat.APNG: - suffix = ".png" kwargs = dict( disposal=APNGDisposal.OP_BACKGROUND, ) case AnimationFormat.GIF: - suffix = ".gif" kwargs = dict( loop=0, # loop forever disposal=2, # restore to background color ) frames[0].save( - output_path.with_suffix(suffix), + output_path.with_suffix(output_format.suffix), save_all=True, append_images=frames[1:], duration=1000 / 20, **kwargs, ) - return suffix + return output_format.suffix def destroy(self): self.window.destroy() diff --git a/src/hexdoc/minecraft/model_loader.py b/src/hexdoc/minecraft/model_loader.py new file mode 100644 index 00000000..4f8934d9 --- /dev/null +++ b/src/hexdoc/minecraft/model_loader.py @@ -0,0 +1,149 @@ +import logging +import shutil +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path + +from typing_extensions import override +from yarl import URL + +from hexdoc.core import ModResourceLoader, ResourceLocation +from hexdoc.graphics import ModelRenderer + +from .model import BlockModel + +logger = logging.getLogger(__name__) + + +@dataclass(kw_only=True) +class ModelLoader: + loader: ModResourceLoader + renderer: ModelRenderer + site_dir: Path + site_url: URL + + def __post_init__(self): + self._cache = dict[ResourceLocation, URL]() + + self._strategies: list[ModelLoaderStrategy] = [ + FromProps(self), + FromResources(self, internal=True), + FromRenderer(self), + FromResources(self, internal=False), + ] + + @property + def props(self): + return self.loader.props + + def render_block(self, block_id: ResourceLocation): + return self.render_model("block" / block_id) + + def render_item(self, item_id: ResourceLocation): + return self.render_model("item" / item_id) + + def render_model(self, model_id: ResourceLocation): + if result := self._cache.get(model_id): + logger.debug(f"Cache hit: {model_id} = {result}") + return result + + _, model = BlockModel.load_and_resolve(self.loader, model_id) + for override_id, override_model in self._get_overrides(model_id, model): + for strategy in self._strategies: + try: + if result := strategy(override_id, override_model): + self._cache[model_id] = self._cache[override_id] = result + return result + except Exception: + logger.debug( + f"Exception while rendering override: {override_id}", + exc_info=True, + ) + + message = f"All strategies failed to render model: {model_id}" + if self.props.textures.strict: + raise ValueError(message) + logger.error(message) + + def _get_overrides(self, model_id: ResourceLocation, model: BlockModel): + # TODO: implement + yield model_id, model + + +@dataclass +class ModelLoaderStrategy(ABC): + ml: ModelLoader = field(repr=False) + + model_id: ResourceLocation = field(init=False, repr=False) + model: BlockModel = field(init=False, repr=False) + + def __call__(self, model_id: ResourceLocation, model: BlockModel) -> URL | None: + logger.debug(f"Attempting strategy: {self}") + self.model_id = model_id + self.model = model + return self._execute() + + @abstractmethod + def _execute(self) -> URL | None: ... + + def _from_existing_image(self, src: Path): + fragment = self._get_fragment(src.suffix) + shutil.copyfile(src, self.ml.site_dir / fragment) + return self._fragment_to_url(fragment) + + def _get_fragment(self, suffix: str = ".png"): + path = Path("renders") / self.model_id.namespace / self.model_id.path + return path.with_suffix(suffix) + + def _fragment_to_url(self, fragment: Path): + return self.ml.site_url.joinpath(*fragment.parts) + + +class FromProps(ModelLoaderStrategy): + @override + def _execute(self) -> URL | None: + match self.ml.props.textures.overrides.models.get(self.model_id): + case ResourceLocation() as texture_id: + _, src = self.ml.loader.find_resource("assets", "", texture_id) + return self._from_existing_image(src) + case URL() as url: + return url + case None: + logger.debug(f"No props override for model: {self.model_id}") + return None + + +@dataclass +class FromResources(ModelLoaderStrategy): + internal: bool + + @override + def _execute(self) -> URL | None: + preferred_suffix = self.ml.props.textures.animated.format.suffix + + src = None + for resource_dir, _, path in self.ml.loader.find_resources( + "assets", + namespace=self.model_id.namespace, + folder="hexdoc/renders", + glob=self.model_id.path + ".{png,gif}", + allow_missing=True, + ): + if resource_dir.internal == self.internal: + src = path + if path.suffix == preferred_suffix: + break + + if src: + return self._from_existing_image(src) + + type_ = "internal" if self.internal else "external" + logger.debug(f"No {type_} rendered resource for model: {self.model_id}") + + +class FromRenderer(ModelLoaderStrategy): + @override + def _execute(self) -> URL | None: + fragment = self._get_fragment() + suffix = self.ml.renderer.render_model(self.model, self.ml.site_dir / fragment) + return self._fragment_to_url(fragment.with_suffix(suffix)) diff --git a/web/docusaurus/docs/03-guides/04-textures/_model_precedence.mdx b/web/docusaurus/docs/03-guides/04-textures/_model_precedence.mdx index 9f01b7f8..03e8301c 100644 --- a/web/docusaurus/docs/03-guides/04-textures/_model_precedence.mdx +++ b/web/docusaurus/docs/03-guides/04-textures/_model_precedence.mdx @@ -3,7 +3,7 @@ import CodeBlock from "@theme/CodeBlock"; 1. `hexdoc.toml`: {[ - "[textures.override.model]", + "[textures.overrides.models]", `${props.namespace}:${props.path}" = ...`, ].join("\n")}