diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 802526a13..ef9d0d743 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,10 +52,37 @@ jobs: name: python-build path: dist + pre-commit: + runs-on: ubuntu-latest + env: + HEXDOC_RELEASE: false + steps: + - uses: actions/checkout@v4 + with: + submodules: true + fetch-depth: 0 + - uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + - uses: yezz123/setup-uv@v4 + + - name: Run pre-commit hooks + env: + SKIP: pyright + uses: pre-commit/action@v3.0.0 + test: runs-on: ubuntu-latest env: HEXDOC_RELEASE: false + strategy: + fail-fast: false + matrix: + nox-args: + - --tags test_fast + - --sessions test_build "test_hexcasting(branch='1.19')" + - --sessions test_build "test_hexcasting(branch='main')" + - --sessions test_build test_copier steps: - uses: actions/checkout@v4 with: @@ -71,20 +98,15 @@ jobs: with: packages: xvfb - - name: Run pre-commit hooks - env: - SKIP: pyright - uses: pre-commit/action@v3.0.0 - - name: Install Nox run: uv pip install --system nox - name: Run Nox with display server - run: xvfb-run --auto-servernum nox + run: xvfb-run --auto-servernum nox ${{ matrix.nox-args }} update-tags: runs-on: ubuntu-latest - needs: [build, test] + needs: [build, pre-commit, test] if: github.event_name == 'push' && needs.build.outputs.release == 'true' permissions: contents: write @@ -108,7 +130,7 @@ jobs: publish-pypi: runs-on: ubuntu-latest - needs: [build, test] + needs: [build, pre-commit, test] if: github.event_name == 'push' && needs.build.outputs.release == 'true' environment: name: pypi diff --git a/.gitignore b/.gitignore index acdcb428c..502646cf2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __gradle_version__.py out/ .hexdoc*/ out.png +out.gif node_modules/ package-lock.json diff --git a/.gitmodules b/.gitmodules index 9b6865e84..dbed86955 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,9 @@ [submodule "submodules/HexMod"] - path = submodules/HexMod + path = submodules/HexMod_main url = https://github.com/object-Object/HexMod [submodule "submodules/hexdoc-hexcasting-template"] path = submodules/hexdoc-hexcasting-template url = https://github.com/hexdoc-dev/hexdoc-hexcasting-template +[submodule "submodules/HexMod_1.19"] + path = submodules/HexMod_1.19 + url = https://github.com/object-Object/HexMod diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 92ab5fe08..58326310b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,22 @@ exclude: '__snapshots__|^vendor/' - +default_install_hook_types: + - pre-commit + - pre-push repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + files: ^web/docusaurus/ + types_or: [javascript, jsx, ts, tsx, mdx] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.2 + rev: v0.14.10 hooks: - name: ruff-fix id: ruff @@ -19,6 +27,7 @@ repos: hooks: - id: pyright name: pyright + stages: [pre-push] entry: nox -s pyright -- language: python language_version: '3.11' diff --git a/.vscode/launch.json b/.vscode/launch.json index 50071fceb..d6a9ecb89 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,7 +20,7 @@ "args": [ "build", // "--props", - // "./submodules/HexMod/doc/hexdoc.toml", + // "./submodules/HexMod_main/doc/hexdoc.toml", // "--release", ], "console": "integratedTerminal", diff --git a/.vscode/settings.json b/.vscode/settings.json index 41f38a8d4..882b2a43d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,8 +14,8 @@ "editor.rulers": [120], }, "ruff.organizeImports": true, - "ruff.lint.args": [ - "--extend-ignore=I", // format on save is enabled, so don't show the squiggles + "ruff.lint.ignore": [ + "I", // format on save is enabled, so don't show the squiggles ], "python.languageServer": "Pylance", "python.analysis.diagnosticMode": "workspace", diff --git a/examples/model_rendering/.gitignore b/examples/model_rendering/.gitignore index 852addd30..4c360a723 100644 --- a/examples/model_rendering/.gitignore +++ b/examples/model_rendering/.gitignore @@ -1,5 +1,6 @@ # hexdoc out/ +/renders/ .hexdoc*/ out.png diff --git a/examples/model_rendering/resources/assets/example/models/item/example_item.json b/examples/model_rendering/resources/assets/example/models/item/example_item.json index f866099ea..a08376f32 100644 --- a/examples/model_rendering/resources/assets/example/models/item/example_item.json +++ b/examples/model_rendering/resources/assets/example/models/item/example_item.json @@ -1,3 +1,6 @@ { - "parent": "minecraft:block/stone" + "parent": "item/generated", + "textures": { + "layer0": "minecraft:block/command_block_front" + } } diff --git a/examples/model_rendering/resources/assets/minecraft/hexdoc/renders/item/chest.png b/examples/model_rendering/resources/assets/minecraft/hexdoc/renders/item/chest.png new file mode 100644 index 000000000..eb7f7668a Binary files /dev/null and b/examples/model_rendering/resources/assets/minecraft/hexdoc/renders/item/chest.png differ diff --git a/nodemon.json b/nodemon.json index 11a6a944f..e0c370b36 100644 --- a/nodemon.json +++ b/nodemon.json @@ -3,12 +3,12 @@ "src", "resources", "vendor", - "submodules/HexMod/doc/src", - "submodules/HexMod/doc/resources", - "submodules/HexMod/doc/hexdoc.toml", - "submodules/HexMod/Common/src/main/resources/assets/*/lang" + "submodules/HexMod_main/doc/src", + "submodules/HexMod_main/doc/resources", + "submodules/HexMod_main/doc/hexdoc.toml", + "submodules/HexMod_main/Common/src/main/resources/assets/*/lang" ], "ignore": ["**/generated/**"], "ext": "jinja,html,css,js,ts,toml,json,json5,py,png,mcmeta", - "exec": "hexdoc build && hexdoc --quiet-lang ru_ru --quiet-lang zh_cn serve --props submodules/HexMod/doc/hexdoc.toml" + "exec": "hexdoc build && hexdoc serve --props submodules/HexMod_main/doc/hexdoc.toml" } diff --git a/noxfile.py b/noxfile.py index fa547782c..3cd904814 100644 --- a/noxfile.py +++ b/noxfile.py @@ -36,7 +36,7 @@ # tests -@nox.session +@nox.session(tags=["test_fast"]) def pyright(session: nox.Session): session.install("-e", ".[test]") @@ -47,7 +47,7 @@ def pyright(session: nox.Session): session.run("pyright", *args) -@nox.session(tags=["test"]) +@nox.session(tags=["test", "test_fast"]) def test(session: nox.Session): session.install("-e", ".[test]") @@ -61,46 +61,45 @@ def test_build(session: nox.Session): session.run("hexdoc", "build", "--branch=main", env=MOCK_ENV) -@nox.session(tags=["test", "post_build"]) -@nox.parametrize(["branch"], ["1.19_old", "main_old"]) +@nox.session(tags=["test"]) +@nox.parametrize(["branch"], ["1.19", "main"]) def test_hexcasting(session: nox.Session, branch: str): - with session.cd("submodules/HexMod"): - original_branch = run_silent_external( - session, "git", "rev-parse", "--abbrev-ref", "HEAD" - ) - if original_branch == "HEAD": # properly handle detached HEAD - original_branch = run_silent_external(session, "git", "rev-parse", "HEAD") + submodule = f"submodules/HexMod_{branch}" - session.run("git", "checkout", branch, external=True) + session.install("-e", ".[test]", "-e", f"./{submodule}") - try: - session.install("-e", ".[test]", "-e", "./submodules/HexMod") - - session.run( - "hexdoc", - "--quiet-lang=ru_ru", - "--quiet-lang=zh_cn", - "build", - "--branch=main", - "--props=submodules/HexMod/doc/hexdoc.toml", - env=MOCK_ENV, - ) + session.run( + "hexdoc", + "build", + "--branch=main", + f"--props={submodule}/doc/hexdoc.toml", + env=MOCK_ENV, + ) - session.run( - "pytest", - "-m", - "hexcasting", - *session.posargs, - env={"MOCK_PLATFORM": "Windows"}, - ) - finally: - with session.cd("submodules/HexMod"): - session.run("git", "checkout", original_branch, external=True) + session.run( + "pytest", + "-m", + "hexcasting", + *session.posargs, + env={ + "MOCK_PLATFORM": "Windows", + "TEST_SUBMODULE": submodule, + "TEST_BRANCH": branch, + }, + ) -@nox.session(tags=["test", "post_build"]) +@nox.session(tags=["test"]) def test_copier(session: nox.Session): - session.install("pip", "-e", ".[test]", "-e", "./submodules/HexMod") + session.install("pip", "-e", ".[test]", "-e", "./submodules/HexMod_main") + + session.run( + "hexdoc", + "build", + "--branch=main", + "--props=submodules/HexMod_main/doc/hexdoc.toml", + env=MOCK_ENV, + ) template_repo = Path("submodules/hexdoc-hexcasting-template") rendered_template = template_repo / ".ctt" / "test_copier" @@ -174,7 +173,8 @@ def pdoc(session: nox.Session): # docs for the docs! -@nox.session(tags=["docs"], python=False) +# note: we can't use python=False because then --no-install doesn't work +@nox.session(tags=["docs"]) def docusaurus(session: nox.Session): shutil.copytree("media", f"{STATIC_GENERATED}/img", dirs_exist_ok=True) @@ -227,7 +227,7 @@ def tag(session: nox.Session): def setup(session: nox.Session): session.install("uv", "pre-commit") - if not Path("submodules/HexMod/pyproject.toml").exists(): + if not Path("submodules/HexMod_main/pyproject.toml").exists(): session.run("git", "submodule", "update", "--init") rmtree(session, "venv", onerror=on_rm_error) @@ -237,7 +237,7 @@ def setup(session: nox.Session): *("uv", "pip", "install"), "--quiet", "-e=.[dev]", - "-e=./submodules/HexMod", + "-e=./submodules/HexMod_main", env={ "VIRTUAL_ENV": str(Path.cwd() / "venv"), }, @@ -649,6 +649,9 @@ def dummy_setup(session: nox.Session): "foo:baz" = false "mod:foo" = false + [textures] + strict = false + [template] icon = "icon.png" include = [ diff --git a/pyproject.toml b/pyproject.toml index 2a878787f..309afb17d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dependencies = [ "pygithub~=2.1", "pyjson5~=1.6", "requests~=2.31", + "tqdm~=4.66", "typer~=0.12", "typing_extensions~=4.6", "yarl~=1.9", @@ -75,7 +76,7 @@ pdoc = [ "pdoc~=14.1", ] test = [ - "pyright==1.1.361", + "pyright==1.1.389", "pytest~=7.4", "pytest-dependency~=0.5", "pytest-describe~=2.2", @@ -85,7 +86,7 @@ test = [ ] dev = [ "hexdoc[pdoc,test]", - "ruff~=0.3.2", + "ruff~=0.14.10", "pre-commit", "nox[uv]", ] @@ -193,6 +194,7 @@ include = [ ] extraPaths = [ "vendor", + "typings", ] exclude = [ "noxfile.py", @@ -205,7 +207,6 @@ exclude = [ typeCheckingMode = "basic" -enableExperimentalFeatures = true strictDictionaryInference = true strictListInference = true strictSetInference = true diff --git a/src/_scripts/json_schema.py b/src/_scripts/json_schema.py index 7f96ee42f..4ddc09390 100644 --- a/src/_scripts/json_schema.py +++ b/src/_scripts/json_schema.py @@ -4,11 +4,12 @@ import rich import typer +from pydantic import BaseModel, TypeAdapter +from typer import Argument, Option + from hexdoc.cli.utils import DefaultTyper from hexdoc.cli.utils.args import parse_import_class from hexdoc.core.compat import MinecraftVersion -from pydantic import BaseModel, TypeAdapter -from typer import Argument, Option app = DefaultTyper() diff --git a/src/hexdoc/__version__.py b/src/hexdoc/__version__.py index 7339599eb..f747685b4 100644 --- a/src/hexdoc/__version__.py +++ b/src/hexdoc/__version__.py @@ -1 +1 @@ -VERSION = "1!0.1.0a35" +VERSION = "1!0.2.0rc1.dev0" diff --git a/src/hexdoc/_export/resources/assets/hexdoc/lang/en_us.flatten.json5 b/src/hexdoc/_export/resources/assets/hexdoc/lang/en_us.flatten.json5 index a321d8aa9..89cefad21 100644 --- a/src/hexdoc/_export/resources/assets/hexdoc/lang/en_us.flatten.json5 +++ b/src/hexdoc/_export/resources/assets/hexdoc/lang/en_us.flatten.json5 @@ -43,8 +43,6 @@ when_clicked: "When clicked, would execute: {}", - any_block: "Any Block", - redirect: { "category.title": "Category: {}", "entry.title": "Entry: {}", @@ -55,4 +53,6 @@ Minecraft content and materials are the intellectual property of their respective owners.$(br2)\ Made with ❤️ using $(l:https://pypi.org/project/hexdoc/)hexdoc/$." }, + + "gui.hexdoc.any_block": "Any Block", } diff --git a/src/hexdoc/_export/resources/assets/hexdoc/lang/zh_cn.flatten.json5 b/src/hexdoc/_export/resources/assets/hexdoc/lang/zh_cn.flatten.json5 index 968d3af31..5fabaf376 100644 --- a/src/hexdoc/_export/resources/assets/hexdoc/lang/zh_cn.flatten.json5 +++ b/src/hexdoc/_export/resources/assets/hexdoc/lang/zh_cn.flatten.json5 @@ -43,8 +43,6 @@ when_clicked: "点击时执行:{}", - any_block: "任意方块", - redirect: { "category.title": "类别:{}", "entry.title": "条目:{}", @@ -55,4 +53,6 @@ Minecraft的游戏内容和资源均为各自所有者的知识财产。$(br2)\ 使用$(l:https://pypi.org/project/hexdoc/)hexdoc/$精心制作。❤️" }, + + "gui.hexdoc.any_block": "任意方块", } diff --git a/src/hexdoc/_export/resources/assets/hexdoc/textures/item/tag.png b/src/hexdoc/_export/resources/assets/hexdoc/textures/item/tag.png new file mode 100644 index 000000000..8c1ef45e1 Binary files /dev/null and b/src/hexdoc/_export/resources/assets/hexdoc/textures/item/tag.png differ diff --git a/src/hexdoc/_hooks.py b/src/hexdoc/_hooks.py index df3c182ac..5ba32816d 100644 --- a/src/hexdoc/_hooks.py +++ b/src/hexdoc/_hooks.py @@ -9,6 +9,7 @@ import hexdoc from hexdoc import HEXDOC_MODID, VERSION from hexdoc.core import IsVersion, ModResourceLoader, ResourceLocation +from hexdoc.graphics.validators import ItemImage, SingleItemImage from hexdoc.minecraft.recipe import ( ingredients as minecraft_ingredients, recipes as minecraft_recipes, @@ -82,7 +83,6 @@ def default_rendered_templates(self) -> dict[str | Path, str]: return { "index.html": "index.html.jinja", "index.css": "index.css.jinja", - "textures.css": "textures.jcss.jinja", "index.js": "index.js.jinja", } @@ -130,6 +130,9 @@ def default_rendered_templates_v2( return templates + def item_image_types(self) -> HookReturn[type[ItemImage]]: + return SingleItemImage + class PatchouliBookPlugin(BookPlugin[Book]): @property diff --git a/src/hexdoc/_templates/images/hexdoc/cycling.html.jinja b/src/hexdoc/_templates/images/hexdoc/cycling.html.jinja new file mode 100644 index 000000000..d0d7b03ae --- /dev/null +++ b/src/hexdoc/_templates/images/hexdoc/cycling.html.jinja @@ -0,0 +1,19 @@ +{% import "macros/images.html.jinja" as Images with context -%} + +
+ {% for child in image.images %} + {{ + Images.image( + child, + class_names=class_names, + is_first=loop.first, + ) + }} + {% endfor %} +
diff --git a/src/hexdoc/_templates/images/hexdoc/single.html.jinja b/src/hexdoc/_templates/images/hexdoc/single.html.jinja new file mode 100644 index 000000000..08d8deb72 --- /dev/null +++ b/src/hexdoc/_templates/images/hexdoc/single.html.jinja @@ -0,0 +1,13 @@ +{{ name }} diff --git a/src/hexdoc/_templates/macros/formatting.html.jinja b/src/hexdoc/_templates/macros/formatting.html.jinja index e580e6ed0..43add830e 100644 --- a/src/hexdoc/_templates/macros/formatting.html.jinja +++ b/src/hexdoc/_templates/macros/formatting.html.jinja @@ -1,6 +1,6 @@ {% import "macros/styles.html.jinja" as styles_html with context %} -{% import "macros/textures.html.jinja" as texture_macros with context %} {% import "macros/formatting.jinja" as fmt_base with context %} +{% import "macros/images.html.jinja" as Images with context -%} {# jump to top icon in section headers #} {% macro jump_to_top() -%} @@ -23,7 +23,7 @@ {# header for categories and entries #} {% macro section_header(value, header_tag, class_name) -%} <{{ header_tag }} class="{{ class_name }} page-header"> - {{ texture_macros.render_icon(value.icon) }} + {{ Images.item(value.icon) }} {{- value.name ~ jump_to_top() ~ permalink(value.id.path) -}} {%- endmacro %} @@ -31,7 +31,7 @@ {# link to value.id, with spoiler blur if value is a spoiler #} {% macro maybe_spoilered_link(value) -%} - {{- texture_macros.render_icon(value.icon) }} {{ value.name -}} + {{- Images.item(value.icon) }} {{ value.name -}} {%- endmacro %} diff --git a/src/hexdoc/_templates/macros/images.html.jinja b/src/hexdoc/_templates/macros/images.html.jinja new file mode 100644 index 000000000..adb4b35d9 --- /dev/null +++ b/src/hexdoc/_templates/macros/images.html.jinja @@ -0,0 +1,32 @@ +{% macro image(image, name=none, class_names=[], lazy=true, title=true, is_first=none) -%} + {%- with name = name if name is not none else image.name -%} + {%- include image.template~".html.jinja" -%} + {%- endwith -%} +{%- endmacro %} + +{% macro item(item, is_first=none, count=1) -%} + {{- image(item, is_first=is_first, class_names=["item-texture"]) -}} + {%- if count > 1 -%} +
{{ count }}
+ {%- endif -%} +{%- endmacro %} + +{% macro recipe_gui(recipe, class_names=[]) -%} + {{- image( + recipe.gui_texture, + name=recipe.gui_name, + class_names=class_names, + lazy=false, + ) -}} +{%- endmacro %} + +{% macro url(image) -%} + {{- site_url }}/{{ image.first_url -}} +{%- endmacro %} + +{% macro load_texture(id, alt) -%} + {{ alt }} +{%- endmacro %} diff --git a/src/hexdoc/_templates/macros/recipes.html.jinja b/src/hexdoc/_templates/macros/recipes.html.jinja index 91fbe12c8..a7c381dea 100644 --- a/src/hexdoc/_templates/macros/recipes.html.jinja +++ b/src/hexdoc/_templates/macros/recipes.html.jinja @@ -1,4 +1,4 @@ -{% import "macros/textures.html.jinja" as texture_macros with context -%} +{% import "macros/images.html.jinja" as Images with context -%} {% import "macros/formatting.html.jinja" as fmt with context -%} {# show the names of all the recipe results in a list of recipes #} @@ -21,7 +21,7 @@ {{ render_ingredients(ingredient.default, true) }} {{ render_ingredients(ingredient.if_loaded, true) }} {% else %} - {{ texture_macros.render_item(ingredient.item, is_first=loop.first and not is_recursive) }} + {{ Images.item(ingredient.item, is_first=loop.first and not is_recursive) }} {% endif %} {% endfor %} {%- endmacro %} diff --git a/src/hexdoc/_templates/macros/textures.html.jinja b/src/hexdoc/_templates/macros/textures.html.jinja deleted file mode 100644 index 9ef713de2..000000000 --- a/src/hexdoc/_templates/macros/textures.html.jinja +++ /dev/null @@ -1,108 +0,0 @@ -{% macro render_icon(item_or_texture, name="") -%} - {% if item_or_texture.texture is defined %} - {{- render_item(item_or_texture) -}} - {% else %} - {{- render_texture(name, item_or_texture) -}} - {% endif %} -{%- endmacro %} - -{% macro render_texture(name, texture, class_names=[], lazy=true, title=true) -%} - {% if texture.meta is defined -%} - - {%- else -%} - {{ name }} - {%- endif %} -{%- endmacro %} - -{# display a single item, with a badge if the count is greater than 1 #} -{% macro render_item(item, is_first=none, count=1) -%} - {% if item.image_textures is sequence -%} -
- {% for texture in item.image_textures %} - {{ - render_texture( - name=item.name, - texture=texture, - class_names=[ - "item-texture", - "multi-texture-active" if loop.first, - ], - ) - }} - {% endfor %} -
- {%- else -%} - {{ render_texture( - name=item.name, - texture=item.image_texture, - class_names=[ - "item-texture", - "multi-texture-active" if is_first is true, - ], - ) }} - {%- endif -%} - {% if count > 1 -%} -
{{ count }}
- {%- endif %} -{%- endmacro %} - -{% macro render_recipe_gui(recipe, class_names=[]) -%} - {{ render_texture( - name=recipe.gui_name, - texture=recipe.gui_texture, - class_names=class_names, - lazy=false, - ) }} -{%- endmacro %} - -{% macro icon_url(item_or_texture) -%} - {%- if item_or_texture.texture is defined -%} - {{ item_url(item_or_texture) }} - {%- else -%} - {{ texture_url(item_or_texture) }} - {%- endif -%} -{%- endmacro %} - -{% macro item_url(item) -%} - {%- if item.image_textures is sequence -%} - {{ texture_url(item.image_textures|first) }} - {%- else -%} - {{ texture_url(item.image_texture) }} - {%- endif -%} -{%- endmacro %} - -{% macro texture_url(texture) -%} - {#- TODO: replace when we implement gif rendering -#} - {% if texture.meta is not defined -%} - {{ texture.url }} - {%- endif %} -{%- endmacro %} diff --git a/src/hexdoc/_templates/pages/patchouli/image.html.jinja b/src/hexdoc/_templates/pages/patchouli/image.html.jinja index 865173d4c..bdac9c3a0 100644 --- a/src/hexdoc/_templates/pages/patchouli/image.html.jinja +++ b/src/hexdoc/_templates/pages/patchouli/image.html.jinja @@ -1,14 +1,14 @@ {% extends "pages/patchouli/text.html.jinja" %} -{% import "macros/textures.html.jinja" as texture_macros with context %} +{% import "macros/images.html.jinja" as Images with context -%} {% block inner_body %}

{% for image in page.images %} {#- TODO: figure out a better default name if there's no title #} {{ - texture_macros.render_texture( + Images.image( name=page.title or "", - texture=image, + image=image, lazy=false, ) }} @@ -18,7 +18,7 @@ {% endblock inner_body %} {% block redirect_image -%} - {{ texture_macros.texture_url(page.images|first) }} + {{ Images.url(page.images|first) }} {%- endblock redirect_image %} {% block redirect_extra_opengraph %} diff --git a/src/hexdoc/_templates/pages/patchouli/multiblock.html.jinja b/src/hexdoc/_templates/pages/patchouli/multiblock.html.jinja index 48681768d..6bfd9aa7a 100644 --- a/src/hexdoc/_templates/pages/patchouli/multiblock.html.jinja +++ b/src/hexdoc/_templates/pages/patchouli/multiblock.html.jinja @@ -1,5 +1,5 @@ {% extends "pages/patchouli/text.html.jinja" %} -{% import "macros/textures.html.jinja" as texture_macros with context %} +{% import "macros/images.html.jinja" as Images with context -%} {% import "macros/formatting.html.jinja" as fmt with context -%} {% block inner_body %} @@ -12,11 +12,8 @@ {% for item, count in page.multiblock.bill_of_materials() %}

- Spotlight inventory slot - {{ texture_macros.render_item(item) }} + {{ Images.load_texture("hexdoc:textures/gui/spotlight.png", "Spotlight inventory slot") }} + {{ Images.item(item) }}
{{ count }}x {{ item.name }}
diff --git a/src/hexdoc/_templates/pages/patchouli/spotlight.html.jinja b/src/hexdoc/_templates/pages/patchouli/spotlight.html.jinja index a4fc0b9a1..101df03ca 100644 --- a/src/hexdoc/_templates/pages/patchouli/spotlight.html.jinja +++ b/src/hexdoc/_templates/pages/patchouli/spotlight.html.jinja @@ -1,19 +1,16 @@ {% extends "pages/patchouli/text.html.jinja" %} -{% import "macros/textures.html.jinja" as texture_macros with context -%} +{% import "macros/images.html.jinja" as Images with context -%} {% block inner_body %}
- Spotlight inventory slot - {{ texture_macros.render_item(page.item) }} + {{ Images.load_texture("hexdoc:textures/gui/spotlight.png", "Spotlight inventory slot") }} + {{ Images.item(page.item) }}
{{ super() }} {% endblock inner_body %} {% block redirect_image -%} - {{ texture_macros.item_url(page.item) }} + {{ Images.url(page.item) }} {%- endblock redirect_image %} {% block title_attrs %} class="spotlight-title page-header"{% endblock title_attrs %} diff --git a/src/hexdoc/_templates/recipes/base.html.jinja b/src/hexdoc/_templates/recipes/base.html.jinja index 376060fcb..120f07b9e 100644 --- a/src/hexdoc/_templates/recipes/base.html.jinja +++ b/src/hexdoc/_templates/recipes/base.html.jinja @@ -1,4 +1,4 @@ -{% import "macros/textures.html.jinja" as texture_macros with context -%} +{% import "macros/images.html.jinja" as Images with context -%}
{% block title %} @@ -10,7 +10,7 @@ {% block recipe %}
{% block gui %} - {{ texture_macros.render_recipe_gui(recipe) }} + {{ Images.recipe_gui(recipe) }} {% endblock%} {% block content %}{% endblock %} diff --git a/src/hexdoc/_templates/recipes/minecraft/crafting_table.html.jinja b/src/hexdoc/_templates/recipes/minecraft/crafting_table.html.jinja index 15d900a3f..c39dc9190 100644 --- a/src/hexdoc/_templates/recipes/minecraft/crafting_table.html.jinja +++ b/src/hexdoc/_templates/recipes/minecraft/crafting_table.html.jinja @@ -1,6 +1,7 @@ {% extends "recipes/base.html.jinja" %} {% import "macros/recipes.html.jinja" as recipe_macros with context %} +{% import "macros/images.html.jinja" as Images with context -%} {% block recipe_class -%} crafting-table @@ -20,6 +21,6 @@
- {{ texture_macros.render_item(recipe.result.item, count=recipe.result.count) }} + {{ Images.item(recipe.result.item, count=recipe.result.count) }}
{% endblock content %} diff --git a/src/hexdoc/_templates/recipes/minecraft/furnace.html.jinja b/src/hexdoc/_templates/recipes/minecraft/furnace.html.jinja index 40054cb5b..b8877581e 100644 --- a/src/hexdoc/_templates/recipes/minecraft/furnace.html.jinja +++ b/src/hexdoc/_templates/recipes/minecraft/furnace.html.jinja @@ -1,7 +1,7 @@ {% extends "recipes/base.html.jinja" %} {% import "macros/recipes.html.jinja" as recipe_macros with context %} -{% import "macros/textures.html.jinja" as texture_macros with context %} +{% import "macros/images.html.jinja" as Images with context -%} {% import "macros/formatting.html.jinja" as fmt with context %} {% block recipe_class -%} @@ -18,7 +18,7 @@
- {{ texture_macros.render_item(recipe.result) }} + {{ Images.item(recipe.result) }}
{{ extra_info( @@ -39,7 +39,7 @@ {# TODO: move somewhere more sensible #} {% macro extra_info(item_id, text, classes="") -%}
- {{ texture_macros.render_item(item_id|hexdoc_item)}} + {{ Images.item(item_id|hexdoc_item_image)}} {{ text }}
{%- endmacro %} diff --git a/src/hexdoc/_templates/recipes/minecraft/smithing_table.html.jinja b/src/hexdoc/_templates/recipes/minecraft/smithing_table.html.jinja index e3f16be8f..672a722f7 100644 --- a/src/hexdoc/_templates/recipes/minecraft/smithing_table.html.jinja +++ b/src/hexdoc/_templates/recipes/minecraft/smithing_table.html.jinja @@ -1,7 +1,7 @@ {% extends "recipes/base.html.jinja" %} {% import "macros/recipes.html.jinja" as recipe_macros with context %} -{% import "macros/textures.html.jinja" as texture_macros with context %} +{% import "macros/images.html.jinja" as Images with context -%} {% import "macros/formatting.html.jinja" as fmt with context %} {% block recipe_class -%} @@ -14,16 +14,16 @@ {% block content %}
- {{ texture_macros.render_item(recipe.base.item) }} + {{ Images.item(recipe.base.item) }}
- {{ texture_macros.render_item(recipe.addition.item) }} + {{ Images.item(recipe.addition.item) }}
- {{ texture_macros.render_item(recipe.template_ingredient.item) }} + {{ Images.item(recipe.template_ingredient.item) }}
- {{ texture_macros.render_item(recipe.result_item) }} + {{ Images.item(recipe.result_item) }}
{% endblock content %} diff --git a/src/hexdoc/_templates/recipes/minecraft/stonecutter.html.jinja b/src/hexdoc/_templates/recipes/minecraft/stonecutter.html.jinja index 19242fdb9..ddbe555e2 100644 --- a/src/hexdoc/_templates/recipes/minecraft/stonecutter.html.jinja +++ b/src/hexdoc/_templates/recipes/minecraft/stonecutter.html.jinja @@ -1,7 +1,7 @@ {% extends "recipes/base.html.jinja" %} {% import "macros/recipes.html.jinja" as recipe_macros with context %} -{% import "macros/textures.html.jinja" as texture_macros with context %} +{% import "macros/images.html.jinja" as Images with context -%} {% block recipe_class -%} stonecutting-recipe recipe @@ -17,6 +17,6 @@
- {{ texture_macros.render_item(recipe.result, count=recipe.count) }} + {{ Images.item(recipe.result, count=recipe.count) }}
{% endblock content %} diff --git a/src/hexdoc/_templates/redirects/category.html.jinja b/src/hexdoc/_templates/redirects/category.html.jinja index 7708a7f31..5a0265e8a 100644 --- a/src/hexdoc/_templates/redirects/category.html.jinja +++ b/src/hexdoc/_templates/redirects/category.html.jinja @@ -1,7 +1,7 @@ {% extends "redirects/book_link.html.jinja" %} {% import "macros/formatting.txt.jinja" as fmt_txt with context %} -{% import "macros/textures.html.jinja" as texture_macros with context %} +{% import "macros/images.html.jinja" as Images with context %} {% block title -%} {{ _('hexdoc.redirect.category.title').format(category.name) }} @@ -12,5 +12,5 @@ {%- endblock description %} {% block image -%} - {{ texture_macros.icon_url(category.icon) }} + {{ Images.url(category.icon) }} {%- endblock image %} diff --git a/src/hexdoc/_templates/redirects/entry.html.jinja b/src/hexdoc/_templates/redirects/entry.html.jinja index bdb85e0f3..e6f5bcbfc 100644 --- a/src/hexdoc/_templates/redirects/entry.html.jinja +++ b/src/hexdoc/_templates/redirects/entry.html.jinja @@ -1,7 +1,7 @@ {% extends "redirects/book_link.html.jinja" %} {% import "macros/formatting.txt.jinja" as fmt_txt with context %} -{% import "macros/textures.html.jinja" as texture_macros with context %} +{% import "macros/images.html.jinja" as Images with context %} {% block title -%} {{ _('hexdoc.redirect.entry.title').format(entry.name) }} @@ -14,5 +14,5 @@ {%- endblock description %} {% block image -%} - {{ texture_macros.icon_url(entry.icon) }} + {{ Images.url(entry.icon) }} {%- endblock image %} diff --git a/src/hexdoc/_templates/textures.jcss.jinja b/src/hexdoc/_templates/textures.jcss.jinja deleted file mode 100644 index 48f366b90..000000000 --- a/src/hexdoc/_templates/textures.jcss.jinja +++ /dev/null @@ -1,13 +0,0 @@ -{% for animation in animations %} - .{{ animation.css_class }} { - animation: {{ animation.css_class }} {{ animation.time_seconds }}s steps(1, start) infinite; - background-size: 100%; - background-image: url("{{ animation.url }}"); - } - - @keyframes {{ animation.css_class }} { - {% for frame in animation.frames %} - {{ frame.start_percent }}%, {{ frame.end_percent }}% { background-position-y: {{ -100 * frame.index }}% } - {% endfor %} - } -{% endfor %} diff --git a/src/hexdoc/cli/app.py b/src/hexdoc/cli/app.py index e2727de6b..f675eae6a 100644 --- a/src/hexdoc/cli/app.py +++ b/src/hexdoc/cli/app.py @@ -11,11 +11,14 @@ import typer from packaging.version import Version +from tqdm import tqdm +from tqdm.contrib.logging import logging_redirect_tqdm from typer import Argument, Option from yarl import URL from hexdoc.__version__ import VERSION -from hexdoc.core import ModResourceLoader, ResourceLocation +from hexdoc.core import I18n, ModResourceLoader, ResourceLocation +from hexdoc.core.properties import AnimationFormat from hexdoc.data.metadata import HexdocMetadata from hexdoc.data.sitemap import ( SitemapMarker, @@ -23,17 +26,9 @@ dump_sitemap, load_sitemap, ) -from hexdoc.graphics.render import BlockRenderer, DebugType +from hexdoc.graphics import DebugType, ModelRenderer +from hexdoc.graphics.loader import ImageLoader from hexdoc.jinja.render import create_jinja_env, get_templates, render_book -from hexdoc.minecraft import I18n -from hexdoc.minecraft.assets import ( - AnimatedTexture, - PNGTexture, - TextureContext, -) -from hexdoc.minecraft.assets.load_assets import render_block -from hexdoc.minecraft.models.item import ItemModel -from hexdoc.minecraft.models.load import load_model from hexdoc.patchouli import BookContext, FormattingContext from hexdoc.plugin import ModPluginWithBook from hexdoc.utils import git_root, setup_logging, write_to_path @@ -51,24 +46,14 @@ VerbosityOption, ) from .utils.load import ( + export_metadata, init_context, load_common_data, - render_textures_and_export_metadata, ) logger = logging.getLogger(__name__) -def set_default_env(): - """Sets placeholder values for unneeded environment variables.""" - for key, value in { - "GITHUB_REPOSITORY": "placeholder/placeholder", - "GITHUB_SHA": "", - "GITHUB_PAGES_URL": "", - }.items(): - os.environ.setdefault(key, value) - - @dataclass(kw_only=True) class LoadedBookInfo: language: str @@ -97,11 +82,27 @@ def callback( Option("--version", "-V", callback=version_callback, is_eager=True), ] = False, ): + setup_logging(verbosity, ci=False, quiet_langs=quiet_lang) + if quiet_lang: logger.warning( "`--quiet-lang` is deprecated, use `props.lang.{lang}.quiet` instead." ) - setup_logging(verbosity, ci=False, quiet_langs=quiet_lang) + + if not os.getenv("CI"): + set_any = False + for key, value in { + "GITHUB_REPOSITORY": "placeholder/placeholder", + "GITHUB_SHA": "00000000", + "GITHUB_PAGES_URL": "https://example.hexxy.media", + }.items(): + if not os.getenv(key): + os.environ[key] = value + set_any = True + if set_any: + logger.info( + "CI not detected, setting defaults for missing environment variables." + ) @app.command() @@ -114,18 +115,21 @@ def repl(*, props_file: PropsOption): try: props, pm, book_plugin, plugin = load_common_data(props_file, branch="") - repl_locals |= dict( - props=props, - pm=pm, - plugin=plugin, - ) loader = ModResourceLoader.load_all( props, pm, export=False, ) - repl_locals["loader"] = loader + + renderer = ModelRenderer(loader=loader) + + image_loader = ImageLoader( + loader=loader, + renderer=renderer, + site_url=URL(), + site_dir=Path("out"), + ) if props.book_id: book_id, book_data = book_plugin.load_book_data(props.book_id, loader) @@ -139,18 +143,28 @@ def repl(*, props_file: PropsOption): )[props.default_lang] all_metadata = loader.load_metadata(model_type=HexdocMetadata) - repl_locals["all_metadata"] = all_metadata + + repl_locals |= dict( + props=props, + pm=pm, + plugin=plugin, + loader=loader, + renderer=renderer, + image_loader=image_loader, + all_metadata=all_metadata, + ) if book_id and book_data: - context = init_context( + with init_context( book_id=book_id, book_data=book_data, pm=pm, loader=loader, + image_loader=image_loader, i18n=i18n, all_metadata=all_metadata, - ) - book = book_plugin.validate_book(book_data, context=context) + ) as context: + book = book_plugin.validate_book(book_data, context=context) repl_locals |= dict( book=book, context=context, @@ -162,7 +176,7 @@ def repl(*, props_file: PropsOption): banner=dedent( f"""\ [hexdoc repl] Python {sys.version} - Locals: {', '.join(sorted(repl_locals.keys()))}""" + Locals: {", ".join(sorted(repl_locals.keys()))}""" ), readfunc=repl_readfunc(), local=repl_locals, @@ -177,6 +191,7 @@ def build( branch: BranchOption, release: ReleaseOption = False, clean: bool = False, + clean_exports: bool = True, props_file: PropsOption, ) -> Path: """Export resources and render the web book. @@ -190,18 +205,37 @@ def build( output_dir /= props.env.hexdoc_subdirectory logger.info("Exporting resources.") - with ModResourceLoader.clean_and_load_all(props, pm, export=True) as loader: + with ( + ModResourceLoader.load_all( + props, pm, export=True, clean=clean_exports + ) as loader, + ModelRenderer(loader=loader) as renderer, + ): site_path = plugin.site_path(versioned=release) site_dir = output_dir / site_path - asset_loader = plugin.asset_loader( + image_loader = ImageLoader( loader=loader, + renderer=renderer, + site_dir=site_dir, + site_url=URL().joinpath(*site_path.parts), + ) + + all_metadata = export_metadata( + loader, site_url=props.env.github_pages_url.joinpath(*site_path.parts), - asset_url=props.env.asset_url, - render_dir=site_dir, ) - all_metadata = render_textures_and_export_metadata(loader, asset_loader) + # FIXME: put this somewhere saner? + logger.info("Exporting all image-related resources.") + for folder in ["blockstates", "models", "textures"]: + loader.export_resources( + "assets", + namespace="*", + folder=folder, + glob="**/*.*", + allow_missing=True, + ) if not props.book_id: logger.info("Skipping book load because props.book_id is not set.") @@ -218,15 +252,16 @@ def build( books = list[LoadedBookInfo]() for language, i18n in all_i18n.items(): try: - context = init_context( + with init_context( book_id=book_id, book_data=book_data, pm=pm, loader=loader, + image_loader=image_loader, i18n=i18n, all_metadata=all_metadata, - ) - book = book_plugin.validate_book(book_data, context=context) + ) as context: + book = book_plugin.validate_book(book_data, context=context) books.append( LoadedBookInfo( language=language, @@ -276,7 +311,6 @@ def build( book_ctx = BookContext.of(book_info.context) formatting_ctx = FormattingContext.of(book_info.context) - texture_ctx = TextureContext.of(book_info.context) site_book_path = plugin.site_book_path( book_info.language, @@ -287,11 +321,6 @@ def build( template_args: dict[str, Any] = book_info.context | { "all_metadata": all_metadata, - "png_textures": PNGTexture.get_lookup(texture_ctx.textures), - "animations": sorted( # this MUST be sorted to avoid flaky tests - AnimatedTexture.get_lookup(texture_ctx.textures).values(), - key=lambda t: t.css_class, - ), "book": book_info.book, "book_links": book_ctx.book_links, } @@ -465,12 +494,13 @@ def render_model( model_id: str, *, props_file: PropsOption, - output_path: Annotated[Path, Option("--output", "-o")] = Path("out.png"), + output_dir: Annotated[Path, Option("--output", "-o")] = Path("out"), axes: bool = False, normals: bool = False, + size: Optional[int] = None, + format: Optional[AnimationFormat] = None, ): """Use hexdoc's block rendering to render an item or block model.""" - set_default_env() props, pm, *_ = load_common_data(props_file, branch="") debug = DebugType.NONE @@ -479,16 +509,26 @@ def render_model( if normals: debug |= DebugType.NORMALS - with ModResourceLoader.load_all(props, pm, export=False) as loader: - _, model = load_model(loader, ResourceLocation.from_str(model_id)) - while isinstance(model, ItemModel) and model.parent: - _, model = load_model(loader, model.parent) + if format: + props.textures.animated.format = format - if isinstance(model, ItemModel): - raise ValueError(f"Invalid block id: {model_id}") - - with BlockRenderer(loader=loader, debug=debug) as renderer: - renderer.render_block_model(model, output_path) + with ( + ModResourceLoader.load_all(props, pm, export=False) as loader, + ModelRenderer( + loader=loader, + debug=debug, + block_size=size, + item_size=size, + ) as renderer, + ): + image_loader = ImageLoader( + loader=loader, + renderer=renderer, + site_url=URL(), + site_dir=output_dir, + ) + result = image_loader.render_model(ResourceLocation.from_str(model_id)) + print(f"Rendered model {model_id} to {result.url}.") @app.command() @@ -506,23 +546,43 @@ def render_models( site_url = URL(site_url_str or "") - set_default_env() - props, pm, _, plugin = load_common_data(props_file, branch="") + props, pm, *_ = load_common_data(props_file, branch="") + + with ( + ModResourceLoader.load_all(props, pm, export=export_resources) as loader, + ModelRenderer(loader=loader) as renderer, + ): + image_loader = ImageLoader( + loader=loader, + renderer=renderer, + site_url=site_url, + site_dir=output_dir, + ) - with ModResourceLoader.load_all(props, pm, export=export_resources) as loader: if model_ids: - with BlockRenderer(loader=loader, output_dir=output_dir) as renderer: - for model_id in model_ids: - model_id = ResourceLocation.from_str(model_id) - render_block(model_id, renderer, site_url) + iterator = (ResourceLocation.from_str(model_id) for model_id in model_ids) else: - asset_loader = plugin.asset_loader( - loader=loader, - site_url=site_url, - asset_url=props.env.asset_url, - render_dir=output_dir, + iterator = ( + "item" / item_id + for _, item_id, _ in loader.find_resources( + "assets", + namespace="*", + folder="models/item", + internal_only=True, + ) ) - render_textures_and_export_metadata(loader, asset_loader) + + with logging_redirect_tqdm(): + bar = tqdm(iterator) + for model_id in bar: + bar.set_postfix_str(str(model_id)) + try: + image_loader.render_model(model_id) + except Exception: + logger.warning(f"Failed to render model: {model_id}") + if not props.textures.can_be_missing(model_id): + raise + logger.debug("Error:", exc_info=True) logger.info("Done.") diff --git a/src/hexdoc/cli/ci.py b/src/hexdoc/cli/ci.py index a31b62bff..1e3aac50a 100644 --- a/src/hexdoc/cli/ci.py +++ b/src/hexdoc/cli/ci.py @@ -28,6 +28,7 @@ def build( *, props_file: PropsOption, release: ReleaseOption, + clean_exports: bool = True, ): from . import app as hexdoc_app @@ -52,6 +53,7 @@ def build( branch=env.branch, props_file=props_file, release=release, + clean_exports=clean_exports, ) site_dist = site_path / "dist" diff --git a/src/hexdoc/cli/utils/__init__.py b/src/hexdoc/cli/utils/__init__.py index 991f23dff..21f4dae13 100644 --- a/src/hexdoc/cli/utils/__init__.py +++ b/src/hexdoc/cli/utils/__init__.py @@ -1,15 +1,15 @@ __all__ = [ "DefaultTyper", + "export_metadata", "init_context", "load_common_data", - "render_textures_and_export_metadata", ] from .args import ( DefaultTyper, ) from .load import ( + export_metadata, init_context, load_common_data, - render_textures_and_export_metadata, ) diff --git a/src/hexdoc/cli/utils/info.py b/src/hexdoc/cli/utils/info.py index abf5819e3..92e41a6af 100644 --- a/src/hexdoc/cli/utils/info.py +++ b/src/hexdoc/cli/utils/info.py @@ -12,7 +12,7 @@ Properties, ) from hexdoc.plugin import ModPlugin, PluginManager -from hexdoc.plugin.manager import flatten, import_package +from hexdoc.plugin.manager import flatten_hook_return, import_package logger = logging.getLogger(__name__) @@ -53,7 +53,7 @@ def _plugins(pm: PluginManager): def _jinja_template_roots(mod_plugin: ModPlugin): - for package, folder in flatten([mod_plugin.jinja_template_root() or []]): + for package, folder in flatten_hook_return(mod_plugin.jinja_template_root()): module_path = _get_package_path(package) folder_path = module_path / folder yield _relative_path(folder_path) diff --git a/src/hexdoc/cli/utils/load.py b/src/hexdoc/cli/utils/load.py index 82e536536..24e28604d 100644 --- a/src/hexdoc/cli/utils/load.py +++ b/src/hexdoc/cli/utils/load.py @@ -1,24 +1,22 @@ import logging +from contextlib import contextmanager from pathlib import Path from textwrap import indent from typing import Any, Literal, overload +from yarl import URL + from hexdoc.core import ( + I18n, MinecraftVersion, ModResourceLoader, Properties, ResourceLocation, ) -from hexdoc.data import HexdocMetadata, load_metadata_textures -from hexdoc.minecraft import I18n, Tag -from hexdoc.minecraft.assets import ( - AnimatedTexture, - HexdocAssetLoader, - PNGTexture, - Texture, - TextureContext, - TextureLookups, -) +from hexdoc.data import HexdocMetadata +from hexdoc.graphics.loader import ImageLoader +from hexdoc.minecraft import Tag +from hexdoc.model import init_context as set_init_context from hexdoc.patchouli import BookContext from hexdoc.patchouli.text import DEFAULT_MACROS, FormattingContext from hexdoc.plugin import BookPlugin, ModPlugin, ModPluginWithBook, PluginManager @@ -70,32 +68,13 @@ def load_common_data( return props, pm, book_plugin, mod_plugin -def render_textures_and_export_metadata( - loader: ModResourceLoader, - asset_loader: HexdocAssetLoader, -): +def export_metadata(loader: ModResourceLoader, site_url: URL): all_metadata = loader.load_metadata(model_type=HexdocMetadata) - all_lookups = load_metadata_textures(all_metadata) - image_textures = { - id: texture - for texture_type in [PNGTexture, AnimatedTexture] - for id, texture in texture_type.get_lookup(all_lookups).items() - } - - internal_lookups = TextureLookups[Texture](dict) - if loader.props.textures.enabled: - logger.info(f"Loading and rendering textures to {asset_loader.render_dir}.") - for id, texture in asset_loader.load_and_render_internal_textures( - image_textures - ): - texture.insert_texture(internal_lookups, id) - # this mod's metadata metadata = HexdocMetadata( - book_url=asset_loader.site_url / loader.props.default_lang, + book_url=site_url / loader.props.default_lang, asset_url=loader.props.env.asset_url, - textures=internal_lookups, ) loader.export( @@ -113,15 +92,19 @@ def render_textures_and_export_metadata( # TODO: refactor a lot of this out +@contextmanager def init_context( *, book_id: ResourceLocation, book_data: dict[str, Any], pm: PluginManager, loader: ModResourceLoader, + image_loader: ImageLoader, i18n: I18n, all_metadata: dict[str, HexdocMetadata], ): + """This is only a contextmanager so it can call `hexdoc.model.init_context`.""" + props = loader.props context = dict[str, Any]() @@ -130,11 +113,8 @@ def init_context( props, pm, loader, + image_loader, i18n, - TextureContext( - textures=load_metadata_textures(all_metadata), - allowed_missing_textures=props.textures.missing, - ), FormattingContext( book_id=book_id, macros=DEFAULT_MACROS | book_data.get("macros", {}) | props.macros, @@ -154,4 +134,5 @@ def init_context( for item in pm.update_context(context): item.add_to_context(context) - return context + with set_init_context(context): + yield context diff --git a/src/hexdoc/core/__init__.py b/src/hexdoc/core/__init__.py index d17712974..a097b94b6 100644 --- a/src/hexdoc/core/__init__.py +++ b/src/hexdoc/core/__init__.py @@ -7,8 +7,11 @@ "BookFolder", "Entity", "ExportFn", + "I18n", "IsVersion", "ItemStack", + "LocalizedItem", + "LocalizedStr", "MinecraftVersion", "ModResourceLoader", "PathResourceDir", @@ -32,6 +35,7 @@ Versioned, VersionSource, ) +from .i18n import I18n, LocalizedItem, LocalizedStr from .loader import ( METADATA_SUFFIX, BookFolder, diff --git a/src/hexdoc/minecraft/i18n.py b/src/hexdoc/core/i18n.py similarity index 96% rename from src/hexdoc/minecraft/i18n.py rename to src/hexdoc/core/i18n.py index d868022e7..30bc549b3 100644 --- a/src/hexdoc/minecraft/i18n.py +++ b/src/hexdoc/core/i18n.py @@ -9,18 +9,15 @@ from pydantic import ValidationInfo, model_validator from pydantic.functional_validators import ModelWrapValidatorHandler -from hexdoc.core import ( - ItemStack, - ModResourceLoader, - ResourceLocation, - ValueIfVersion, -) -from hexdoc.core.properties import LangProps -from hexdoc.model import HexdocModel, ValidationContextModel -from hexdoc.model.base import DEFAULT_CONFIG +from hexdoc.model.base import DEFAULT_CONFIG, HexdocModel, ValidationContextModel from hexdoc.utils import decode_and_flatten_json_dict from hexdoc.utils.json_schema import inherited, json_schema_extra_config, type_str +from .compat import ValueIfVersion +from .loader import ModResourceLoader +from .properties import LangProps +from .resource import ItemStack, ResourceLocation + logger = logging.getLogger(__name__) @@ -349,6 +346,11 @@ def fallback_tag_name(self, tag: ResourceLocation): def localize_texture(self, texture_id: ResourceLocation, silent: bool = False): path = texture_id.path.removeprefix("textures/").removesuffix(".png") + if "/" not in path: + raise ValueError( + f"Unable to localize texture id not containing '/': {texture_id}" + ) + root, rest = path.split("/", 1) # TODO: refactor / extensibilify diff --git a/src/hexdoc/core/loader.py b/src/hexdoc/core/loader.py index f1ed0487d..aed3e0ce5 100644 --- a/src/hexdoc/core/loader.py +++ b/src/hexdoc/core/loader.py @@ -10,7 +10,7 @@ from textwrap import dedent from typing import Any, Callable, Literal, Self, Sequence, TypeVar, overload -from pydantic import SkipValidation +from pydantic import Field, SkipValidation from pydantic.dataclasses import dataclass from hexdoc.model import DEFAULT_CONFIG, HexdocModel @@ -50,6 +50,7 @@ class ModResourceLoader(ValidationContext): export_dir: Path | None resource_dirs: Sequence[PathResourceDir] _stack: SkipValidation[ExitStack] + _cache: dict[Path, str] = Field(default_factory=lambda: {}) @classmethod def clean_and_load_all( @@ -59,12 +60,24 @@ def clean_and_load_all( *, export: bool = False, ): + return cls.load_all(props, pm, export=export, clean=True) + + @classmethod + def load_all( + cls, + props: Properties, + pm: PluginManager, + *, + export: bool = False, + clean: bool = False, + ) -> Self: # clear the export dir so we start with a clean slate if props.export_dir and export: - subprocess.run( - ["git", "clean", "-fdX", props.export_dir], - cwd=props.props_dir, - ) + if clean: + subprocess.run( + ["git", "clean", "-fdX", props.export_dir], + cwd=props.props_dir, + ) write_to_path( props.export_dir / "__init__.py", @@ -76,20 +89,6 @@ def clean_and_load_all( ), ) - return cls.load_all( - props, - pm, - export=export, - ) - - @classmethod - def load_all( - cls, - props: Properties, - pm: PluginManager, - *, - export: bool = False, - ) -> Self: export_dir = props.export_dir if export else None stack = ExitStack() @@ -449,12 +448,17 @@ def _load_path( decode: Callable[[str], _T] = decode_json_dict, export: ExportFn[_T] | Literal[False] | None = None, ) -> _T: - if not path.is_file(): - raise FileNotFoundError(path) + if path in self._cache: + data = self._cache[path] + logger.debug(f"Fetching {path} from cache") + else: + if not path.is_file(): + raise FileNotFoundError(path) - logger.debug(f"Loading {path}") + logger.debug(f"Loading {path}") - data = path.read_text("utf-8") + data = path.read_text("utf-8") + self._cache[path] = data value = decode(data) if resource_dir.reexport and export is not False: @@ -468,15 +472,46 @@ def _load_path( return value + def export_resources( + self, + type: ResourceType, + *, + namespace: str, + folder: str | Path, + glob: str | list[str] = "**/*", + allow_missing: bool = False, + internal_only: bool = False, + ): + for resource_dir, _, path in self.find_resources( + type, + namespace=namespace, + folder=folder, + glob=glob, + allow_missing=allow_missing, + internal_only=internal_only, + ): + if resource_dir.reexport: + self.export( + path=path.relative_to(resource_dir.path), + data=path.read_bytes(), + ) + @overload - def export(self, /, path: Path, data: str, *, cache: bool = False) -> None: ... + def export( + self, + /, + path: Path, + data: str | bytes, + *, + cache: bool = False, + ) -> None: ... @overload def export( self, /, path: Path, - data: str, + data: str | bytes, value: _T, *, decode: Callable[[str], _T] = decode_json_dict, @@ -484,10 +519,10 @@ def export( cache: bool = False, ) -> None: ... - def export( + def export( # pyright: ignore[reportInconsistentOverload] self, path: Path, - data: str, + data: str | bytes, value: _T = None, *, decode: Callable[[str], _T] = decode_json_dict, diff --git a/src/hexdoc/core/properties.py b/src/hexdoc/core/properties.py deleted file mode 100644 index f9ec6b9a4..000000000 --- a/src/hexdoc/core/properties.py +++ /dev/null @@ -1,274 +0,0 @@ -from __future__ import annotations - -import logging -from collections import defaultdict -from functools import cached_property -from pathlib import Path -from typing import Annotated, Any, Literal, Self, Sequence - -from pydantic import Field, PrivateAttr, field_validator, model_validator -from pydantic.json_schema import ( - DEFAULT_REF_TEMPLATE, - GenerateJsonSchema, - SkipJsonSchema, -) -from typing_extensions import override -from yarl import URL - -from hexdoc.model.base import HexdocSettings -from hexdoc.model.strip_hidden import StripHiddenModel -from hexdoc.utils import ( - TRACE, - PydanticOrderedSet, - RelativePath, - ValidationContext, - git_root, - load_toml_with_placeholders, - relative_path_root, -) -from hexdoc.utils.deserialize.toml import GenerateJsonSchemaTOML -from hexdoc.utils.types import PydanticURL - -from .resource import ResourceLocation -from .resource_dir import ResourceDir - -logger = logging.getLogger(__name__) - -JINJA_NAMESPACE_ALIASES = { - "patchouli": "hexdoc", -} - - -class EnvironmentVariableProps(HexdocSettings): - # default Actions environment variables - github_repository: str - github_sha: str - - # set by CI - github_pages_url: PydanticURL - - # for putting books somewhere other than the site root - hexdoc_subdirectory: str | None = None - - # optional for debugging - debug_githubusercontent: PydanticURL | None = None - - @property - def asset_url(self) -> URL: - if self.debug_githubusercontent is not None: - return URL(str(self.debug_githubusercontent)) - - return ( - URL("https://raw.githubusercontent.com") - / self.github_repository - / self.github_sha - ) - - @property - def source_url(self) -> URL: - return ( - URL("https://github.com") - / self.github_repository - / "tree" - / self.github_sha - ) - - @property - def repo_owner(self): - return self._github_repository_parts[0] - - @property - def repo_name(self): - return self._github_repository_parts[1] - - @property - def _github_repository_parts(self): - owner, repo_name = self.github_repository.split("/", maxsplit=1) - return owner, repo_name - - @model_validator(mode="after") - def _append_subdirectory(self) -> Self: - if self.hexdoc_subdirectory: - self.github_pages_url /= self.hexdoc_subdirectory - return self - - -class TemplateProps(StripHiddenModel, validate_assignment=True): - static_dir: RelativePath | None = None - icon: RelativePath | None = None - include: PydanticOrderedSet[str] - - render_from: PydanticOrderedSet[str] = Field(None, validate_default=False) # type: ignore - """List of modids to include default rendered templates from. - - If not provided, defaults to `self.include`. - """ - render: dict[Path, str] = Field(default_factory=dict) - extend_render: dict[Path, str] = Field(default_factory=dict) - - redirect: tuple[Path, str] | None = (Path("index.html"), "redirect.html.jinja") - """filename, template""" - - args: dict[str, Any] - - _was_render_set: bool = PrivateAttr(False) - - @property - def override_default_render(self): - return self._was_render_set - - @field_validator("include", "render_from", mode="after") - @classmethod - def _resolve_aliases(cls, values: PydanticOrderedSet[str] | None): - if values: - for alias, replacement in JINJA_NAMESPACE_ALIASES.items(): - if alias in values: - values.remove(alias) - values.add(replacement) - return values - - @model_validator(mode="after") - def _set_default_render_from(self): - if self.render_from is None: # pyright: ignore[reportUnnecessaryComparison] - self.render_from = self.include - return self - - -# TODO: support item/block override -class PNGTextureOverride(StripHiddenModel): - url: PydanticURL - pixelated: bool - - -class TextureTextureOverride(StripHiddenModel): - texture: ResourceLocation - """The id of an image texture (eg. `minecraft:textures/item/stick.png`).""" - - -class TexturesProps(StripHiddenModel): - enabled: bool = True - """Set to False to disable texture rendering.""" - strict: bool = True - """Set to False to print some errors instead of throwing them.""" - missing: set[ResourceLocation] | Literal["*"] = Field(default_factory=set) - override: dict[ - ResourceLocation, - PNGTextureOverride | TextureTextureOverride, - ] = Field(default_factory=dict) - - -class LangProps(StripHiddenModel): - """Configuration for a specific book language.""" - - quiet: bool = False - """If `True`, do not log warnings for missing translations. - - Using this option for the default language is not recommended. - """ - ignore_errors: bool = False - """If `True`, log fatal errors for this language instead of failing entirely. - - Using this option for the default language is not recommended. - """ - - -class BaseProperties(StripHiddenModel, ValidationContext): - env: SkipJsonSchema[EnvironmentVariableProps] - props_dir: SkipJsonSchema[Path] - - @classmethod - def load(cls, path: Path) -> Self: - return cls.load_data( - props_dir=path.parent, - data=load_toml_with_placeholders(path), - ) - - @classmethod - def load_data(cls, props_dir: Path, data: dict[str, Any]) -> Self: - props_dir = props_dir.resolve() - - with relative_path_root(props_dir): - env = EnvironmentVariableProps.model_getenv() - props = cls.model_validate( - data - | { - "env": env, - "props_dir": props_dir, - }, - ) - - logger.log(TRACE, props) - return props - - @override - @classmethod - def model_json_schema( # pyright: ignore[reportIncompatibleMethodOverride] - cls, - by_alias: bool = True, - ref_template: str = DEFAULT_REF_TEMPLATE, - schema_generator: type[GenerateJsonSchema] = GenerateJsonSchemaTOML, - mode: Literal["validation", "serialization"] = "validation", - ) -> dict[str, Any]: - return super().model_json_schema(by_alias, ref_template, schema_generator, mode) - - -class Properties(BaseProperties): - """Pydantic model for `hexdoc.toml` / `properties.toml`.""" - - modid: str - - book_type: str = "patchouli" - """Modid of the `hexdoc.plugin.BookPlugin` to use when loading this book.""" - - # TODO: make another properties type without book_id - book_id: ResourceLocation | None = Field(alias="book", default=None) - extra_books: list[ResourceLocation] = Field(default_factory=list) - - default_lang: str = "en_us" - default_branch: str = "main" - - is_0_black: bool = False - """If true, the style `$(0)` changes the text color to black; otherwise it resets - the text color to the default.""" - - resource_dirs: Sequence[ResourceDir] - export_dir: RelativePath | None = None - - entry_id_blacklist: set[ResourceLocation] = Field(default_factory=set) - - macros: dict[str, str] = Field(default_factory=dict) - link_overrides: dict[str, str] = Field(default_factory=dict) - - textures: TexturesProps = Field(default_factory=TexturesProps) - - flags: dict[str, bool] = Field(default_factory=dict) - """Local Patchouli flag overrides. - - This has the final say over built-in defaults and flags exported by other mods. - """ - - template: TemplateProps | None = None - - lang: defaultdict[ - str, - Annotated[LangProps, Field(default_factory=LangProps)], - ] = Field(default_factory=lambda: defaultdict(LangProps)) - """Per-language configuration. The key should be the language code, eg. `en_us`.""" - - extra: dict[str, Any] = Field(default_factory=dict) - - def mod_loc(self, path: str) -> ResourceLocation: - """Returns a ResourceLocation with self.modid as the namespace.""" - return ResourceLocation(self.modid, path) - - @property - def prerender_dir(self): - return self.cache_dir / "prerender" - - @property - def cache_dir(self): - return self.repo_root / ".hexdoc" - - @cached_property - def repo_root(self): - return git_root(self.props_dir) diff --git a/src/hexdoc/core/properties/__init__.py b/src/hexdoc/core/properties/__init__.py new file mode 100644 index 000000000..d1712fbfc --- /dev/null +++ b/src/hexdoc/core/properties/__init__.py @@ -0,0 +1,36 @@ +__all__ = [ + "JINJA_NAMESPACE_ALIASES", + "AnimatedTexturesProps", + "AnimationFormat", + "BaseProperties", + "EnvironmentVariableProps", + "ItemOverride", + "LangProps", + "ModelOverride", + "Properties", + "TemplateProps", + "TextureOverride", + "TextureOverrides", + "TexturesProps", + "URLOverride", + "env", + "lang", + "properties", + "template", + "textures", +] + +from .env import EnvironmentVariableProps +from .lang import LangProps +from .properties import BaseProperties, Properties +from .template import JINJA_NAMESPACE_ALIASES, TemplateProps +from .textures import ( + AnimatedTexturesProps, + AnimationFormat, + ItemOverride, + ModelOverride, + TextureOverride, + TextureOverrides, + TexturesProps, + URLOverride, +) diff --git a/src/hexdoc/core/properties/env.py b/src/hexdoc/core/properties/env.py new file mode 100644 index 000000000..6c99b4940 --- /dev/null +++ b/src/hexdoc/core/properties/env.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Self + +from pydantic import model_validator +from yarl import URL + +from hexdoc.model.base import HexdocSettings +from hexdoc.utils.types import PydanticURL + + +class EnvironmentVariableProps(HexdocSettings): + # default Actions environment variables + github_repository: str + github_sha: str + + # set by CI + github_pages_url: PydanticURL + + # for putting books somewhere other than the site root + hexdoc_subdirectory: str | None = None + + # optional for debugging + debug_githubusercontent: PydanticURL | None = None + + @property + def asset_url(self) -> URL: + if self.debug_githubusercontent is not None: + return URL(str(self.debug_githubusercontent)) + + return ( + URL("https://raw.githubusercontent.com") + / self.github_repository + / self.github_sha + ) + + @property + def source_url(self) -> URL: + return ( + URL("https://github.com") + / self.github_repository + / "tree" + / self.github_sha + ) + + @property + def repo_owner(self): + return self._github_repository_parts[0] + + @property + def repo_name(self): + return self._github_repository_parts[1] + + @property + def _github_repository_parts(self): + owner, repo_name = self.github_repository.split("/", maxsplit=1) + return owner, repo_name + + @model_validator(mode="after") + def _append_subdirectory(self) -> Self: + if self.hexdoc_subdirectory: + self.github_pages_url /= self.hexdoc_subdirectory + return self diff --git a/src/hexdoc/core/properties/lang.py b/src/hexdoc/core/properties/lang.py new file mode 100644 index 000000000..f4b0dc2ff --- /dev/null +++ b/src/hexdoc/core/properties/lang.py @@ -0,0 +1,16 @@ +from hexdoc.model.strip_hidden import StripHiddenModel + + +class LangProps(StripHiddenModel): + """Configuration for a specific book language.""" + + quiet: bool = False + """If `True`, do not log warnings for missing translations. + + Using this option for the default language is not recommended. + """ + ignore_errors: bool = False + """If `True`, log fatal errors for this language instead of failing entirely. + + Using this option for the default language is not recommended. + """ diff --git a/src/hexdoc/core/properties/properties.py b/src/hexdoc/core/properties/properties.py new file mode 100644 index 000000000..55941b74e --- /dev/null +++ b/src/hexdoc/core/properties/properties.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from functools import cached_property +from pathlib import Path +from typing import Annotated, Any, Literal, Self, Sequence + +from pydantic import Field +from pydantic.json_schema import ( + DEFAULT_REF_TEMPLATE, + GenerateJsonSchema, + SkipJsonSchema, +) +from typing_extensions import override + +from hexdoc.model.strip_hidden import StripHiddenModel +from hexdoc.utils import ( + TRACE, + RelativePath, + ValidationContext, + git_root, + load_toml_with_placeholders, + relative_path_root, +) +from hexdoc.utils.deserialize.toml import GenerateJsonSchemaTOML + +from ..resource import ResourceLocation +from ..resource_dir import ResourceDir +from .env import EnvironmentVariableProps +from .lang import LangProps +from .template import TemplateProps +from .textures import TexturesProps + +logger = logging.getLogger(__name__) + + +# TODO: why is this a separate class? +class BaseProperties(StripHiddenModel, ValidationContext): + env: SkipJsonSchema[EnvironmentVariableProps] + props_dir: SkipJsonSchema[Path] + + @classmethod + def load(cls, path: Path) -> Self: + return cls.load_data( + props_dir=path.parent, + data=load_toml_with_placeholders(path), + ) + + @classmethod + def load_data(cls, props_dir: Path, data: dict[str, Any]) -> Self: + props_dir = props_dir.resolve() + + with relative_path_root(props_dir): + env = EnvironmentVariableProps.model_getenv() + props = cls.model_validate( + data + | { + "env": env, + "props_dir": props_dir, + }, + ) + + logger.log(TRACE, props) + return props + + @override + @classmethod + def model_json_schema( # pyright: ignore[reportIncompatibleMethodOverride] + cls, + by_alias: bool = True, + ref_template: str = DEFAULT_REF_TEMPLATE, + schema_generator: type[GenerateJsonSchema] = GenerateJsonSchemaTOML, + mode: Literal["validation", "serialization"] = "validation", + ) -> dict[str, Any]: + return super().model_json_schema(by_alias, ref_template, schema_generator, mode) + + +class Properties(BaseProperties): + """Pydantic model for `hexdoc.toml` / `properties.toml`.""" + + modid: str + + book_type: str = "patchouli" + """Modid of the `hexdoc.plugin.BookPlugin` to use when loading this book.""" + + # TODO: make another properties type without book_id + book_id: ResourceLocation | None = Field(alias="book", default=None) + extra_books: list[ResourceLocation] = Field(default_factory=lambda: []) + + default_lang: str = "en_us" + default_branch: str = "main" + + is_0_black: bool = False + """If true, the style `$(0)` changes the text color to black; otherwise it resets + the text color to the default.""" + + resource_dirs: Sequence[ResourceDir] + export_dir: RelativePath | None = None + + entry_id_blacklist: set[ResourceLocation] = Field(default_factory=lambda: set()) + + macros: dict[str, str] = Field(default_factory=dict) + link_overrides: dict[str, str] = Field(default_factory=dict) + + textures: TexturesProps = Field(default_factory=lambda: TexturesProps()) + + flags: dict[str, bool] = Field(default_factory=dict) + """Local Patchouli flag overrides. + + This has the final say over built-in defaults and flags exported by other mods. + """ + + template: TemplateProps | None = None + + lang: defaultdict[ + str, + Annotated[LangProps, Field(default_factory=lambda: LangProps())], + ] = Field(default_factory=lambda: defaultdict[str, LangProps](LangProps)) + """Per-language configuration. The key should be the language code, eg. `en_us`.""" + + extra: dict[str, Any] = Field(default_factory=dict) + + def mod_loc(self, path: str) -> ResourceLocation: + """Returns a ResourceLocation with self.modid as the namespace.""" + return ResourceLocation(self.modid, path) + + @property + def prerender_dir(self): + return self.cache_dir / "prerender" + + @property + def cache_dir(self): + return self.repo_root / ".hexdoc" + + @cached_property + def repo_root(self): + return git_root(self.props_dir) diff --git a/src/hexdoc/core/properties/template.py b/src/hexdoc/core/properties/template.py new file mode 100644 index 000000000..6e5646a02 --- /dev/null +++ b/src/hexdoc/core/properties/template.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from pydantic import Field, PrivateAttr, field_validator, model_validator + +from hexdoc.model.strip_hidden import StripHiddenModel +from hexdoc.utils import ( + PydanticOrderedSet, + RelativePath, +) + +logger = logging.getLogger(__name__) + + +JINJA_NAMESPACE_ALIASES = { + "patchouli": "hexdoc", +} + + +class TemplateProps(StripHiddenModel, validate_assignment=True): + static_dir: RelativePath | None = None + icon: RelativePath | None = None + include: PydanticOrderedSet[str] + + render_from: PydanticOrderedSet[str] = Field(None, validate_default=False) # pyright: ignore[reportAssignmentType] + """List of modids to include default rendered templates from. + + If not provided, defaults to `self.include`. + """ + render: dict[Path, str] = Field(default_factory=lambda: {}) + extend_render: dict[Path, str] = Field(default_factory=lambda: {}) + + redirect: tuple[Path, str] | None = (Path("index.html"), "redirect.html.jinja") + """filename, template""" + + args: dict[str, Any] + + _was_render_set: bool = PrivateAttr(False) + + @property + def override_default_render(self): + return self._was_render_set + + @field_validator("include", "render_from", mode="after") + @classmethod + def _resolve_aliases(cls, values: PydanticOrderedSet[str] | None): + if values: + for alias, replacement in JINJA_NAMESPACE_ALIASES.items(): + if alias in values: + values.remove(alias) + values.add(replacement) + return values + + @model_validator(mode="after") + def _set_default_render_from(self): + if self.render_from is None: # pyright: ignore[reportUnnecessaryComparison] + self.render_from = self.include + return self diff --git a/src/hexdoc/core/properties/textures.py b/src/hexdoc/core/properties/textures.py new file mode 100644 index 000000000..41986aa75 --- /dev/null +++ b/src/hexdoc/core/properties/textures.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import logging +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import Field, model_validator +from typing_extensions import deprecated + +from hexdoc.model.strip_hidden import StripHiddenModel +from hexdoc.utils.types import PydanticURL + +from ..resource import ResourceLocation + +logger = logging.getLogger(__name__) + + +class URLOverride(StripHiddenModel): + url: PydanticURL + pixelated: bool = True + + +class TextureOverride(StripHiddenModel): + texture: ResourceLocation + """The id of an image texture (eg. `minecraft:textures/item/stick.png`).""" + + +class ItemOverride(StripHiddenModel): + item: ResourceLocation + """The id of an item (eg. `minecraft:stick`).""" + + +class ModelOverride(StripHiddenModel): + model: ResourceLocation + """The id of a model (eg. `minecraft:item/stick`).""" + + +Override = URLOverride | TextureOverride | ItemOverride | ModelOverride + + +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 + """If set to `False`, animated textures will be rendered as a PNG with the first + frame of the animation.""" + format: AnimationFormat = AnimationFormat.GIF + """Animated image output format. + + `gif` (the default) produces smaller but lower quality files, and interpolated + textures may have issues with flickering. + + `apng` is higher quality, but the file size is a bit larger, and some + platforms (eg. Discord) don't fully support it. + """ + max_frames: Annotated[int, Field(ge=0)] = 15 * 20 # 15 seconds * 20 tps + """Maximum number of frames for animated textures (1 frame = 1 tick = 1/20 seconds). + If a texture would have more frames than this, some frames will be dropped. + + This is mostly necessary because of prismarine, which is an interpolated texture + with a total length of 6600 frames or 5.5 minutes. + + The default value is 300 frames or 15 seconds, producing about a 3 MB animation for + prismarine. + + To disable the frame limit entirely, set this value to 0. + """ + + +class TextureOverrides(StripHiddenModel): + models: dict[ResourceLocation, Override] = Field(default_factory=lambda: {}) + """Model overrides. + + Key: model id (eg. `minecraft:item/stick`). + + Value: override. + """ + + +class TexturesProps(StripHiddenModel): + enabled: bool = True + """Set to False to disable model rendering.""" + strict: bool = True + """Set to False to print some errors instead of throwing them.""" + large_items: bool = True + """Controls whether flat item renders should be enlarged after rendering, or left at + the default size (usually 16x16). Defaults to `True`.""" + + missing: set[ResourceLocation] | Literal["*"] = Field(default_factory=lambda: set()) + + animated: AnimatedTexturesProps = Field(default_factory=AnimatedTexturesProps) + + overrides: TextureOverrides = Field(default_factory=TextureOverrides) + + override: dict[ResourceLocation, URLOverride | TextureOverride] = Field( + default_factory=lambda: {}, + deprecated=deprecated("Use textures.overrides.models instead"), + ) + """DEPRECATED - Use `textures.overrides.models` instead.""" + + def can_be_missing(self, id: ResourceLocation): + if not self.strict or self.missing == "*": + return True + return any(id.match(pat) for pat in self.missing) + + @model_validator(mode="after") + def _copy_deprecated_overrides(self): + self.overrides.models = self.override | self.overrides.models + return self diff --git a/src/hexdoc/core/resource.py b/src/hexdoc/core/resource.py index 767ef6d89..027900f92 100644 --- a/src/hexdoc/core/resource.py +++ b/src/hexdoc/core/resource.py @@ -207,6 +207,8 @@ def match(self, pattern: Self) -> bool: return fnmatch(str(self), str(pattern)) def template_path(self, type: str, folder: str = "") -> str: + """Returns a Jinja template path in the format + `{type}/{namespace}/{folder}/{path}`.""" return self.file_path_stub(type, folder, assume_json=False).as_posix() def file_path_stub( diff --git a/src/hexdoc/core/resource_dir.py b/src/hexdoc/core/resource_dir.py index 1d9977991..e1ced6989 100644 --- a/src/hexdoc/core/resource_dir.py +++ b/src/hexdoc/core/resource_dir.py @@ -18,8 +18,8 @@ from hexdoc.model import HexdocModel from hexdoc.model.base import DEFAULT_CONFIG from hexdoc.plugin import PluginManager -from hexdoc.utils import JSONDict, RelativePath -from hexdoc.utils.types import cast_nullable +from hexdoc.utils import RelativePath +from hexdoc.utils.types import cast_nullable, isdict class BaseResourceDir(HexdocModel, ABC): @@ -65,8 +65,8 @@ def internal(self): @model_validator(mode="before") @classmethod - def _default_reexport(cls, data: JSONDict | Any): - if not isinstance(data, dict): + def _default_reexport(cls, data: Any) -> Any: + if not isdict(data): return data external = cls._get_external(data) @@ -79,7 +79,7 @@ def _default_reexport(cls, data: JSONDict | Any): return data @classmethod - def _get_external(cls, data: JSONDict | Any): + def _get_external(cls, data: dict[Any, Any]): match data: case {"external": bool(), "internal": bool()}: raise ValueError(f"Expected internal OR external, got both: {data}") diff --git a/src/hexdoc/data/__init__.py b/src/hexdoc/data/__init__.py index 5f50e4d0b..729f8d7b2 100644 --- a/src/hexdoc/data/__init__.py +++ b/src/hexdoc/data/__init__.py @@ -1,3 +1,5 @@ -__all__ = ["HexdocMetadata", "load_metadata_textures"] +__all__ = [ + "HexdocMetadata", +] -from .metadata import HexdocMetadata, load_metadata_textures +from .metadata import HexdocMetadata diff --git a/src/hexdoc/data/metadata.py b/src/hexdoc/data/metadata.py index cf55c0f27..ccede92bd 100644 --- a/src/hexdoc/data/metadata.py +++ b/src/hexdoc/data/metadata.py @@ -1,29 +1,20 @@ from pathlib import Path -from hexdoc.minecraft.assets import Texture, TextureLookups -from hexdoc.model import HexdocModel -from hexdoc.utils.types import PydanticURL +from hexdoc.model import IGNORE_EXTRA_CONFIG, HexdocModel +from hexdoc.utils import PydanticURL class HexdocMetadata(HexdocModel): """Automatically generated at `export_dir/modid.hexdoc.json`.""" + # fields have been removed from the metadata; this makes it not a breaking change + model_config = IGNORE_EXTRA_CONFIG + book_url: PydanticURL | None """Github Pages base url.""" asset_url: PydanticURL """raw.githubusercontent.com base url.""" - textures: TextureLookups[Texture] @classmethod def path(cls, modid: str) -> Path: return Path(f"{modid}.hexdoc.json") - - -def load_metadata_textures(all_metadata: dict[str, HexdocMetadata]): - lookups = TextureLookups[Texture](dict) - - for metadata in all_metadata.values(): - for classname, lookup in metadata.textures.items(): - lookups[classname] |= lookup - - return lookups diff --git a/src/hexdoc/graphics/__init__.py b/src/hexdoc/graphics/__init__.py index e69de29bb..59b01b9a8 100644 --- a/src/hexdoc/graphics/__init__.py +++ b/src/hexdoc/graphics/__init__.py @@ -0,0 +1,27 @@ +__all__ = [ + "DebugType", + "HexdocImage", + "ImageField", + "ImageLoader", + "ItemImage", + "MissingImage", + "ModelRenderer", + "ModelTexture", + "TagImage", + "TextureImage", + "model", +] + +from . import model +from .loader import ImageLoader +from .renderer import ModelRenderer +from .texture import ModelTexture +from .utils import DebugType +from .validators import ( + HexdocImage, + ImageField, + ItemImage, + MissingImage, + TagImage, + TextureImage, +) diff --git a/src/hexdoc/graphics/block.py b/src/hexdoc/graphics/block.py new file mode 100644 index 000000000..b8506beb5 --- /dev/null +++ b/src/hexdoc/graphics/block.py @@ -0,0 +1,314 @@ +# pyright: reportUnknownMemberType=false + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from functools import cached_property +from typing import cast + +import moderngl as mgl +import numpy as np +from moderngl import Context, Program, Uniform +from moderngl_window import WindowConfig +from moderngl_window.context.headless import Window as HeadlessWindow +from moderngl_window.opengl.vao import VAO +from PIL import Image +from pyrr import Matrix44 + +from hexdoc.utils.logging import TRACE +from hexdoc.utils.types import Vec3, Vec4 + +from .camera import direction_camera +from .lookups import get_face_normals, get_face_uv_indices, get_face_verts +from .model import ( + BlockModel, + DisplayPosition, + Element, + ElementFace, + ElementFaceUV, + FaceName, +) +from .texture import ModelTexture +from .utils import DebugType, get_rotation_matrix, read_shader + +logger = logging.getLogger(__name__) + + +# https://minecraft.wiki/w/Help:Isometric_renders#Preferences +LIGHT_TOP = 0.98 +LIGHT_LEFT = 0.8 +LIGHT_RIGHT = 0.608 + +LIGHT_FLAT = 0.98 + + +class BlockRenderer(WindowConfig): + def __init__(self, ctx: Context, wnd: HeadlessWindow): + super().__init__(ctx, wnd) + + # depth test: ensure faces are displayed in the correct order + # blend: handle translucency + # cull face: remove back faces, eg. for trapdoors + self.ctx.enable(mgl.DEPTH_TEST | mgl.BLEND | mgl.CULL_FACE) + + view_size = 16 + self.projection = Matrix44.orthogonal_projection( + left=-view_size / 2, + right=view_size / 2, + top=view_size / 2, + bottom=-view_size / 2, + near=0.01, + far=20_000, + dtype="f4", + ) * Matrix44.from_scale((1, -1, 1), "f4") + + self.camera, self.eye = direction_camera(pos=FaceName.south) + + self.lights = [ + ((0, -1, 0), LIGHT_TOP), + ((1, 0, -1), LIGHT_LEFT), + ((-1, 0, -1), LIGHT_RIGHT), + ] + + # block faces + + self.face_prog = self.ctx.program( + vertex_shader=read_shader("block_face", "vertex"), + fragment_shader=read_shader("block_face", "fragment"), + ) + + self.uniform("m_proj").write(self.projection) + self.uniform("m_camera").write(self.camera) + + for i, (direction, diffuse) in enumerate(self.lights): + self.uniform(f"lights[{i}].direction").value = direction + self.uniform(f"lights[{i}].diffuse").value = diffuse + + # axis planes + + self.debug_plane_prog = self.ctx.program( + vertex_shader=read_shader("debug/plane", "vertex"), + fragment_shader=read_shader("debug/plane", "fragment"), + ) + + self.uniform("m_proj", self.debug_plane_prog).write(self.projection) + self.uniform("m_camera", self.debug_plane_prog).write(self.camera) + self.uniform("m_model", self.debug_plane_prog).write(Matrix44.identity("f4")) + + self.debug_axes = list[tuple[VAO, Vec4]]() + + pos = 8 + neg = 0 + for from_, to, color, direction in [ + ((0, neg, neg), (0, pos, pos), (1, 0, 0, 0.75), FaceName.east), + ((neg, 0, neg), (pos, 0, pos), (0, 1, 0, 0.75), FaceName.up), + ((neg, neg, 0), (pos, pos, 0), (0, 0, 1, 0.75), FaceName.south), + ]: + vao = VAO() + verts = get_face_verts(from_, to, direction) + vao.buffer(np.array(verts, np.float32), "3f", ["in_position"]) + self.debug_axes.append((vao, color)) + + # vertex normal vectors + + self.debug_normal_prog = self.ctx.program( + vertex_shader=read_shader("debug/normal", "vertex"), + geometry_shader=read_shader("debug/normal", "geometry"), + fragment_shader=read_shader("debug/normal", "fragment"), + ) + + self.uniform("m_proj", self.debug_normal_prog).write(self.projection) + self.uniform("m_camera", self.debug_normal_prog).write(self.camera) + self.uniform("lineSize", self.debug_normal_prog).value = 4 + + self.ctx.line_width = 3 + + def render_block( + self, + model: BlockModel, + texture_vars: dict[str, ModelTexture], + debug: DebugType = DebugType.NONE, + ): + if not model.elements: + raise ValueError("Unable to render model, no elements found") + + self.wnd.clear() + + # enable/disable flat item lighting + match model.gui_light: + case "front": + flatLighting = LIGHT_FLAT + case "side": + flatLighting = 0 + self.uniform("flatLighting").value = flatLighting + + # load textures + texture_locs = dict[str, int]() + transparent_textures = set[str]() + + for i, (name, texture) in enumerate(texture_vars.items()): + texture_locs[name] = i + + image = texture.image + frame_height = texture.frame_height + layers = len(texture.frames) + + extrema = image.getextrema() + assert len(extrema) >= 4, f"Expected 4 bands but got {len(extrema)}" + min_alpha, _ = extrema[3] + if min_alpha < 255: + logger.log(TRACE, f"Transparent texture: {name} ({min_alpha=})") + transparent_textures.add(name) + + data = bytes() + for frame in texture.frames: + data += frame.tobytes() + + logger.log( + TRACE, f"Texture array: {image.width=}, {frame_height=}, {layers=}" + ) + texture_array = self.ctx.texture_array( + size=(image.width, frame_height, layers), + components=4, + data=data, + ) + texture_array.filter = (mgl.NEAREST, mgl.NEAREST) + texture_array.use(i) + + # transform entire model + + gui = model.display.get("gui") or DisplayPosition( + rotation=(30, 225, 0), + translation=(0, 0, 0), + scale=(0.625, 0.625, 0.625), + ) + + model_transform = cast( + Matrix44, + Matrix44.from_scale(gui.scale, "f4") + * get_rotation_matrix(gui.eulers) + * Matrix44.from_translation(gui.translation, "f4") + * Matrix44.from_translation((-8, -8, -8), "f4"), + ) + + normals_transform = Matrix44.from_y_rotation(-gui.eulers[1], "f4") + self.uniform("m_normals").write(normals_transform) + + # render elements + + baked_faces = list[BakedFace]() + + for element in model.elements: + element_transform = model_transform.copy() + + # TODO: rescale?? + if rotation := element.rotation: + origin = np.array(rotation.origin) + element_transform *= cast( + Matrix44, + Matrix44.from_translation(origin, "f4") + * get_rotation_matrix(rotation.eulers) + * Matrix44.from_translation(-origin, "f4"), + ) + + # prepare each face of the element for rendering + for direction, face in element.faces.items(): + baked_face = BakedFace( + element=element, + direction=direction, + face=face, + m_model=element_transform, + texture_loc=texture_locs[face.texture_name], + texture=texture_vars[face.texture_name], + is_opaque=face.texture_name not in transparent_textures, + ) + baked_faces.append(baked_face) + + # TODO: use a map if this is actually slow + baked_faces.sort(key=lambda face: face.sortkey(self.eye)) + + animation_length = max(len(face.texture.frames) for face in baked_faces) + return [ + self._render_frame(baked_faces, debug, animation_tick) + for animation_tick in range(animation_length) + ] + + def _render_frame(self, baked_faces: list[BakedFace], debug: DebugType, tick: int): + self.wnd.clear() + + for face in baked_faces: + self.uniform("m_model").write(face.m_model) + self.uniform("texture0").value = face.texture_loc + self.uniform("layer").value = face.texture.get_frame_index(tick) + + face.vao.render(self.face_prog) + + if DebugType.NORMALS in debug: + self.uniform("m_model", self.debug_normal_prog).write(face.m_model) + face.vao.render(self.debug_normal_prog) + + if DebugType.AXES in debug: + self.ctx.disable(mgl.CULL_FACE) + for axis, color in self.debug_axes: + self.uniform("color", self.debug_plane_prog).value = color + axis.render(self.debug_plane_prog) + self.ctx.enable(mgl.CULL_FACE) + + self.ctx.finish() + + image = Image.frombytes( + mode="RGBA", + size=self.wnd.fbo.size, + data=self.wnd.fbo.read(components=4), + ).transpose(Image.Transpose.FLIP_TOP_BOTTOM) + + return image + + def uniform(self, name: str, program: Program | None = None): + program = program or self.face_prog + assert isinstance(uniform := program[name], Uniform) + return uniform + + +@dataclass(kw_only=True) +class BakedFace: + element: Element + direction: FaceName + face: ElementFace + m_model: Matrix44 + texture_loc: float + texture: ModelTexture + is_opaque: bool + + def __post_init__(self): + self.verts = get_face_verts(self.element.from_, self.element.to, self.direction) + + self.normals = get_face_normals(self.direction) + + face_uv = self.face.uv or ElementFaceUV.default(self.element, self.direction) + self.uvs = [ + value + for index in get_face_uv_indices(self.direction) + for value in face_uv.get_uv(index) + ] + + self.vao = VAO() + self.vao.buffer(np.array(self.verts, np.float32), "3f", ["in_position"]) + self.vao.buffer(np.array(self.normals, np.float32), "3f", ["in_normal"]) + self.vao.buffer(np.array(self.uvs, np.float32) / 16, "2f", ["in_texcoord_0"]) + + @cached_property + def position(self): + x, y, z, n = 0, 0, 0, 0 + for i in range(0, len(self.verts), 3): + x += self.verts[i] + y += self.verts[i + 1] + z += self.verts[i + 2] + n += 1 + return (x / n, y / n, z / n) + + def sortkey(self, eye: Vec3): + if self.is_opaque: + return 0 + return sum((a - b) ** 2 for a, b in zip(eye, self.position)) diff --git a/src/hexdoc/graphics/camera.py b/src/hexdoc/graphics/camera.py new file mode 100644 index 000000000..4ccea7ac0 --- /dev/null +++ b/src/hexdoc/graphics/camera.py @@ -0,0 +1,54 @@ +# pyright: reportUnknownMemberType=false + +from __future__ import annotations + +import math +from typing import cast + +from pyrr import Matrix44 + +from .lookups import get_direction_vec +from .model import FaceName +from .utils import transform_vec + + +def orbit_camera(pitch: float, yaw: float): + """Both values are in degrees.""" + + eye = transform_vec( + (-64, 0, 0), + cast( + Matrix44, + Matrix44.identity(dtype="f4") + * Matrix44.from_y_rotation(math.radians(yaw)) + * Matrix44.from_z_rotation(math.radians(pitch)), + ), + ) + + up = transform_vec( + (-1, 0, 0), + cast( + Matrix44, + Matrix44.identity(dtype="f4") + * Matrix44.from_y_rotation(math.radians(yaw)) + * Matrix44.from_z_rotation(math.radians(90 - pitch)), + ), + ) + + return Matrix44.look_at( + eye=eye, + target=(0, 0, 0), + up=up, + dtype="f4", + ), eye + + +def direction_camera(pos: FaceName, up: FaceName = FaceName.up): + """eg. north -> camera is placed to the north of the model, looking south""" + eye = get_direction_vec(pos, 64) + return Matrix44.look_at( + eye=eye, + target=(0, 0, 0), + up=get_direction_vec(up), + dtype="f4", + ), eye diff --git a/src/hexdoc/graphics/loader.py b/src/hexdoc/graphics/loader.py new file mode 100644 index 000000000..bad7d815e --- /dev/null +++ b/src/hexdoc/graphics/loader.py @@ -0,0 +1,250 @@ +import logging +from dataclasses import dataclass +from pathlib import Path +from traceback import TracebackException +from typing import Callable + +from yarl import URL + +from hexdoc.core import ModResourceLoader, ResourceLocation +from hexdoc.core.properties import ( + ItemOverride, + ModelOverride, + TextureOverride, + URLOverride, +) +from hexdoc.core.resource import BaseResourceLocation +from hexdoc.utils import ValidationContext +from hexdoc.utils.logging import TRACE + +from .model import BlockModel +from .renderer import ImageType, ModelRenderer +from .texture import ModelTexture + +logger = logging.getLogger(__name__) + +MISSING_TEXTURE_ID = ResourceLocation("hexdoc", "textures/item/missing.png") +TAG_TEXTURE_ID = ResourceLocation("hexdoc", "textures/item/tag.png") + + +@dataclass +class LoadedModel: + url: URL + model: BlockModel | None = None + image_type: ImageType = ImageType.UNKNOWN + + +ModelLoaderStrategy = Callable[[ResourceLocation], LoadedModel | None] + + +@dataclass(kw_only=True) +class ImageLoader(ValidationContext): + loader: ModResourceLoader + renderer: ModelRenderer + site_dir: Path + site_url: URL # this should probably be a relative path + + def __post_init__(self): + # TODO: see cache comment in hexdoc.graphics.texture + # (though it's less of an issue here since these aren't globals) + self._model_cache = dict[ResourceLocation, LoadedModel]() + self._texture_cache = dict[ResourceLocation, URL]() + + self._model_strategies: list[ModelLoaderStrategy] = [ + self._from_props, + self._from_resources(internal=True), + self._from_renderer, + self._from_resources(internal=False), + ] + + self._overridden_models = set[ResourceLocation]() + self._exceptions = list[Exception]() + + @property + def props(self): + return self.loader.props + + def render_block(self, block_id: BaseResourceLocation) -> LoadedModel: + return self.render_model("block" / block_id.id) + + def render_item(self, item_id: BaseResourceLocation) -> LoadedModel: + return self.render_model("item" / item_id.id) + + def render_model(self, model_id: BaseResourceLocation) -> LoadedModel: + self._overridden_models.clear() + self._exceptions.clear() + try: + return self._render_model_recursive(model_id.id) + except Exception as e: + if self._exceptions: + # FIXME: hack + # necessary because of https://github.com/Textualize/rich/issues/1859 + group = ExceptionGroup( + "Caught errors while rendering model", + self._exceptions, + ) + traceback = "".join(TracebackException.from_exception(group).format()) + if e.args: + e.args = (f"{e.args[0]}\n{traceback}", *e.args[1:]) + else: + e.args = (traceback,) + raise e + finally: + self._overridden_models.clear() + self._exceptions.clear() + + def _render_model_recursive(self, model_id: ResourceLocation): + logger.debug(f"Rendering model: {model_id}") + for strategy in self._model_strategies: + logger.debug(f"Attempting model strategy: {strategy.__name__}") + try: + if result := strategy(model_id): + self._model_cache[model_id] = result + return result + except Exception as e: + logger.debug( + f"Exception while rendering override: {model_id}", + exc_info=True, + ) + self._exceptions.append(e) + + raise ValueError(f"Failed to render model: {model_id}") + + def render_texture(self, texture_id: ResourceLocation) -> URL: + if result := self._texture_cache.get(texture_id): + return result + + try: + _, path = self.loader.find_resource("assets", "", texture_id) + result = self._render_existing_texture(path, texture_id) + self._texture_cache[texture_id] = result + return result + except FileNotFoundError: + raise ValueError(f"Failed to find texture: {texture_id}") + + def _load_model(self, model_id: ResourceLocation) -> LoadedModel | BlockModel: + if result := self._model_cache.get(model_id): + logger.log(TRACE, f"Cache hit: {model_id} = {result}") + return result + + try: + _, model = BlockModel.load_and_resolve(self.loader, model_id) + return model + except Exception as e: + raise ValueError(f"Failed to load model: {model_id}: {e}") from e + + def _render_existing_texture(self, src: Path, output_id: ResourceLocation): + fragment = self._get_fragment(output_id, src.suffix) + texture = ModelTexture.load(self.loader, src) + suffix = self.renderer.save_image(self.site_dir / fragment, texture.frames) + return self._fragment_to_url(fragment.with_suffix(suffix)) + + def _get_fragment(self, output_id: ResourceLocation, suffix: str = ".png"): + path = Path("renders") / output_id.namespace / output_id.path + return path.with_suffix(suffix) + + def _fragment_to_url(self, fragment: Path): + return self.site_url.joinpath(*fragment.parts) + + def _find_texture( + self, + texture_id: ResourceLocation, + *, + folder: str, + internal: bool, + ): + preferred_suffix = self.props.textures.animated.format.suffix + + path = None + prev_dir = None + for resource_dir, _, path in self.loader.find_resources( + "assets", + namespace=texture_id.namespace, + folder=folder, + glob=texture_id.path + ".*", + allow_missing=True, + ): + if path.suffix not in {".png", ".gif"}: + continue + # after we find a match, only keep looking in the same resource dir + # so we don't break the load order + if prev_dir and prev_dir != resource_dir: + break + if resource_dir.internal == internal: + path = path + prev_dir = resource_dir + if path.suffix == preferred_suffix: + break + return path + + # model rendering strategies + + def _from_props(self, model_id: ResourceLocation): + match self.props.textures.overrides.models.get(model_id): + case URLOverride(url=url, pixelated=pixelated): + return LoadedModel( + url, + image_type=ImageType.ITEM if pixelated else ImageType.BLOCK, + ) + case TextureOverride(texture=texture_id): + return LoadedModel(self.render_texture(texture_id)) + case ModelOverride(model=override_model_id): + return self.render_model(override_model_id) + case ItemOverride(item=item_id): + return self.render_item(item_id) + case None: + logger.debug(f"No props override for model: {model_id}") + return None + + def _from_resources(self, *, internal: bool): + type_ = "internal" if internal else "external" + + def inner(model_id: ResourceLocation): + if path := self._find_texture( + model_id, + folder="hexdoc/renders", + internal=internal, + ): + return LoadedModel(self._render_existing_texture(path, model_id)) + logger.debug(f"No {type_} rendered resource for model: {model_id}") + + inner.__name__ = f"_from_resources({internal=})" + return inner + + def _from_renderer(self, model_id: ResourceLocation): + model = self._load_model(model_id) + if not isinstance(model, BlockModel): + return model + + try: + return self._from_model(model) + except Exception as e: + logger.debug(f"Failed to render model {model.id}: {e}") + self._exceptions.append(e) + + if not model.overrides: + return None + + if model.id in self._overridden_models: + logger.debug(f"Skipping override check for recursive override: {model.id}") + return None + self._overridden_models.add(model.id) + + # TODO: implement a smarter way of choosing an override? + n = len(model.overrides) + for i, override in enumerate(model.overrides): + logger.debug(f"Rendering model override ({i + 1}/{n}): {override.model}") + try: + return self._render_model_recursive(override.model) + except Exception as e: + logger.debug(f"Failed to render override {override.model}: {e}") + self._exceptions.append(e) + + def _from_model(self, model: BlockModel): + fragment = self._get_fragment(model.id) + suffix, image_type = self.renderer.render_model(model, self.site_dir / fragment) + return LoadedModel( + self._fragment_to_url(fragment.with_suffix(suffix)), + model, + image_type, + ) diff --git a/src/hexdoc/graphics/lookups.py b/src/hexdoc/graphics/lookups.py new file mode 100644 index 000000000..5da9495f9 --- /dev/null +++ b/src/hexdoc/graphics/lookups.py @@ -0,0 +1,106 @@ +# pyright: reportUnknownMemberType=false + +from __future__ import annotations + +from hexdoc.utils.types import Vec3 + +from .model import FaceName + + +def get_face_verts(from_: Vec3, to: Vec3, direction: FaceName): + x1, y1, z1 = from_ + x2, y2, z2 = to + + # fmt: off + match direction: + case FaceName.south: + return [ + x2, y1, z2, + x2, y2, z2, + x1, y1, z2, + x2, y2, z2, + x1, y2, z2, + x1, y1, z2, + ] + case FaceName.east: + return [ + x2, y1, z1, + x2, y2, z1, + x2, y1, z2, + x2, y2, z1, + x2, y2, z2, + x2, y1, z2, + ] + case FaceName.down: + return [ + x2, y1, z1, + x2, y1, z2, + x1, y1, z2, + x2, y1, z1, + x1, y1, z2, + x1, y1, z1, + ] + case FaceName.west: + return [ + x1, y1, z2, + x1, y2, z2, + x1, y2, z1, + x1, y1, z2, + x1, y2, z1, + x1, y1, z1, + ] + case FaceName.north: + return [ + x2, y2, z1, + x2, y1, z1, + x1, y1, z1, + x2, y2, z1, + x1, y1, z1, + x1, y2, z1, + ] + case FaceName.up: + return [ + x2, y2, z1, + x1, y2, z1, + x2, y2, z2, + x1, y2, z1, + x1, y2, z2, + x2, y2, z2, + ] + # fmt: on + + +def get_face_uv_indices(direction: FaceName): + match direction: + case FaceName.south: + return (2, 3, 1, 3, 0, 1) + case FaceName.east: + return (2, 3, 1, 3, 0, 1) + case FaceName.down: + return (2, 3, 0, 2, 0, 1) + case FaceName.west: + return (2, 3, 0, 2, 0, 1) + case FaceName.north: + return (0, 1, 2, 0, 2, 3) + case FaceName.up: + return (3, 0, 2, 0, 1, 2) + + +def get_direction_vec(direction: FaceName, magnitude: float = 1): + match direction: + case FaceName.north: + return (0, 0, -magnitude) + case FaceName.south: + return (0, 0, magnitude) + case FaceName.west: + return (-magnitude, 0, 0) + case FaceName.east: + return (magnitude, 0, 0) + case FaceName.down: + return (0, -magnitude, 0) + case FaceName.up: + return (0, magnitude, 0) + + +def get_face_normals(direction: FaceName): + return 6 * get_direction_vec(direction) diff --git a/src/hexdoc/graphics/model/__init__.py b/src/hexdoc/graphics/model/__init__.py new file mode 100644 index 000000000..b52af2a89 --- /dev/null +++ b/src/hexdoc/graphics/model/__init__.py @@ -0,0 +1,19 @@ +__all__ = [ + "Animation", + "AnimationFrame", + "AnimationMeta", + "BlockModel", + "Blockstate", + "BuiltInModelType", + "DisplayPosition", + "Element", + "ElementFace", + "ElementFaceUV", + "FaceName", +] + +from .animation import Animation, AnimationFrame, AnimationMeta +from .block import BlockModel, BuiltInModelType +from .blockstate import Blockstate +from .display import DisplayPosition +from .element import Element, ElementFace, ElementFaceUV, FaceName diff --git a/src/hexdoc/graphics/model/animation.py b/src/hexdoc/graphics/model/animation.py new file mode 100644 index 000000000..3493363e4 --- /dev/null +++ b/src/hexdoc/graphics/model/animation.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import Field, ValidationInfo, field_validator, model_validator + +from hexdoc.model import HexdocModel +from hexdoc.utils.types import cast_nullable + + +class AnimationMeta(HexdocModel): + """Animated texture `.mcmeta` file. + + Block, item, particle, painting, item frame, and status effect icon + (`assets/minecraft/textures/mob_effect`) textures support animation by placing each + additional frame below the last. The animation is then controlled using a `.mcmeta` + file in JSON format with the same name and `.png` at the end of the filename, in the + same directory. For example, the `.mcmeta` file for `stone.png` would be + `stone.png.mcmeta`. + + https://minecraft.wiki/w/Resource_pack#Animation + """ + + animation: Animation + """Contains data for the animation.""" + + +class Animation(HexdocModel): + """https://minecraft.wiki/w/Resource_pack#Animation""" + + interpolate: bool = False + """If true, generate additional frames between frames with a frame time greater than + 1 between them.""" + width: int | None = Field(None, ge=1) + """The width of the tile, as a direct ratio rather than in pixels. + + This is unused in vanilla's files but can be used by resource packs to have + frames that are not perfect squares. + """ + height: int | None = Field(None, ge=1) + """The height of the tile as a ratio rather than in pixels. + + This is unused in vanilla's files but can be used by resource packs to have + frames that are not perfect squares. + """ + frametime: int = Field(1, ge=1) + """Sets the default time for each frame in increments of one game tick.""" + frames: list[AnimationFrame] = Field(default_factory=lambda: []) + """Contains a list of frames. + + Integer values are a number corresponding to position of a frame from the top, + with the top frame being 0. + + Defaults to displaying all the frames from top to bottom. + """ + + @field_validator("width", "height", mode="after") + @classmethod + def _raise_not_implemented(cls, value: int | None, info: ValidationInfo): + if value is not None: + raise ValueError( + f"Field {info.field_name} is not currently supported" + + f" (expected None, got {value})" + ) + return value + + @model_validator(mode="after") + def _late_init_frames(self): + for i, frame in enumerate(self.frames): + if cast_nullable(frame.index) is None: + frame.index = i + if cast_nullable(frame.time) is None: + frame.time = self.frametime + return self + + +class AnimationFrame(HexdocModel): + index: int = Field(None, ge=0, validate_default=False) # pyright: ignore[reportAssignmentType] + """A number corresponding to position of a frame from the top, with the top + frame being 0.""" + time: int = Field(None, ge=1, validate_default=False) # pyright: ignore[reportAssignmentType] + """The time in ticks to show this frame.""" + + @model_validator(mode="before") + @classmethod + def _convert_from_int(cls, value: Any): + match value: + case int(): + return {"index": value} + case _: + return value diff --git a/src/hexdoc/graphics/model/block.py b/src/hexdoc/graphics/model/block.py new file mode 100644 index 000000000..fc00724c5 --- /dev/null +++ b/src/hexdoc/graphics/model/block.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from enum import Enum +from functools import cached_property +from typing import Literal, Self + +from pydantic import Field, PrivateAttr, model_validator + +from hexdoc.core import ModResourceLoader, ResourceLocation +from hexdoc.model import IGNORE_EXTRA_CONFIG, HexdocModel +from hexdoc.utils.types import PydanticOrderedSet, cast_nullable + +from .display import DisplayPosition, DisplayPositionName +from .element import Element, TextureVariable + + +class BlockModel(HexdocModel): + """Represents a Minecraft block (or item!!) model. + + https://minecraft.wiki/w/Tutorials/Models + """ + + model_config = IGNORE_EXTRA_CONFIG + + # common fields + + parent_id: ResourceLocation | None = Field(None, alias="parent") + """Loads a different model from the given path, in form of a resource location. + + If both "parent" and "elements" are set, the "elements" tag overrides the "elements" + tag from the previous model. + """ + display: dict[DisplayPositionName, DisplayPosition] = Field( + default_factory=lambda: {} + ) + """Holds the different places where item models are displayed. + + `fixed` refers to item frames, while the rest are as their name states. + """ + textures: dict[str, TextureVariable | ResourceLocation] = Field( + default_factory=dict + ) + """Holds the textures of the model, in form of a resource location or can be another + texture variable.""" + elements: list[Element] | None = None + """Contains all the elements of the model. They can have only cubic forms. + + If both "parent" and "elements" are set, the "elements" tag overrides the "elements" + tag from the previous model. + """ + gui_light: Literal["front", "side"] = Field(None, validate_default=False) # pyright: ignore[reportAssignmentType] + """If set to `side` (default), the model is rendered like a block. + + If set to `front`, model is shaded like a flat item. + + Note: although the wiki only lists this field for item models, Minecraft sets it in + the models `minecraft:block/block` and `minecraft:block/calibrated_sculk_sensor`. + """ + + # blocks only + + ambientocclusion: bool = True + """Whether to use ambient occlusion or not. + + Note: only works on parent file. + """ + render_type: ResourceLocation | None = None + """Sets the rendering type for this model. + + https://docs.minecraftforge.net/en/latest/rendering/modelextensions/rendertypes/ + """ + + # items only + + overrides: list[ItemOverride] | None = None + """Determines cases in which a different model should be used based on item tags. + + All cases are evaluated in order from top to bottom and last predicate that matches + overrides. However, overrides are ignored if it has been already overridden once, + for example this avoids recursion on overriding to the same model. + """ + + # internal fields + _builtin_parent: BuiltInModelType | None = PrivateAttr(None) + _id: ResourceLocation = PrivateAttr(None) # pyright: ignore[reportAssignmentType] + + @classmethod + def load_and_resolve(cls, loader: ModResourceLoader, model_id: ResourceLocation): + resource_dir, model = cls.load_only(loader, model_id) + return resource_dir, model.resolve(loader) + + @classmethod + def load_only(cls, loader: ModResourceLoader, model_id: ResourceLocation): + """Loads the given model without resolving it.""" + try: + resource_dir, model = loader.load_resource( + type="assets", + folder="models", + id=model_id, + decode=cls.model_validate_json, + ) + model._id = model_id + return resource_dir, model + except Exception as e: + e.add_note(f" note: {model_id=}") + raise + + def resolve(self, loader: ModResourceLoader): + """Loads this model's parents and applies them in-place. + + Returns this model for convenience. + """ + loaded_parents = PydanticOrderedSet[ResourceLocation]() + while parent_id := self.parent_id: + if parent_id in loaded_parents: + raise ValueError( + "Recursive model parent chain: " + + " -> ".join(str(v) for v in [*loaded_parents, parent_id]) + ) + loaded_parents.add(parent_id) + + if builtin_parent := BuiltInModelType.get(parent_id): + self._builtin_parent = builtin_parent + self.parent_id = None + else: + _, parent = self.load_only(loader, parent_id) + self._apply_parent(parent) + + return self + + def _apply_parent(self, parent: Self): + self.parent_id = parent.parent_id + + # prefer current display/textures over parent + self.display = parent.display | self.display + self.textures = parent.textures | self.textures + + # only use parent elements if current model doesn't have elements + if self.elements is None: + self.elements = parent.elements + + if not self._was_gui_light_set: + self.gui_light = parent.gui_light + + self.ambientocclusion = parent.ambientocclusion + + self.render_type = self.render_type or parent.render_type + + @property + def is_resolved(self): + return self.parent_id is None + + @property + def builtin_parent(self): + return self._builtin_parent + + @property + def id(self): + return self._id + + @cached_property + def resolved_textures(self): + assert self.is_resolved, "Cannot resolve textures for unresolved model" + + textures = dict[str, ResourceLocation]() + for name, value in self.textures.items(): + # TODO: is it possible for this to loop forever? + while not isinstance(value, ResourceLocation): + value = value.lstrip("#") + if value == name: + raise ValueError(f"Cyclic texture variable detected: {name}") + value = self.textures[value] + textures[name] = value + return textures + + @model_validator(mode="after") + def _set_default_gui_light(self): + self._was_gui_light_set = cast_nullable(self.gui_light) is not None + if not self._was_gui_light_set: + self.gui_light = "side" + return self + + +class ItemOverride(HexdocModel): + """An item model override case. + + https://minecraft.wiki/w/Tutorials/Models#Item_models + """ + + model: ResourceLocation + """The path to the model to use if the case is met.""" + predicate: dict[ResourceLocation, float] + """Item predicates that must be true for this model to be used.""" + + +class BuiltInModelType(Enum): + GENERATED = ResourceLocation("minecraft", "builtin/generated") + ENTITY = ResourceLocation("minecraft", "builtin/entity") + + @classmethod + def get(cls, id: ResourceLocation): + try: + return cls(id) + except ValueError: + return None diff --git a/src/hexdoc/minecraft/models/blockstate.py b/src/hexdoc/graphics/model/blockstate.py similarity index 98% rename from src/hexdoc/minecraft/models/blockstate.py rename to src/hexdoc/graphics/model/blockstate.py index a80125898..6ad337e84 100644 --- a/src/hexdoc/minecraft/models/blockstate.py +++ b/src/hexdoc/graphics/model/blockstate.py @@ -6,8 +6,7 @@ from pydantic import AfterValidator, BeforeValidator, ConfigDict, model_validator from hexdoc.core import ResourceLocation -from hexdoc.model import HexdocModel -from hexdoc.model.base import DEFAULT_CONFIG +from hexdoc.model import DEFAULT_CONFIG, HexdocModel class Blockstate(HexdocModel): diff --git a/src/hexdoc/graphics/model/display.py b/src/hexdoc/graphics/model/display.py new file mode 100644 index 000000000..938a28186 --- /dev/null +++ b/src/hexdoc/graphics/model/display.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import math +from typing import Annotated, Literal + +from pydantic import Field + +from hexdoc.model import HexdocModel +from hexdoc.utils.types import Vec3, clamped + +DisplayPositionName = Literal[ + "thirdperson_righthand", + "thirdperson_lefthand", + "firstperson_righthand", + "firstperson_lefthand", + "gui", + "head", + "ground", + "fixed", +] +"""`fixed` refers to item frames, while the rest are as their name states.""" + + +class DisplayPosition(HexdocModel): + """Place where an item model is displayed. Holds its rotation, translation and scale + for the specified situation. + + Note that translations are applied to the model before rotations. + + If this is specified but not all of translation, rotation and scale are in it, the + others aren't inherited from the parent. (TODO: is this only for items? see wiki) + + https://minecraft.wiki/w/Tutorials/Models + """ + + rotation: Vec3 = (0, 0, 0) + """Specifies the rotation of the model according to the scheme [x, y, z].""" + translation: Vec3[Annotated[float, clamped(-80, 80)]] = (0, 0, 0) + """Specifies the position of the model according to the scheme [x, y, z]. + + The values are clamped between -80 and 80. + """ + scale: Vec3[Annotated[float, clamped(None, 4), Field(ge=0)]] = Field((0, 0, 0)) + """Specifies the scale of the model according to the scheme [x, y, z]. + + If the value is greater than 4, it is displayed as 4. + """ + + @property + def eulers(self) -> Vec3: + """Euler rotation vector, in radians.""" + rotation = tuple(math.radians(v) for v in self.rotation) + assert len(rotation) == 3 + return rotation diff --git a/src/hexdoc/minecraft/models/base_model.py b/src/hexdoc/graphics/model/element.py similarity index 54% rename from src/hexdoc/minecraft/models/base_model.py rename to src/hexdoc/graphics/model/element.py index f6ec9f1f3..384920d28 100644 --- a/src/hexdoc/minecraft/models/base_model.py +++ b/src/hexdoc/graphics/model/element.py @@ -1,133 +1,20 @@ from __future__ import annotations +import logging import math import re -from abc import ABC, abstractmethod -from typing import Annotated, Literal, Self +from enum import StrEnum +from typing import Annotated, Literal -from pydantic import AfterValidator, Field, model_validator +from pydantic import AfterValidator, Field -from hexdoc.core import ResourceLocation -from hexdoc.model import HexdocModel -from hexdoc.model.base import IGNORE_EXTRA_CONFIG +from hexdoc.model import IGNORE_EXTRA_CONFIG, HexdocModel from hexdoc.utils.types import Vec3, Vec4, clamped +logger = logging.getLogger(__name__) -class BaseMinecraftModel(HexdocModel, ABC): - """Base class for Minecraft block/item models. - https://minecraft.wiki/w/Tutorials/Models - """ - - model_config = IGNORE_EXTRA_CONFIG - - parent: ResourceLocation | None = None - """Loads a different model from the given path, in form of a resource location. - - If both "parent" and "elements" are set, the "elements" tag overrides the "elements" - tag from the previous model. - """ - display: dict[DisplayPositionName, DisplayPosition] = Field(default_factory=dict) - """Holds the different places where item models are displayed. - - `fixed` refers to item frames, while the rest are as their name states. - """ - textures: dict[str, TextureVariable | ResourceLocation] = Field( - default_factory=dict - ) - """Holds the textures of the model, in form of a resource location or can be another - texture variable.""" - elements: list[ModelElement] | None = None - """Contains all the elements of the model. They can have only cubic forms. - - If both "parent" and "elements" are set, the "elements" tag overrides the "elements" - tag from the previous model. - """ - gui_light: Literal["front", "side"] = Field(None, validate_default=False) # type: ignore - """If set to `side` (default), the model is rendered like a block. - - If set to `front`, model is shaded like a flat item. - - Note: although the wiki only lists this field for item models, Minecraft sets it in - the models `minecraft:block/block` and `minecraft:block/calibrated_sculk_sensor`. - """ - - @abstractmethod - def apply_parent(self, parent: Self): - """Merge the parent model into this one.""" - self.parent = parent.parent - # prefer current display/textures over parent - self.display = parent.display | self.display - self.textures = parent.textures | self.textures - # only use parent elements if current model doesn't have elements - if self.elements is None: - self.elements = parent.elements - if not self._was_gui_light_set: - self.gui_light = parent.gui_light - - @model_validator(mode="after") - def _set_default_gui_light(self): - self._was_gui_light_set = self.gui_light is not None # type: ignore - if not self._was_gui_light_set: - self.gui_light = "side" - return self - - -def _validate_texture_variable(value: str): - assert re.fullmatch(r"#\w+", value) - return value - - -TextureVariable = Annotated[str, AfterValidator(_validate_texture_variable)] - - -DisplayPositionName = Literal[ - "thirdperson_righthand", - "thirdperson_lefthand", - "firstperson_righthand", - "firstperson_lefthand", - "gui", - "head", - "ground", - "fixed", -] -"""`fixed` refers to item frames, while the rest are as their name states.""" - - -class DisplayPosition(HexdocModel): - """Place where an item model is displayed. Holds its rotation, translation and scale - for the specified situation. - - Note that translations are applied to the model before rotations. - - If this is specified but not all of translation, rotation and scale are in it, the - others aren't inherited from the parent. (TODO: is this only for items? see wiki) - - https://minecraft.wiki/w/Tutorials/Models - """ - - rotation: Vec3 = (0, 0, 0) - """Specifies the rotation of the model according to the scheme [x, y, z].""" - translation: Vec3[Annotated[float, clamped(-80, 80)]] = (0, 0, 0) - """Specifies the position of the model according to the scheme [x, y, z]. - - The values are clamped between -80 and 80. - """ - scale: Vec3[Annotated[float, clamped(None, 4), Field(ge=0)]] = Field((0, 0, 0)) - """Specifies the scale of the model according to the scheme [x, y, z]. - - If the value is greater than 4, it is displayed as 4. - """ - - @property - def eulers(self) -> Vec3: - """Euler rotation vector, in radians.""" - rotation = tuple(math.radians(v) for v in self.rotation) - assert len(rotation) == 3 - return rotation - - -class ModelElement(HexdocModel): +class Element(HexdocModel): """An element of a block/item model. Must be cubic. https://minecraft.wiki/w/Tutorials/Models @@ -187,7 +74,13 @@ def eulers(self) -> Vec3: return (0, 0, angle) -FaceName = Literal["down", "up", "north", "south", "west", "east"] +class FaceName(StrEnum): + down = "down" + up = "up" + north = "north" + south = "south" + west = "west" + east = "east" class ElementFace(HexdocModel): @@ -208,7 +101,7 @@ class ElementFace(HexdocModel): UV is optional, and if not supplied it automatically generates based on the element's position. """ - texture: TextureVariable + texture: ElementFaceTextureVariable """Specifies the texture in form of the texture variable prepended with a #.""" cullface: FaceName | None = None """Specifies whether a face does not need to be rendered when there is a block @@ -256,23 +149,23 @@ class ElementFaceUV(HexdocModel): rotation: Literal[0, 90, 180, 270] = 0 @classmethod - def default(cls, element: ModelElement, direction: FaceName): + def default(cls, element: Element, direction: FaceName): x1, y1, z1 = element.from_ x2, y2, z2 = element.to uvs: Vec4 match direction: - case "down": + case FaceName.down: uvs = (x1, 16 - z2, x2, 16 - z1) - case "up": + case FaceName.up: uvs = (x1, z1, x2, z2) - case "north": + case FaceName.north: uvs = (16 - x2, 16 - y2, 16 - x1, 16 - y1) - case "south": + case FaceName.south: uvs = (x1, 16 - y2, x2, 16 - y1) - case "west": + case FaceName.west: uvs = (z1, 16 - y2, z2, 16 - y1) - case "east": + case FaceName.east: uvs = (16 - z2, 16 - y2, 16 - z1, 16 - y1) return cls(uvs=uvs) @@ -294,5 +187,31 @@ def _get_shifted_index(self, index: Literal[0, 1, 2, 3]): return (index + self.rotation // 90) % 4 -# this is required to ensure BlockModel and ItemModel are fully defined -BaseMinecraftModel.model_rebuild() +def _validate_texture_variable(value: str): + if not re.fullmatch(r"#\w+", value): + raise ValueError( + f"Malformed texture variable, expected `#` followed by at least 1 word character (^#\\w+$): {value}" + ) + return value + + +TextureVariable = Annotated[str, AfterValidator(_validate_texture_variable)] + + +def _validate_element_face_texture_variable(value: str): + # the minecraft:block/heavy_core model in 1.21.0 doesn't use # for its texture variables ??? + # https://bugs.mojang.com/browse/MC-270059 + # TODO: this is not a very useful error message since it doesn't say what model it's from + if re.fullmatch(r"\w+", value): + logger.warning( + f"Malformed texture variable, expected to start with `#`: {value}" + ) + value = "#" + value + return value + + +ElementFaceTextureVariable = Annotated[ + str, + AfterValidator(_validate_element_face_texture_variable), + AfterValidator(_validate_texture_variable), +] diff --git a/src/hexdoc/graphics/render.py b/src/hexdoc/graphics/render.py deleted file mode 100644 index 857eb341d..000000000 --- a/src/hexdoc/graphics/render.py +++ /dev/null @@ -1,575 +0,0 @@ -# pyright: reportUnknownMemberType=false - -from __future__ import annotations - -import logging -import math -from dataclasses import dataclass -from enum import Flag, auto -from functools import cached_property -from pathlib import Path -from typing import Any, Literal, cast - -import importlib_resources as resources -import moderngl as mgl -import moderngl_window as mglw -import numpy as np -from moderngl import Context, Program, Uniform -from moderngl_window import WindowConfig -from moderngl_window.context.headless import Window as HeadlessWindow -from moderngl_window.opengl.vao import VAO -from PIL import Image -from pydantic import ValidationError -from pyrr import Matrix44 - -from hexdoc.core import ModResourceLoader, ResourceLocation -from hexdoc.graphics import glsl -from hexdoc.minecraft.assets import AnimationMeta -from hexdoc.minecraft.assets.animated import AnimationMetaTag -from hexdoc.minecraft.models import BlockModel -from hexdoc.minecraft.models.base_model import ( - DisplayPosition, - ElementFace, - ElementFaceUV, - FaceName, - ModelElement, -) -from hexdoc.utils.types import Vec3, Vec4 - -logger = logging.getLogger(__name__) - - -# https://minecraft.wiki/w/Help:Isometric_renders#Preferences -LIGHT_TOP = 0.98 -LIGHT_LEFT = 0.8 -LIGHT_RIGHT = 0.608 - -LIGHT_FLAT = 0.98 - - -class DebugType(Flag): - NONE = 0 - AXES = auto() - NORMALS = auto() - - -@dataclass(kw_only=True) -class BlockRenderer: - loader: ModResourceLoader - output_dir: Path | None = None - debug: DebugType = DebugType.NONE - - def __post_init__(self): - self.window = HeadlessWindow( - size=(300, 300), - ) - mglw.activate_context(self.window) - - self.config = BlockRendererConfig(ctx=self.window.ctx, wnd=self.window) - - self.window.config = self.config - self.window.swap_buffers() - self.window.set_default_viewport() - - @property - def ctx(self): - return self.window.ctx - - def render_block_model( - self, - model: BlockModel | ResourceLocation, - output_path: str | Path, - ): - if isinstance(model, ResourceLocation): - _, model = self.loader.load_resource( - type="assets", - folder="models", - id=model, - decode=BlockModel.model_validate_json, - ) - - model.load_parents_and_apply(self.loader) - - textures = { - name: self.load_texture(texture_id) - for name, texture_id in model.resolve_texture_variables().items() - } - - output_path = Path(output_path) - if self.output_dir and not output_path.is_absolute(): - output_path = self.output_dir / output_path - - self.config.render_block(model, textures, output_path, self.debug) - - def load_texture(self, texture_id: ResourceLocation): - logger.debug(f"Loading texture: {texture_id}") - _, path = self.loader.find_resource("assets", "textures", texture_id + ".png") - - meta_path = path.with_suffix(".png.mcmeta") - if meta_path.is_file(): - logger.debug(f"Loading animation mcmeta: {meta_path}") - # FIXME: hack - try: - meta = AnimationMeta.model_validate_json(meta_path.read_bytes()) - except ValidationError as e: - logger.warning(f"Failed to parse animation meta for {texture_id}:\n{e}") - meta = None - else: - meta = None - - return BlockTextureInfo(path, meta) - - def destroy(self): - self.window.destroy() - - def __enter__(self): - return self - - def __exit__(self, *_: Any): - self.destroy() - return False - - -class BlockRendererConfig(WindowConfig): - def __init__(self, ctx: Context, wnd: HeadlessWindow): - super().__init__(ctx, wnd) - - # depth test: ensure faces are displayed in the correct order - # blend: handle translucency - # cull face: remove back faces, eg. for trapdoors - self.ctx.enable(mgl.DEPTH_TEST | mgl.BLEND | mgl.CULL_FACE) - - view_size = 16 - self.projection = Matrix44.orthogonal_projection( - left=-view_size / 2, - right=view_size / 2, - top=view_size / 2, - bottom=-view_size / 2, - near=0.01, - far=20_000, - dtype="f4", - ) * Matrix44.from_scale((1, -1, 1), "f4") - - self.camera, self.eye = direction_camera(pos="south") - - self.lights = [ - ((0, -1, 0), LIGHT_TOP), - ((1, 0, -1), LIGHT_LEFT), - ((-1, 0, -1), LIGHT_RIGHT), - ] - - # block faces - - self.face_prog = self.ctx.program( - vertex_shader=read_shader("block_face", "vertex"), - fragment_shader=read_shader("block_face", "fragment"), - ) - - self.uniform("m_proj").write(self.projection) - self.uniform("m_camera").write(self.camera) - self.uniform("layer").value = 0 # TODO: implement animations - - for i, (direction, diffuse) in enumerate(self.lights): - self.uniform(f"lights[{i}].direction").value = direction - self.uniform(f"lights[{i}].diffuse").value = diffuse - - # axis planes - - self.debug_plane_prog = self.ctx.program( - vertex_shader=read_shader("debug/plane", "vertex"), - fragment_shader=read_shader("debug/plane", "fragment"), - ) - - self.uniform("m_proj", self.debug_plane_prog).write(self.projection) - self.uniform("m_camera", self.debug_plane_prog).write(self.camera) - self.uniform("m_model", self.debug_plane_prog).write(Matrix44.identity("f4")) - - self.debug_axes = list[tuple[VAO, Vec4]]() - - pos = 8 - neg = 0 - for from_, to, color, direction in [ - ((0, neg, neg), (0, pos, pos), (1, 0, 0, 0.75), "east"), - ((neg, 0, neg), (pos, 0, pos), (0, 1, 0, 0.75), "up"), - ((neg, neg, 0), (pos, pos, 0), (0, 0, 1, 0.75), "south"), - ]: - vao = VAO() - verts = get_face_verts(from_, to, direction) - vao.buffer(np.array(verts, np.float32), "3f", ["in_position"]) - self.debug_axes.append((vao, color)) - - # vertex normal vectors - - self.debug_normal_prog = self.ctx.program( - vertex_shader=read_shader("debug/normal", "vertex"), - geometry_shader=read_shader("debug/normal", "geometry"), - fragment_shader=read_shader("debug/normal", "fragment"), - ) - - self.uniform("m_proj", self.debug_normal_prog).write(self.projection) - self.uniform("m_camera", self.debug_normal_prog).write(self.camera) - self.uniform("lineSize", self.debug_normal_prog).value = 4 - - self.ctx.line_width = 3 - - def render_block( - self, - model: BlockModel, - texture_vars: dict[str, BlockTextureInfo], - output_path: Path, - debug: DebugType = DebugType.NONE, - ): - if not model.elements: - raise ValueError("Unable to render model, no elements found") - - self.wnd.clear() - - # enable/disable flat item lighting - match model.gui_light: - case "front": - flatLighting = LIGHT_FLAT - case "side": - flatLighting = 0 - self.uniform("flatLighting").value = flatLighting - - # load textures - texture_locs = dict[str, int]() - transparent_textures = set[str]() - - for i, (name, info) in enumerate(texture_vars.items()): - texture_locs[name] = i - - logger.debug(f"Loading texture {name}: {info}") - image = Image.open(info.image_path).convert("RGBA") - - extrema = image.getextrema() - assert len(extrema) >= 4, f"Expected 4 bands but got {len(extrema)}" - min_alpha, _ = extrema[3] - if min_alpha < 255: - logger.debug(f"Transparent texture: {name} ({min_alpha=})") - transparent_textures.add(name) - - # TODO: implement non-square animations, write test cases - match info.meta: - case AnimationMeta( - animation=AnimationMetaTag(height=frame_height), - ) if frame_height: - # animated with specified size - layers = image.height // frame_height - case AnimationMeta(): - # size is unspecified, assume it's square and verify later - frame_height = image.width - layers = image.height // frame_height - case None: - # non-animated - frame_height = image.height - layers = 1 - - if frame_height * layers != image.height: - raise RuntimeError( - f"Invalid texture size for variable #{name}:" - + f" {frame_height}x{layers} != {image.height}" - + f"\n {info}" - ) - - logger.debug(f"Texture array: {image.width=}, {frame_height=}, {layers=}") - texture = self.ctx.texture_array( - size=(image.width, frame_height, layers), - components=4, - data=image.tobytes(), - ) - texture.filter = (mgl.NEAREST, mgl.NEAREST) - texture.use(i) - - # transform entire model - - gui = model.display.get("gui") or DisplayPosition( - rotation=(30, 225, 0), - translation=(0, 0, 0), - scale=(0.625, 0.625, 0.625), - ) - - model_transform = cast( - Matrix44, - Matrix44.from_scale(gui.scale, "f4") - * get_rotation_matrix(gui.eulers) - * Matrix44.from_translation(gui.translation, "f4") - * Matrix44.from_translation((-8, -8, -8), "f4"), - ) - - normals_transform = Matrix44.from_y_rotation(-gui.eulers[1], "f4") - self.uniform("m_normals").write(normals_transform) - - # render elements - - baked_faces = list[BakedFace]() - - for element in model.elements: - element_transform = model_transform.copy() - - # TODO: rescale?? - if rotation := element.rotation: - origin = np.array(rotation.origin) - element_transform *= cast( - Matrix44, - Matrix44.from_translation(origin, "f4") - * get_rotation_matrix(rotation.eulers) - * Matrix44.from_translation(-origin, "f4"), - ) - - # prepare each face of the element for rendering - for direction, face in element.faces.items(): - baked_face = BakedFace( - element=element, - direction=direction, - face=face, - m_model=element_transform, - texture0=texture_locs[face.texture_name], - is_opaque=face.texture_name not in transparent_textures, - ) - baked_faces.append(baked_face) - - # TODO: use a map if this is actually slow - baked_faces.sort(key=lambda face: face.sortkey(self.eye)) - - for face in baked_faces: - self.uniform("m_model").write(face.m_model) - self.uniform("texture0").value = face.texture0 - - face.vao.render(self.face_prog) - - if DebugType.NORMALS in debug: - self.uniform("m_model", self.debug_normal_prog).write(face.m_model) - face.vao.render(self.debug_normal_prog) - - if DebugType.AXES in debug: - self.ctx.disable(mgl.CULL_FACE) - for axis, color in self.debug_axes: - self.uniform("color", self.debug_plane_prog).value = color - axis.render(self.debug_plane_prog) - self.ctx.enable(mgl.CULL_FACE) - - self.ctx.finish() - - # save to file - - image = Image.frombytes( - mode="RGBA", - size=self.wnd.fbo.size, - data=self.wnd.fbo.read(components=4), - ).transpose(Image.Transpose.FLIP_TOP_BOTTOM) - - output_path.parent.mkdir(parents=True, exist_ok=True) - image.save(output_path, format="png") - - def uniform(self, name: str, program: Program | None = None): - program = program or self.face_prog - assert isinstance(uniform := program[name], Uniform) - return uniform - - -@dataclass -class BlockTextureInfo: - image_path: Path - meta: AnimationMeta | None - - -@dataclass(kw_only=True) -class BakedFace: - element: ModelElement - direction: FaceName - face: ElementFace - m_model: Matrix44 - texture0: float - is_opaque: bool - - def __post_init__(self): - self.verts = get_face_verts(self.element.from_, self.element.to, self.direction) - - self.normals = get_face_normals(self.direction) - - face_uv = self.face.uv or ElementFaceUV.default(self.element, self.direction) - self.uvs = [ - value - for index in get_face_uv_indices(self.direction) - for value in face_uv.get_uv(index) - ] - - self.vao = VAO() - self.vao.buffer(np.array(self.verts, np.float32), "3f", ["in_position"]) - self.vao.buffer(np.array(self.normals, np.float32), "3f", ["in_normal"]) - self.vao.buffer(np.array(self.uvs, np.float32) / 16, "2f", ["in_texcoord_0"]) - - @cached_property - def position(self): - x, y, z, n = 0, 0, 0, 0 - for i in range(0, len(self.verts), 3): - x += self.verts[i] - y += self.verts[i + 1] - z += self.verts[i + 2] - n += 1 - return (x / n, y / n, z / n) - - def sortkey(self, eye: Vec3): - if self.is_opaque: - return 0 - return sum((a - b) ** 2 for a, b in zip(eye, self.position)) - - -def get_face_verts(from_: Vec3, to: Vec3, direction: FaceName): - x1, y1, z1 = from_ - x2, y2, z2 = to - - # fmt: off - match direction: - case "south": - return [ - x2, y1, z2, - x2, y2, z2, - x1, y1, z2, - x2, y2, z2, - x1, y2, z2, - x1, y1, z2, - ] - case "east": - return [ - x2, y1, z1, - x2, y2, z1, - x2, y1, z2, - x2, y2, z1, - x2, y2, z2, - x2, y1, z2, - ] - case "down": - return [ - x2, y1, z1, - x2, y1, z2, - x1, y1, z2, - x2, y1, z1, - x1, y1, z2, - x1, y1, z1, - ] - case "west": - return [ - x1, y1, z2, - x1, y2, z2, - x1, y2, z1, - x1, y1, z2, - x1, y2, z1, - x1, y1, z1, - ] - case "north": - return [ - x2, y2, z1, - x2, y1, z1, - x1, y1, z1, - x2, y2, z1, - x1, y1, z1, - x1, y2, z1, - ] - case "up": - return [ - x2, y2, z1, - x1, y2, z1, - x2, y2, z2, - x1, y2, z1, - x1, y2, z2, - x2, y2, z2, - ] - # fmt: on - - -def get_face_normals(direction: FaceName): - return 6 * get_direction_vec(direction) - - -def get_face_uv_indices(direction: FaceName): - match direction: - case "south": - return (2, 3, 1, 3, 0, 1) - case "east": - return (2, 3, 1, 3, 0, 1) - case "down": - return (2, 3, 0, 2, 0, 1) - case "west": - return (2, 3, 0, 2, 0, 1) - case "north": - return (0, 1, 2, 0, 2, 3) - case "up": - return (3, 0, 2, 0, 1, 2) - - -def orbit_camera(pitch: float, yaw: float): - """Both values are in degrees.""" - - eye = transform_vec( - (-64, 0, 0), - cast( - Matrix44, - Matrix44.identity(dtype="f4") - * Matrix44.from_y_rotation(math.radians(yaw)) - * Matrix44.from_z_rotation(math.radians(pitch)), - ), - ) - - up = transform_vec( - (-1, 0, 0), - cast( - Matrix44, - Matrix44.identity(dtype="f4") - * Matrix44.from_y_rotation(math.radians(yaw)) - * Matrix44.from_z_rotation(math.radians(90 - pitch)), - ), - ) - - return Matrix44.look_at( - eye=eye, - target=(0, 0, 0), - up=up, - dtype="f4", - ), eye - - -def transform_vec(vec: Vec3, matrix: Matrix44) -> Vec3: - return np.matmul((*vec, 1), matrix, dtype="f4")[:3] - - -def direction_camera(pos: FaceName, up: FaceName = "up"): - """eg. north -> camera is placed to the north of the model, looking south""" - eye = get_direction_vec(pos, 64) - return Matrix44.look_at( - eye=eye, - target=(0, 0, 0), - up=get_direction_vec(up), - dtype="f4", - ), eye - - -def get_direction_vec(direction: FaceName, magnitude: float = 1): - match direction: - case "north": - return (0, 0, -magnitude) - case "south": - return (0, 0, magnitude) - case "west": - return (-magnitude, 0, 0) - case "east": - return (magnitude, 0, 0) - case "down": - return (0, -magnitude, 0) - case "up": - return (0, magnitude, 0) - - -def read_shader(path: str, type: Literal["fragment", "vertex", "geometry"]): - file = resources.files(glsl) / path / f"{type}.glsl" - return file.read_text("utf-8") - - -def get_rotation_matrix(eulers: Vec3) -> Matrix44: - return cast( - Matrix44, - Matrix44.from_x_rotation(-eulers[0], "f4") - * Matrix44.from_y_rotation(-eulers[1], "f4") - * Matrix44.from_z_rotation(-eulers[2], "f4"), - ) diff --git a/src/hexdoc/graphics/renderer.py b/src/hexdoc/graphics/renderer.py new file mode 100644 index 000000000..6189ad7c7 --- /dev/null +++ b/src/hexdoc/graphics/renderer.py @@ -0,0 +1,191 @@ +# pyright: reportUnknownMemberType=false + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum, auto +from pathlib import Path +from typing import Any + +import moderngl_window as mglw +from moderngl_window.context.headless import Window as HeadlessWindow +from PIL import Image +from PIL.Image import Resampling +from PIL.PngImagePlugin import Disposal as APNGDisposal + +from hexdoc.core import ModResourceLoader, ResourceLocation +from hexdoc.core.properties import AnimationFormat +from hexdoc.graphics.model.block import BuiltInModelType + +from .block import BlockRenderer +from .model import BlockModel +from .texture import ModelTexture +from .utils import DebugType + +logger = logging.getLogger(__name__) + + +# FIXME: I don't really like this - ideally we should check the image size and pixelate if it's small +class ImageType(Enum): + BLOCK = auto() + ITEM = auto() + UNKNOWN = auto() + + @property + def pixelated(self): + match self: + case ImageType.BLOCK: + return False + case ImageType.ITEM | ImageType.UNKNOWN: + return True + + +@dataclass(kw_only=True) +class ModelRenderer: + """Avoid creating multiple instances of this class - it seems to cause issues with + the OpenGL/ModernGL context.""" + + loader: ModResourceLoader + debug: DebugType = DebugType.NONE + block_size: int | None = None + item_size: int | None = None + + def __post_init__(self): + self.window = HeadlessWindow( + size=(self.block_size or 300,) * 2, + ) + mglw.activate_context(self.window) + + self.block_renderer = BlockRenderer(ctx=self.window.ctx, wnd=self.window) + + self.window.config = self.block_renderer + self.window.swap_buffers() + self.window.set_default_viewport() + + if self.texture_props.large_items: + self.item_size = self.item_size or 256 + + @property + def texture_props(self): + return self.loader.props.textures + + @property + def ctx(self): + return self.window.ctx + + def render_model( + self, + model: BlockModel | ResourceLocation, + output_path: str | Path, + ): + if isinstance(model, ResourceLocation): + _, model = BlockModel.load_only(self.loader, model) + + model.resolve(self.loader) + + match model.builtin_parent: + case BuiltInModelType.GENERATED: + frames = self._render_item(model) + image_type = ImageType.ITEM + case None: + frames = self._render_block(model) + image_type = ImageType.BLOCK + case builtin_type: + raise ValueError(f"Unsupported model parent id: {builtin_type.value}") + + return self.save_image(output_path, frames), image_type + + def save_image(self, output_path: str | Path, frames: list[Image.Image]): + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + match frames: + case []: + # TODO: awful error message. + raise ValueError("No frames rendered!") + case [image]: + image.save(output_path) + return output_path.suffix + case _: + return self._save_animation(output_path, frames) + + def _render_block(self, model: BlockModel): + textures = { + name: ModelTexture.load(self.loader, texture_id) + for name, texture_id in model.resolved_textures.items() + } + return self.block_renderer.render_block(model, textures, self.debug) + + def _render_item(self, model: BlockModel): + layers = sorted( + self._load_layers(model), + key=lambda tex: tex.layer_index, + ) + animation_length = max(len(layer.frames) for layer in layers) + return [ + self._render_item_frame(layers, animation_tick) + for animation_tick in range(animation_length) + ] + + def _render_item_frame(self, layers: list[ModelTexture], tick: int): + image = layers[0].get_frame(tick) + + for texture in layers[1:]: + layer = texture.get_frame(tick) + if layer.size != image.size: + raise ValueError( + f"Mismatched size for layer {texture.layer_index} at frame 0 " + + f"(expected {image.size}, got {layer.size})" + ) + image = Image.alpha_composite(image, layer) + + if self.item_size: + image = image.resize((self.item_size, self.item_size), Resampling.NEAREST) + + return image + + def _load_layers(self, model: BlockModel): + for name, texture_id in model.resolved_textures.items(): + if not name.startswith("layer"): + continue + + index = name.removeprefix("layer") + if not index.isnumeric(): + continue + + texture = ModelTexture.load(self.loader, texture_id) + texture.layer_index = int(index) + yield texture + + def _save_animation(self, output_path: Path, frames: list[Image.Image]): + kwargs: dict[str, Any] + match output_format := self.texture_props.animated.format: + case AnimationFormat.APNG: + kwargs = dict( + disposal=APNGDisposal.OP_BACKGROUND, + ) + case AnimationFormat.GIF: + kwargs = dict( + loop=0, # loop forever + disposal=2, # restore to background color + ) + + frames[0].save( + output_path.with_suffix(output_format.suffix), + save_all=True, + append_images=frames[1:], + duration=1000 / 20, + **kwargs, + ) + return output_format.suffix + + def destroy(self): + self.window.destroy() + + def __enter__(self): + return self + + def __exit__(self, *_: Any): + self.destroy() + return False diff --git a/src/hexdoc/graphics/texture.py b/src/hexdoc/graphics/texture.py new file mode 100644 index 000000000..9ea4b2044 --- /dev/null +++ b/src/hexdoc/graphics/texture.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass +from functools import cached_property +from pathlib import Path +from typing import Iterator + +import numpy as np +from PIL import Image +from pydantic import ValidationError + +from hexdoc.core import ModResourceLoader, ResourceLocation +from hexdoc.core.properties import AnimatedTexturesProps +from hexdoc.utils import listify +from hexdoc.utils.logging import TRACE + +from .model import Animation, AnimationFrame, AnimationMeta + +logger = logging.getLogger(__name__) + + +# TODO: maybe put this in some system in ModResourceLoader? (see weakref.finalize) +# then we could clear the cache and free up memory when the loader is closed +_TEXTURE_CACHE: dict[ResourceLocation | Path, ModelTexture] = {} + + +@dataclass(kw_only=True) +class ModelTexture: + texture_id: ResourceLocation | Path + image: Image.Image + animation: Animation | None + layer_index: int = -1 + props: AnimatedTexturesProps + + @classmethod + def load(cls, loader: ModResourceLoader, texture_id: ResourceLocation | Path): + if cached := _TEXTURE_CACHE.get(texture_id): + logger.log(TRACE, f"Cache hit: {texture_id}") + return cached + + match texture_id: + case ResourceLocation(): + _, path = loader.find_resource( + "assets", + "textures", + texture_id + ".png", + ) + logger.debug(f"Loading texture {texture_id}: {path}") + case Path() as path: + logger.debug(f"Loading texture: {texture_id}") + + texture = cls( + texture_id=texture_id, + image=Image.open(path).convert("RGBA"), + animation=cls._load_animation(path.with_suffix(".png.mcmeta")), + props=loader.props.textures.animated, + ) + _TEXTURE_CACHE[texture_id] = texture + return texture + + @classmethod + def _load_animation(cls, path: Path): + if not path.is_file(): + return None + + logger.debug(f"Loading animation mcmeta: {path}") + try: + meta = AnimationMeta.model_validate_json(path.read_bytes()) + return meta.animation + except ValidationError as e: + # FIXME: hack + logger.warning(f"Failed to parse animation meta ({path}):\n{e}") + return None + + @cached_property + def frame_height(self): + match self.animation: + case Animation(height=int()): + raise NotImplementedError() + case Animation() | None: + return self.image.width + + @cached_property + def _frame_count(self): + """Number of sub-images within `self.image`. + + Not necessarily equal to `len(self.frames)`! + """ + count = self.image.height / self.frame_height + if not count.is_integer() or count < 1: + raise ValueError( + f"Invalid image dimensions (got {count=}):" + + f"width={self.image.width}, height={self.image.height}," + + f" frame_height={self.frame_height}" + ) + return int(count) + + def get_frame_index(self, tick: int): + if tick < 0: + raise ValueError(f"Expected tick >= 0, got {tick}") + return tick % len(self.frames) + + def get_frame(self, tick: int): + return self.frames[self.get_frame_index(tick)] + + @cached_property + @listify + def frames(self) -> Iterator[Image.Image]: + """Returns a list of animation frames, where each frame lasts for one tick. + + If `animation` is None, just returns `[image]`. + """ + if self.animation is None: + yield self.image + return + + # TODO: implement width/height + + frames = self.animation.frames or [ + AnimationFrame(index=i, time=self.animation.frametime) + for i in range(self._frame_count) + ] + + if not self.props.enabled: + yield self._get_frame_image(frames[0]) + return + + images = [self._get_frame_image(frame) for frame in frames] + + frame_time_multiplier = 1 + total_frames = sum(frame.time for frame in frames) + if self.props.max_frames > 0 and total_frames > self.props.max_frames: + logger.warning( + f"Animation for texture {self.texture_id} is too long, dropping about" + + f" {total_frames - self.props.max_frames} frames" + + f" ({total_frames} > {self.props.max_frames})" + ) + frame_time_multiplier = self.props.max_frames / total_frames + + frame_lerps: list[tuple[int, float]] = [ + (frame_idx, time / frame.time if self.animation.interpolate else 0) + for frame_idx, frame in enumerate(frames) + for time in np.linspace( + 0, + frame.time - 1, + max(1, math.floor(frame.time * frame_time_multiplier)), + ) + ] + + for i, lerp in frame_lerps: + j = (i + 1) % len(images) + yield Image.blend(images[i], images[j], lerp) + + def _get_frame_image(self, frame: AnimationFrame): + if frame.index >= self._frame_count: + raise ValueError( + f"Invalid frame index (expected <{self._frame_count}): {frame}" + ) + start_y = self.frame_height * frame.index + end_y = start_y + self.frame_height + return self.image.crop((0, start_y, self.image.width, end_y)) diff --git a/src/hexdoc/graphics/utils.py b/src/hexdoc/graphics/utils.py new file mode 100644 index 000000000..4abff0c92 --- /dev/null +++ b/src/hexdoc/graphics/utils.py @@ -0,0 +1,37 @@ +# pyright: reportUnknownMemberType=false + +from __future__ import annotations + +from enum import Flag, auto +from typing import Literal, cast + +import importlib_resources as resources +import numpy as np +from pyrr import Matrix44 + +from hexdoc.graphics import glsl +from hexdoc.utils.types import Vec3 + + +class DebugType(Flag): + NONE = 0 + AXES = auto() + NORMALS = auto() + + +def read_shader(path: str, type: Literal["fragment", "vertex", "geometry"]): + file = resources.files(glsl) / path / f"{type}.glsl" + return file.read_text("utf-8") + + +def transform_vec(vec: Vec3, matrix: Matrix44) -> Vec3: + return np.matmul((*vec, 1), matrix, dtype="f4")[:3] + + +def get_rotation_matrix(eulers: Vec3) -> Matrix44: + return cast( + Matrix44, + Matrix44.from_x_rotation(-eulers[0], "f4") + * Matrix44.from_y_rotation(-eulers[1], "f4") + * Matrix44.from_z_rotation(-eulers[2], "f4"), + ) diff --git a/src/hexdoc/graphics/validators.py b/src/hexdoc/graphics/validators.py new file mode 100644 index 000000000..c6287eca8 --- /dev/null +++ b/src/hexdoc/graphics/validators.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Annotated, Any + +from pydantic import ( + Field, + PrivateAttr, + SkipValidation, + TypeAdapter, + ValidationError, + ValidationInfo, + field_validator, + model_validator, +) +from typing_extensions import TypeVar, override +from yarl import URL + +from hexdoc.core import I18n, ItemStack, LocalizedStr, Properties, ResourceLocation +from hexdoc.core.i18n import LocalizedItem +from hexdoc.model import ( + InlineItemModel, + InlineModel, + TemplateModel, + UnionModel, +) +from hexdoc.model.types import MustBeAnnotated +from hexdoc.plugin import PluginManager +from hexdoc.utils import ( + ContextSource, + Inherit, + InheritType, + PydanticURL, + cast_context, + classproperty, +) + +from .loader import MISSING_TEXTURE_ID, TAG_TEXTURE_ID, ImageLoader +from .model import BlockModel + +logger = logging.getLogger(__name__) + +_T = TypeVar("_T") + + +class _ImageFieldType: + def __class_getitem__(cls, item: Any) -> Any: + return Annotated[item | MissingImage, cls] + + +# scuffed, but Pydantic did it first +# see: pydantic.functional_validators.SkipValidation +if TYPE_CHECKING: + ImageField = Annotated["_T | MissingImage", _ImageFieldType] +else: + + class ImageField(_ImageFieldType): + pass + + +class HexdocImage(TemplateModel, MustBeAnnotated, ABC, annotation=ImageField): + """An image that can be rendered in a hexdoc web book.""" + + id: ResourceLocation + + _name: LocalizedStr = PrivateAttr() + + # change default from None to Inherit + def __init_subclass__( + cls, + *, + template_id: str | ResourceLocation | InheritType | None = Inherit, + **kwargs: Any, + ): + super().__init_subclass__(template_id=template_id, **kwargs) + + @classproperty + @classmethod + @override + def template(cls): + return cls.template_id.template_path("images") + + @property + def name(self): + return self._name + + @property + @abstractmethod + def first_url(self) -> URL: ... + + @abstractmethod + def _get_name(self, info: ValidationInfo) -> LocalizedStr: ... + + @model_validator(mode="after") + def _set_name(self, info: ValidationInfo): + try: + self._name = self._get_name(info) + except ValidationError as e: + logger.debug(f"Failed to get name for {self.__class__}: {e}") + self._name = LocalizedStr.with_value(str(self.id)) + return self + + +class URLImage(HexdocImage, template_id="hexdoc:single"): + url: PydanticURL + pixelated: bool = True + + @property + @override + def first_url(self): + return self.url + + +class TextureImage(URLImage, InlineModel): + @override + @classmethod + def load_id(cls, id: ResourceLocation, context: dict[str, Any]) -> Any: + url = ImageLoader.of(context).render_texture(id) + return cls(id=id, url=url) + + @override + def _get_name(self, info: ValidationInfo): + return I18n.of(info).localize_texture(self.id) + + +class MissingImage(TextureImage, annotation=None): + @override + @classmethod + def load_id(cls, id: ResourceLocation, context: dict[str, Any]) -> Any: + if cls.should_raise(id, context): + raise ValueError(f"Failed to load image for id: {id}") + logger.warning(f"Using missing texture for id: {id}") + + url = ImageLoader.of(context).render_texture(MISSING_TEXTURE_ID) + return cls(id=id, url=url, pixelated=True) + + @override + def _get_name(self, info: ValidationInfo): + return LocalizedStr.with_value(str(self.id)) + + @classmethod + def should_raise(cls, id: ResourceLocation, context: ContextSource): + return not Properties.of(context).textures.can_be_missing(id) + + +class ItemImage(HexdocImage, InlineItemModel, UnionModel, ABC): + item: ItemStack + + @override + @classmethod + @abstractmethod + def load_id(cls, item: ItemStack, context: dict[str, Any]) -> Any: + pm = PluginManager.of(context) + return cls._resolve_union( + item, + context, + model_types=pm.item_image_types, + allow_ambiguous=True, + ) + + +class SingleItemImage(URLImage, ItemImage): + model: BlockModel | None + + @override + @classmethod + def load_id(cls, item: ItemStack, context: dict[str, Any]) -> Any: + result = ImageLoader.of(context).render_item(item) + return cls( + id=item.id, + item=item, + url=result.url, + model=result.model, + pixelated=result.image_type.pixelated, + ) + + @override + def _get_name(self, info: ValidationInfo): + # TODO: i'm not sure if this is really the right place to put this + if (name := self.item.get_name()) is not None: + return LocalizedItem.with_value(name) + return I18n.of(info).localize_item(self.item) + + +class CyclingImage(HexdocImage, template_id="hexdoc:cycling"): + images: SkipValidation[list[HexdocImage]] = Field(min_length=1) + + @property + @override + def first_url(self): + return self.images[0].first_url + + @override + def _get_name(self, info: ValidationInfo): + return self.images[0].name + + +class TagImage(URLImage, InlineModel): + @override + @classmethod + def load_id(cls, id: ResourceLocation, context: dict[str, Any]) -> Any: + # TODO: load images for all the items in the tag? + url = ImageLoader.of(context).render_texture(TAG_TEXTURE_ID) + return cls(id=id, url=url) + + @override + def _get_name(self, info: ValidationInfo): + return I18n.of(info).localize_item_tag(self.id) + + @field_validator("id", mode="after") + @classmethod + def _validate_id(cls, id: ResourceLocation): + assert id.is_tag, f"Expected tag id, got {id}" + return id + + +def validate_image( + model_type: type[_T] | Any, + value: Any, + context: ContextSource, +) -> _T | MissingImage: + ta = TypeAdapter(ImageField[model_type]) + return ta.validate_python(value, context=cast_context(context)) diff --git a/src/hexdoc/jinja/__init__.py b/src/hexdoc/jinja/__init__.py index b73990190..49790c297 100644 --- a/src/hexdoc/jinja/__init__.py +++ b/src/hexdoc/jinja/__init__.py @@ -1,17 +1,17 @@ __all__ = [ "IncludeRawExtension", - "hexdoc_item", + "hexdoc_item_image", "hexdoc_localize", "hexdoc_smart_var", - "hexdoc_texture", + "hexdoc_texture_image", "hexdoc_wrap", ] from .extensions import IncludeRawExtension from .filters import ( - hexdoc_item, + hexdoc_item_image, hexdoc_localize, hexdoc_smart_var, - hexdoc_texture, + hexdoc_texture_image, hexdoc_wrap, ) diff --git a/src/hexdoc/jinja/filters.py b/src/hexdoc/jinja/filters.py index 8f7acf717..b5c9eace3 100644 --- a/src/hexdoc/jinja/filters.py +++ b/src/hexdoc/jinja/filters.py @@ -1,18 +1,14 @@ import functools -from typing import Any, Callable, ParamSpec, TypeVar, cast +from typing import Any, Callable, ParamSpec, TypeVar from jinja2 import pass_context from jinja2.runtime import Context from markupsafe import Markup -from hexdoc.core import Properties, ResourceLocation +from hexdoc.core import I18n, Properties, ResourceLocation from hexdoc.core.resource import ItemStack -from hexdoc.minecraft import I18n -from hexdoc.minecraft.assets import ( - ItemWithTexture, - PNGTexture, - validate_texture, -) +from hexdoc.graphics.validators import ItemImage, TextureImage, validate_image +from hexdoc.model.base import init_context from hexdoc.patchouli import FormatTree from hexdoc.plugin import PluginManager @@ -20,6 +16,16 @@ _R = TypeVar("_R") +def hexdoc_pass_context(f: Callable[_P, _R]) -> Callable[_P, _R]: + @functools.wraps(f) + @pass_context + def wrapper(*args: _P.args, **kwargs: _P.kwargs): + with init_context(args[0]): + return f(*args, **kwargs) + + return wrapper + + def make_jinja_exceptions_suck_a_bit_less(f: Callable[_P, _R]) -> Callable[_P, _R]: @functools.wraps(f) def wrapper(*args: _P.args, **kwargs: _P.kwargs): @@ -81,28 +87,16 @@ def hexdoc_localize( return formatted -# TODO: support the full texture lookup -@pass_context +@hexdoc_pass_context @make_jinja_exceptions_suck_a_bit_less -def hexdoc_texture(context: Context, id: str | ResourceLocation) -> str: - texture = validate_texture( - id, - context=context, - model_type=PNGTexture, - ) - return str(texture.url) +def hexdoc_texture_image(context: Context, id: str | ResourceLocation): + return validate_image(TextureImage, id, context) -@pass_context +@hexdoc_pass_context @make_jinja_exceptions_suck_a_bit_less -def hexdoc_item( - context: Context, - id: str | ResourceLocation | ItemStack, -) -> ItemWithTexture: - return ItemWithTexture.model_validate( - id, - context=cast(dict[str, Any], context), # lie - ) +def hexdoc_item_image(context: Context, id: str | ResourceLocation | ItemStack): + return validate_image(ItemImage, id, context) @pass_context diff --git a/src/hexdoc/jinja/render.py b/src/hexdoc/jinja/render.py index 07250a259..22db515df 100644 --- a/src/hexdoc/jinja/render.py +++ b/src/hexdoc/jinja/render.py @@ -18,19 +18,18 @@ ) from jinja2.sandbox import SandboxedEnvironment -from hexdoc.core import MinecraftVersion, Properties, ResourceLocation +from hexdoc.core import I18n, MinecraftVersion, Properties, ResourceLocation from hexdoc.core.properties import JINJA_NAMESPACE_ALIASES from hexdoc.data.sitemap import MARKER_NAME, LatestSitemapMarker, VersionedSitemapMarker -from hexdoc.minecraft import I18n from hexdoc.plugin import ModPluginWithBook, PluginManager from hexdoc.utils import ContextSource, write_to_path from .extensions import DefaultMacroExtension, IncludeRawExtension from .filters import ( - hexdoc_item, + hexdoc_item_image, hexdoc_localize, hexdoc_smart_var, - hexdoc_texture, + hexdoc_texture_image, hexdoc_wrap, ) @@ -107,8 +106,8 @@ def create_jinja_env_with_loader(loader: BaseLoader): env.filters |= { # pyright: ignore[reportAttributeAccessIssue] "hexdoc_wrap": hexdoc_wrap, "hexdoc_localize": hexdoc_localize, - "hexdoc_texture": hexdoc_texture, - "hexdoc_item": hexdoc_item, + "hexdoc_texture_image": hexdoc_texture_image, + "hexdoc_item_image": hexdoc_item_image, "hexdoc_smart_var": hexdoc_smart_var, } diff --git a/src/hexdoc/minecraft/__init__.py b/src/hexdoc/minecraft/__init__.py index 1583fd5ad..45c2d2e41 100644 --- a/src/hexdoc/minecraft/__init__.py +++ b/src/hexdoc/minecraft/__init__.py @@ -4,10 +4,11 @@ "LocalizedStr", "Tag", "TagValue", - "assets", "recipe", ] -from . import assets, recipe -from .i18n import I18n, LocalizedItem, LocalizedStr +# for backwards compatibility +from hexdoc.core.i18n import I18n, LocalizedItem, LocalizedStr + +from . import recipe from .tags import Tag, TagValue diff --git a/src/hexdoc/minecraft/assets/__init__.py b/src/hexdoc/minecraft/assets/__init__.py deleted file mode 100644 index c6b00dd01..000000000 --- a/src/hexdoc/minecraft/assets/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -__all__ = [ - "AnimatedTexture", - "AnimationMeta", - "HexdocAssetLoader", - "ImageTexture", - "ItemTexture", - "ItemWithTexture", - "ModelItem", - "MultiItemTexture", - "NamedTexture", - "PNGTexture", - "SingleItemTexture", - "TagWithTexture", - "Texture", - "TextureContext", - "TextureLookup", - "TextureLookups", - "validate_texture", -] - -from .animated import ( - AnimatedTexture, - AnimationMeta, -) -from .items import ( - ImageTexture, - ItemTexture, - MultiItemTexture, - SingleItemTexture, -) -from .load_assets import ( - HexdocAssetLoader, - Texture, - validate_texture, -) -from .models import ModelItem -from .textures import ( - PNGTexture, - TextureContext, - TextureLookup, - TextureLookups, -) -from .with_texture import ( - ItemWithTexture, - NamedTexture, - TagWithTexture, -) - -HexdocPythonResourceLoader = None -"""PLACEHOLDER - DO NOT USE - -This class has been removed from hexdoc, but this variable is required to fix an import -error with old versions of `hexdoc_minecraft`. -""" diff --git a/src/hexdoc/minecraft/assets/animated.py b/src/hexdoc/minecraft/assets/animated.py deleted file mode 100644 index fde43861b..000000000 --- a/src/hexdoc/minecraft/assets/animated.py +++ /dev/null @@ -1,97 +0,0 @@ -from functools import cached_property -from typing import Any, Literal, Self - -from pydantic import Field - -from hexdoc.model import HexdocModel -from hexdoc.utils.types import PydanticURL - -from .textures import BaseTexture - - -class AnimationMetaFrame(HexdocModel): - index: int | None = None - time: int | None = None - - -class AnimationMetaTag(HexdocModel): - interpolate: Literal[False] = False # TODO: handle interpolation - width: None = None # TODO: handle non-square textures - height: None = None - frametime: int = 1 - frames: list[int | AnimationMetaFrame] = Field(default_factory=list) - - -class AnimationMeta(HexdocModel): - animation: AnimationMetaTag - - -class AnimatedTextureFrame(HexdocModel): - index: int - start: int - time: int - animation_time: int - - @property - def start_percent(self): - return self._format_time(self.start) - - @property - def end_percent(self): - return self._format_time(self.start + self.time, backoff=True) - - def _format_time(self, time: int, *, backoff: bool = False) -> str: - percent = 100 * time / self.animation_time - if backoff and percent < 100: - percent -= 0.01 - return f"{percent:.2f}".rstrip("0").rstrip(".") - - -class AnimatedTexture(BaseTexture): - url: PydanticURL | None - pixelated: bool - css_class: str - meta: AnimationMeta - - @classmethod - def from_url(cls, *args: Any, **kwargs: Any) -> Self: - raise NotImplementedError("AnimatedTexture does not support from_url()") - - @property - def time_seconds(self): - return self.time / 20 - - @cached_property - def time(self): - return sum(time for _, time in self._normalized_frames) - - @property - def frames(self): - start = 0 - for index, time in self._normalized_frames: - yield AnimatedTextureFrame( - index=index, - start=start, - time=time, - animation_time=self.time, - ) - start += time - - @property - def _normalized_frames(self): - """index, time""" - animation = self.meta.animation - - for i, frame in enumerate(animation.frames): - match frame: - case int(index): - time = None - case AnimationMetaFrame(index=index, time=time): - pass - - if index is None: - index = i - if time is None: - time = animation.frametime - - yield index, time diff --git a/src/hexdoc/minecraft/assets/constants.py b/src/hexdoc/minecraft/assets/constants.py deleted file mode 100644 index 7a982fe76..000000000 --- a/src/hexdoc/minecraft/assets/constants.py +++ /dev/null @@ -1,13 +0,0 @@ -# 16x16 hashtag icon for tags -from yarl import URL - -TAG_TEXTURE_URL = URL( - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAC4jAAAuIwF4pT92AAAANUlEQVQ4y2NgGJRAXV39v7q6+n9cfGTARKllFBvAiOxMUjTevHmTkSouGPhAHA0DWnmBrgAANLIZgSXEQxIAAAAASUVORK5CYII=" -) - -# purple and black square -MISSING_TEXTURE_URL = URL( - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAJElEQVQoz2NkwAF+MPzAKs7EQCIY1UAMYMQV3hwMHKOhRD8NAPogBA/DVsDEAAAAAElFTkSuQmCC" -) - -NUM_GASLIGHTING_TEXTURES = 4 diff --git a/src/hexdoc/minecraft/assets/items.py b/src/hexdoc/minecraft/assets/items.py deleted file mode 100644 index 061f3a8ae..000000000 --- a/src/hexdoc/minecraft/assets/items.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from typing import Self - -from yarl import URL - -from hexdoc.core import ItemStack, ResourceLocation -from hexdoc.utils import ContextSource - -from .animated import AnimatedTexture -from .textures import BaseTexture, PNGTexture - -ImageTexture = PNGTexture | AnimatedTexture - - -# this needs to be a separate class, rather than just using ImageTexture directly, -# because the key in the lookup for SingleItemTexture is the item id, not the texture id -class SingleItemTexture(BaseTexture): - inner: ImageTexture - - @classmethod - def from_url(cls, url: URL, pixelated: bool) -> Self: - return cls( - inner=PNGTexture(url=url, pixelated=pixelated), - ) - - @classmethod - def load_id(cls, id: ResourceLocation | ItemStack, context: ContextSource): - return super().load_id(id.id, context) - - @property - def url(self): - return self.inner.url - - -class MultiItemTexture(BaseTexture): - inner: list[ImageTexture] - gaslighting: bool - - @classmethod - def from_url(cls, url: URL, pixelated: bool) -> Self: - return cls( - inner=[PNGTexture(url=url, pixelated=pixelated)], - gaslighting=False, - ) - - @classmethod - def load_id(cls, id: ResourceLocation | ItemStack, context: ContextSource): - return super().load_id(id.id, context) - - -ItemTexture = SingleItemTexture | MultiItemTexture diff --git a/src/hexdoc/minecraft/assets/load_assets.py b/src/hexdoc/minecraft/assets/load_assets.py deleted file mode 100644 index 3f372777f..000000000 --- a/src/hexdoc/minecraft/assets/load_assets.py +++ /dev/null @@ -1,319 +0,0 @@ -import logging -import textwrap -from collections.abc import Set -from dataclasses import dataclass -from functools import cached_property -from pathlib import Path -from typing import Any, Iterable, Iterator, TypeVar, cast - -from pydantic import TypeAdapter -from yarl import URL - -from hexdoc.core import ModResourceLoader, ResourceLocation -from hexdoc.core.properties import ( - PNGTextureOverride, - TextureTextureOverride, -) -from hexdoc.graphics.render import BlockRenderer -from hexdoc.utils import PydanticURL -from hexdoc.utils.context import ContextSource - -from ..tags import Tag -from .animated import AnimatedTexture, AnimationMeta -from .constants import MISSING_TEXTURE_URL -from .items import ( - ImageTexture, - ItemTexture, - MultiItemTexture, - SingleItemTexture, -) -from .models import FoundNormalTexture, ModelItem -from .textures import PNGTexture - -logger = logging.getLogger(__name__) - -Texture = ImageTexture | ItemTexture - -_T_Texture = TypeVar("_T_Texture", bound=Texture) - - -def validate_texture( - value: Any, - *, - context: ContextSource, - model_type: type[_T_Texture] | Any = Texture, -) -> _T_Texture: - ta = TypeAdapter(model_type) - return ta.validate_python( - value, - context=cast(dict[str, Any], context), # lie - ) - - -class TextureNotFoundError(FileNotFoundError): - def __init__(self, id_type: str, id: ResourceLocation): - self.message = f"No texture found for {id_type} id: {id}" - super().__init__(self.message) - - -@dataclass(kw_only=True) -class HexdocAssetLoader: - loader: ModResourceLoader - site_url: PydanticURL - asset_url: PydanticURL - render_dir: Path - - @cached_property - def gaslighting_items(self): - return Tag.GASLIGHTING_ITEMS.load(self.loader).value_ids_set - - @property - def texture_props(self): - return self.loader.props.textures - - def can_be_missing(self, id: ResourceLocation): - if self.texture_props.missing == "*": - return True - return any(id.match(pattern) for pattern in self.texture_props.missing) - - def get_override( - self, - id: ResourceLocation, - image_textures: dict[ResourceLocation, ImageTexture], - ) -> Texture | None: - match self.texture_props.override.get(id): - case PNGTextureOverride(url=url, pixelated=pixelated): - return PNGTexture(url=url, pixelated=pixelated) - case TextureTextureOverride(texture=texture): - return image_textures[texture] - case None: - return None - - def find_image_textures( - self, - ) -> Iterable[tuple[ResourceLocation, Path | ImageTexture]]: - for resource_dir, texture_id, path in self.loader.find_resources( - "assets", - namespace="*", - folder="textures", - glob="**/*.png", - internal_only=True, - allow_missing=True, - ): - if resource_dir: - self.loader.export_raw( - path=path.relative_to(resource_dir.path), - data=path.read_bytes(), - ) - yield texture_id, path - - def load_item_models(self) -> Iterable[tuple[ResourceLocation, ModelItem]]: - for _, item_id, data in self.loader.load_resources( - "assets", - namespace="*", - folder="models/item", - internal_only=True, - allow_missing=True, - ): - model = ModelItem.load_data("item" / item_id, data) - yield item_id, model - - @cached_property - def renderer(self): - return BlockRenderer( - loader=self.loader, - output_dir=self.render_dir, - ) - - def fallback_texture(self, item_id: ResourceLocation) -> ItemTexture | None: - return None - - def load_and_render_internal_textures( - self, - image_textures: dict[ResourceLocation, ImageTexture], - ) -> Iterator[tuple[ResourceLocation, Texture]]: - """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.""" - - # images - for texture_id, value in self.find_image_textures(): - if not texture_id.path.startswith("textures"): - texture_id = "textures" / texture_id - - match value: - case Path() as path: - texture = load_texture( - texture_id, - path=path, - repo_root=self.loader.props.repo_root, - asset_url=self.asset_url, - strict=self.texture_props.strict, - ) - - case PNGTexture() | AnimatedTexture() as texture: - pass - - image_textures[texture_id] = texture - yield texture_id, texture - - found_items_from_models = set[ResourceLocation]() - missing_items = set[ResourceLocation]() - - missing_item_texture = SingleItemTexture.from_url( - MISSING_TEXTURE_URL, pixelated=True - ) - - # items - for item_id, model in self.load_item_models(): - if result := self.get_override(item_id, image_textures): - yield item_id, result - elif result := load_and_render_item( - model, - self.loader, - self.renderer, - self.gaslighting_items, - image_textures, - self.site_url, - ): - found_items_from_models.add(item_id) - yield item_id, result - else: - missing_items.add(item_id) - - for item_id in list(missing_items): - if result := self.fallback_texture(item_id): - logger.warning(f"Using fallback texture for item: {item_id}") - elif self.can_be_missing(item_id): - logger.warning(f"Using missing texture for item: {item_id}") - result = missing_item_texture - else: - continue - missing_items.remove(item_id) - yield item_id, result - - # oopsies - if missing_items: - raise FileNotFoundError( - "Failed to find a texture for some items: " - + ", ".join(sorted(str(item) for item in missing_items)) - ) - - -def load_texture( - id: ResourceLocation, - *, - path: Path, - repo_root: Path, - asset_url: URL, - strict: bool, -) -> ImageTexture: - # FIXME: is_relative_to is only false when reading zip archives. ideally we would - # permalink to the gh-pages branch and copy all textures there, but we can't get - # that commit until we build the book, so it's a bit of a circular dependency. - if path.is_relative_to(repo_root): - url = asset_url.joinpath(*path.relative_to(repo_root).parts) - else: - level = logging.WARNING if strict else logging.DEBUG - logger.log(level, f"Failed to find relative path for {id}: {path}") - url = None - - meta_path = path.with_suffix(".png.mcmeta") - if meta_path.is_file(): - try: - meta = AnimationMeta.model_validate_json(meta_path.read_bytes()) - except ValueError as e: - logger.debug(f"Failed to parse AnimationMeta for {id}\n{e}") - else: - return AnimatedTexture( - url=url, - pixelated=True, - css_class=id.css_class, - meta=meta, - ) - - return PNGTexture(url=url, pixelated=True) - - -def load_and_render_item( - model: ModelItem, - loader: ModResourceLoader, - renderer: BlockRenderer, - gaslighting_items: Set[ResourceLocation], - image_textures: dict[ResourceLocation, ImageTexture], - site_url: URL, -) -> ItemTexture | None: - try: - match model.find_texture(loader, gaslighting_items): - case None: - return None - - case "gaslighting", found_textures: - textures = list( - lookup_or_render_single_item( - found_texture, - renderer, - image_textures, - site_url, - ).inner - for found_texture in found_textures - ) - return MultiItemTexture(inner=textures, gaslighting=True) - - case found_texture: - texture = lookup_or_render_single_item( - found_texture, - renderer, - image_textures, - site_url, - ) - return texture - except TextureNotFoundError as e: - logger.warning(e.message) - return None - - -# TODO: move to methods on a class returned by find_texture? -def lookup_or_render_single_item( - found_texture: FoundNormalTexture, - renderer: BlockRenderer, - image_textures: dict[ResourceLocation, ImageTexture], - site_url: URL, -) -> SingleItemTexture: - match found_texture: - case "texture", texture_id: - if texture_id not in image_textures: - raise TextureNotFoundError("item", texture_id) - return SingleItemTexture(inner=image_textures[texture_id]) - - case "block_model", model_id: - return render_block(model_id, renderer, site_url) - - -def render_block( - id: ResourceLocation, - renderer: BlockRenderer, - site_url: URL, -) -> SingleItemTexture: - # FIXME: hack - id_out_path = id.path - if id.path.startswith("item/"): - id_out_path = "block/" + id.path.removeprefix("item/") - elif not id.path.startswith("block/"): - id = "block" / id - - out_path = f"assets/{id.namespace}/textures/{id_out_path}.png" - - try: - renderer.render_block_model(id, out_path) - except Exception as e: - if renderer.loader.props.textures.strict: - raise - message = textwrap.indent(f"{e.__class__.__name__}: {e}", " ") - logger.error(f"Failed to render block {id}:\n{message}") - raise TextureNotFoundError("block", id) - - logger.debug(f"Rendered {id} to {out_path}") - - # TODO: ideally we shouldn't be using site_url here, in case the site is moved - # but I'm not sure what else we could do... - return SingleItemTexture.from_url(site_url / out_path, pixelated=False) diff --git a/src/hexdoc/minecraft/assets/models.py b/src/hexdoc/minecraft/assets/models.py deleted file mode 100644 index e2b90ddf6..000000000 --- a/src/hexdoc/minecraft/assets/models.py +++ /dev/null @@ -1,178 +0,0 @@ -from __future__ import annotations - -import logging -from collections import defaultdict -from collections.abc import Set -from typing import Annotated, Any, Literal - -from hexdoc.core import ModResourceLoader, ResourceLocation -from hexdoc.model import HexdocModel -from hexdoc.utils import JSONDict, clamping_validator - -logger = logging.getLogger(__name__) - -FoundNormalTexture = tuple[Literal["texture", "block_model"], ResourceLocation] -FoundGaslightingTexture = tuple[Literal["gaslighting"], list[FoundNormalTexture]] -FoundTexture = FoundNormalTexture | FoundGaslightingTexture - -ItemDisplayPosition = Literal[ - "thirdperson_righthand", - "thirdperson_lefthand", - "firstperson_righthand", - "firstperson_lefthand", - "gui", - "head", - "ground", - "fixed", -] - -_Translation = Annotated[float, clamping_validator(-80, 80)] -_Scale = Annotated[float, clamping_validator(-4, 4)] - - -class ItemDisplay(HexdocModel): - rotation: tuple[float, float, float] | None = None - translation: tuple[_Translation, _Translation, _Translation] | None = None - scale: tuple[_Scale, _Scale, _Scale] | None = None - - -class ModelOverride(HexdocModel): - model: ResourceLocation - """The id of the model to use if the case is met.""" - predicate: dict[ResourceLocation, float] - - -# allow missing because mods can add custom fields :/ -class ModelItem(HexdocModel, extra="allow"): - """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. - """ - - id: ResourceLocation - """Not in the actual file.""" - - parent: ResourceLocation | None = None - """Loads a different model with the given id.""" - display: dict[ItemDisplayPosition, ItemDisplay] | None = None - gui_light: Literal["front", "side"] = "side" - overrides: list[ModelOverride] | None = None - # TODO: minecraft_render would need to support this - elements: Any | None = None - # TODO: support texture variables etc? - textures: dict[str, ResourceLocation] | None = None - """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. - """ - - @classmethod - def load_resource(cls, id: ResourceLocation, loader: ModResourceLoader): - _, data = loader.load_resource("assets", "models", id, export=False) - return cls.load_data(id, data) - - @classmethod - def load_data(cls, id: ResourceLocation, data: JSONDict): - return cls.model_validate(data | {"id": id}) - - @property - def item_id(self): - if "/" not in self.id.path: - return self.id - path_without_prefix = "/".join(self.id.path.split("/")[1:]) - return self.id.with_path(path_without_prefix) - - @property - def layer0(self): - if self.textures: - return self.textures.get("layer0") - - def find_texture( - self, - loader: ModResourceLoader, - gaslighting_items: Set[ResourceLocation], - checked_overrides: defaultdict[ResourceLocation, set[int]] | None = None, - ) -> FoundTexture | None: - """May return a texture **or** a model. Texture ids will start with `textures/`.""" - if checked_overrides is None: - checked_overrides = defaultdict(set) - - # gaslighting - # as of 0.11.1-7, all gaslighting item models are implemented with overrides - if self.item_id in gaslighting_items: - if not self.overrides: - raise ValueError( - f"Model {self.id} for item {self.item_id} marked as gaslighting but" - " does not have overrides" - ) - - gaslighting_textures = list[FoundNormalTexture]() - for i, override in enumerate(self.overrides): - match self._find_override_texture( - i, override, loader, gaslighting_items, checked_overrides - ): - case "gaslighting", _: - raise ValueError( - f"Model {self.id} for item {self.item_id} marked as" - f" gaslighting but override {i} resolves to another gaslighting texture" - ) - case None: - break - case result: - gaslighting_textures.append(result) - else: - return "gaslighting", gaslighting_textures - - # if it exists, the layer0 texture is *probably* representative - # TODO: impl multi-layer textures for Sam - if self.layer0: - texture_id = "textures" / self.layer0 + ".png" - return "texture", texture_id - - # first resolvable override, if any - for i, override in enumerate(self.overrides or []): - if result := self._find_override_texture( - i, override, loader, gaslighting_items, checked_overrides - ): - return result - - if self.parent and self.parent.path.startswith("block/"): - # try the parent id - # we only do this for blocks in the same namespace because most other - # parents are generic "base class"-type models which won't actually - # represent the item - if self.parent.namespace == self.id.namespace: - return "block_model", self.parent - - # FIXME: hack - # this entire selection process needs to be redone, but the idea here is to - # try rendering item models as blocks in certain cases (eg. edified button) - return "block_model", self.id - - return None - - def _find_override_texture( - self, - index: int, - override: ModelOverride, - loader: ModResourceLoader, - gaslighting_items: Set[ResourceLocation], - checked_overrides: defaultdict[ResourceLocation, set[int]], - ) -> FoundTexture | None: - if override.model.path.startswith("block/"): - return "block_model", override.model - - if index in checked_overrides[self.id]: - logger.debug(f"Ignoring recursive override: {override.model}") - return None - - checked_overrides[self.id].add(index) - return ( - (ModelItem) - .load_resource(override.model, loader) - .find_texture(loader, gaslighting_items, checked_overrides) - ) diff --git a/src/hexdoc/minecraft/assets/textures.py b/src/hexdoc/minecraft/assets/textures.py deleted file mode 100644 index 532cc2899..000000000 --- a/src/hexdoc/minecraft/assets/textures.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -import logging -from abc import ABC, abstractmethod -from collections import defaultdict -from typing import ( - Annotated, - Any, - Iterable, - Literal, - Self, - TypeVar, -) - -from pydantic import Field, SerializeAsAny -from typing_extensions import override -from yarl import URL - -from hexdoc.core import ResourceLocation -from hexdoc.model import ( - InlineModel, - ValidationContextModel, -) -from hexdoc.utils import ContextSource, PydanticURL - -from .constants import MISSING_TEXTURE_URL - -logger = logging.getLogger(__name__) - - -class BaseTexture(InlineModel, ABC): - @classmethod - @abstractmethod - def from_url(cls, url: URL, pixelated: bool) -> Self: ... - - @override - @classmethod - def load_id(cls, id: ResourceLocation, context: ContextSource): - texture_ctx = TextureContext.of(context) - return cls.lookup( - id, - lookups=texture_ctx.textures, - allowed_missing=texture_ctx.allowed_missing_textures, - ) - - @classmethod - def lookup( - cls, - id: ResourceLocation, - lookups: TextureLookups[Any], - allowed_missing: Iterable[ResourceLocation] | Literal["*"], - ) -> Self: - """Returns the texture from the lookup table if it exists, or the "missing - texture" texture if it's in `props.texture.missing`, or raises `ValueError`. - - This is called frequently and does not load any files. - """ - textures = cls.get_lookup(lookups) - if id in textures: - return textures[id] - - # TODO: this logic is duplicated in load_assets.py :/ - if allowed_missing == "*" or any( - id.match(pattern) for pattern in allowed_missing - ): - logger.warning(f"No {cls.__name__} for {id}, using default missing texture") - return cls.from_url(MISSING_TEXTURE_URL, pixelated=True) - - raise ValueError(f"No {cls.__name__} for {id}") - - @classmethod - def get_lookup(cls, lookups: TextureLookups[Any]) -> TextureLookup[Self]: - return lookups[cls.__name__] - - def insert_texture(self, lookups: TextureLookups[Any], id: ResourceLocation): - textures = self.get_lookup(lookups) - textures[id] = self - - -class PNGTexture(BaseTexture): - url: PydanticURL | None - pixelated: bool - - @classmethod - def from_url(cls, url: URL, pixelated: bool) -> Self: - return cls(url=url, pixelated=pixelated) - - -_T_BaseTexture = TypeVar("_T_BaseTexture", bound=BaseTexture) - -TextureLookup = dict[ResourceLocation, SerializeAsAny[_T_BaseTexture]] -"""dict[id, texture]""" - -TextureLookups = defaultdict[ - str, - Annotated[TextureLookup[_T_BaseTexture], Field(default_factory=dict)], -] -"""dict[type(texture).__name__, TextureLookup]""" - - -class TextureContext(ValidationContextModel): - textures: TextureLookups[Any] - allowed_missing_textures: set[ResourceLocation] | Literal["*"] diff --git a/src/hexdoc/minecraft/assets/with_texture.py b/src/hexdoc/minecraft/assets/with_texture.py deleted file mode 100644 index a84e8d139..000000000 --- a/src/hexdoc/minecraft/assets/with_texture.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Generic, TypeVar - -from pydantic import field_validator - -from hexdoc.core import ( - ItemStack, - ResourceLocation, -) -from hexdoc.core.resource import BaseResourceLocation -from hexdoc.model import ( - HexdocModel, - InlineItemModel, - InlineModel, -) -from hexdoc.utils import ContextSource - -from ..i18n import I18n, LocalizedStr -from .animated import AnimatedTexture -from .constants import TAG_TEXTURE_URL -from .items import ImageTexture, ItemTexture, MultiItemTexture, SingleItemTexture -from .load_assets import Texture -from .textures import PNGTexture - -logger = logging.getLogger(__name__) - -_T_BaseResourceLocation = TypeVar("_T_BaseResourceLocation", bound=BaseResourceLocation) - -_T_Texture = TypeVar("_T_Texture", bound=Texture) - - -class BaseWithTexture(HexdocModel, Generic[_T_BaseResourceLocation, _T_Texture]): - id: _T_BaseResourceLocation - name: LocalizedStr - texture: Texture - - @property - def image_texture(self) -> ImageTexture | None: - match self.texture: - case PNGTexture() | AnimatedTexture(): - return self.texture - case SingleItemTexture(): - return self.texture.inner - case MultiItemTexture(): - return None - - @property - def image_textures(self) -> list[ImageTexture] | None: - match self.texture: - case MultiItemTexture(): - return self.texture.inner - case PNGTexture() | AnimatedTexture() | SingleItemTexture(): - return None - - @property - def gaslighting(self) -> bool: - match self.texture: - case MultiItemTexture(): - return self.texture.gaslighting - case PNGTexture() | AnimatedTexture() | SingleItemTexture(): - return False - - -class ItemWithTexture(InlineItemModel, BaseWithTexture[ItemStack, ItemTexture]): - @classmethod - def load_id(cls, item: ItemStack, context: ContextSource): - """Implements InlineModel.""" - - i18n = I18n.of(context) - if (name := item.get_name()) is not None: - pass - elif item.path.startswith("texture"): - name = i18n.localize_texture(item.id) - else: - name = i18n.localize_item(item) - - return { - "id": item, - "name": name, - "texture": item.id, # TODO: fix InlineModel (ItemTexture), then remove .id - } - - -class TagWithTexture(InlineModel, BaseWithTexture[ResourceLocation, Texture]): - @classmethod - def load_id(cls, id: ResourceLocation, context: ContextSource): - i18n = I18n.of(context) - return cls( - id=id, - name=i18n.localize_item_tag(id), - texture=PNGTexture.from_url(TAG_TEXTURE_URL, pixelated=True), - ) - - @field_validator("id", mode="after") - @classmethod - def _validate_id(cls, id: ResourceLocation): - assert id.is_tag, f"Expected tag id, got {id}" - return id - - -class NamedTexture(InlineModel, BaseWithTexture[ResourceLocation, ImageTexture]): - @classmethod - def load_id(cls, id: ResourceLocation, context: ContextSource): - i18n = I18n.of(context) - return { - "id": id, - "name": i18n.localize_texture(id, silent=True), - "texture": id, - } diff --git a/src/hexdoc/minecraft/models/__init__.py b/src/hexdoc/minecraft/models/__init__.py deleted file mode 100644 index 532222f85..000000000 --- a/src/hexdoc/minecraft/models/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -__all__ = [ - "BlockModel", - "Blockstate", - "ItemModel", - "load_model", -] - -from .block import BlockModel -from .blockstate import Blockstate -from .item import ItemModel -from .load import load_model diff --git a/src/hexdoc/minecraft/models/block.py b/src/hexdoc/minecraft/models/block.py deleted file mode 100644 index b527d6881..000000000 --- a/src/hexdoc/minecraft/models/block.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -from typing import Self - -from typing_extensions import override - -from hexdoc.core import ModResourceLoader, ResourceLocation - -from .base_model import BaseMinecraftModel - - -class BlockModel(BaseMinecraftModel): - """Represents a Minecraft block model. - - https://minecraft.wiki/w/Tutorials/Models#Block_models - """ - - ambientocclusion: bool = True - """Whether to use ambient occlusion or not. - - Note: only works on parent file. - """ - render_type: ResourceLocation | None = None - """Sets the rendering type for this model. - - https://docs.minecraftforge.net/en/latest/rendering/modelextensions/rendertypes/ - """ - - @override - def apply_parent(self, parent: Self): - super().apply_parent(parent) - self.ambientocclusion = parent.ambientocclusion - self.render_type = self.render_type or parent.render_type - - def load_parents_and_apply(self, loader: ModResourceLoader): - while self.parent: - _, parent = loader.load_resource( - "assets", - "models", - self.parent, - decode=self.model_validate_json, - ) - self.apply_parent(parent) - - def resolve_texture_variables(self): - textures = dict[str, ResourceLocation]() - for name, value in self.textures.items(): - while not isinstance(value, ResourceLocation): - value = value.lstrip("#") - if value == name: - raise ValueError(f"Cyclic texture variable detected: {name}") - value = self.textures[value] - textures[name] = value - return textures diff --git a/src/hexdoc/minecraft/models/item.py b/src/hexdoc/minecraft/models/item.py deleted file mode 100644 index 520a91d1c..000000000 --- a/src/hexdoc/minecraft/models/item.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from typing import Self - -from typing_extensions import override - -from hexdoc.core import ResourceLocation -from hexdoc.model import HexdocModel - -from .base_model import BaseMinecraftModel - - -class ItemModel(BaseMinecraftModel): - """Represents a Minecraft item model. - - https://minecraft.wiki/w/Tutorials/Models#Item_models - """ - - overrides: list[ItemModelOverride] | None = None - """Determines cases in which a different model should be used based on item tags. - - All cases are evaluated in order from top to bottom and last predicate that matches - overrides. However, overrides are ignored if it has been already overridden once, - for example this avoids recursion on overriding to the same model. - """ - - @override - def apply_parent(self, parent: Self): - super().apply_parent(parent) - - -class ItemModelOverride(HexdocModel): - """An item model override case. - - https://minecraft.wiki/w/Tutorials/Models#Item_models - """ - - model: ResourceLocation - """The path to the model to use if the case is met.""" - predicate: dict[ResourceLocation, float] - """Item predicates that must be true for this model to be used.""" diff --git a/src/hexdoc/minecraft/models/load.py b/src/hexdoc/minecraft/models/load.py deleted file mode 100644 index 2b61cb9d5..000000000 --- a/src/hexdoc/minecraft/models/load.py +++ /dev/null @@ -1,25 +0,0 @@ -from hexdoc.core import ModResourceLoader, ResourceLocation - -from .block import BlockModel -from .item import ItemModel - - -def load_model(loader: ModResourceLoader, model_id: ResourceLocation): - match model_id.path.split("/")[0]: - case "block": - model_type = BlockModel - case "item": - model_type = ItemModel - case type_name: - raise ValueError(f"Unsupported type {type_name} for model {model_id}") - - try: - return loader.load_resource( - type="assets", - folder="models", - id=model_id, - decode=model_type.model_validate_json, - ) - except Exception as e: - e.add_note(f" note: {model_id=}") - raise diff --git a/src/hexdoc/minecraft/recipe/ingredients.py b/src/hexdoc/minecraft/recipe/ingredients.py index 1d342ed56..84de46880 100644 --- a/src/hexdoc/minecraft/recipe/ingredients.py +++ b/src/hexdoc/minecraft/recipe/ingredients.py @@ -9,23 +9,21 @@ ValidationInfo, ) -from hexdoc.core import ResourceLocation -from hexdoc.core.loader import ModResourceLoader -from hexdoc.core.resource import AssumeTag +from hexdoc.core import AssumeTag, ModResourceLoader, ResourceLocation +from hexdoc.graphics.validators import HexdocImage, ImageField, ItemImage, TagImage from hexdoc.model import HexdocModel, NoValue, TypeTaggedUnion from hexdoc.utils import listify -from ..assets import ItemWithTexture, TagWithTexture from ..tags import Tag class ItemIngredient(TypeTaggedUnion, ABC): @property - def item(self) -> ItemWithTexture | TagWithTexture: ... + def item(self) -> HexdocImage: ... class MinecraftItemIdIngredient(ItemIngredient, type=NoValue): - item_: ItemWithTexture = Field(alias="item") + item_: ImageField[ItemImage] = Field(alias="item") @property def item(self): @@ -33,7 +31,7 @@ def item(self): class MinecraftItemTagIngredient(ItemIngredient, type=NoValue): - tag: AssumeTag[TagWithTexture] + tag: ImageField[AssumeTag[TagImage]] @property def item(self): @@ -41,7 +39,7 @@ def item(self): class ItemResult(HexdocModel): - item: ItemWithTexture + item: ImageField[ItemImage] count: int = 1 @@ -63,7 +61,7 @@ def _validate_flatten_nested_tags( yield ingredient if isinstance(ingredient, MinecraftItemTagIngredient): - yield from _items_in_tag(ingredient.tag.id, info, loader) + yield from _items_in_tag(ingredient.tag.id.id, info, loader) def _items_in_tag( diff --git a/src/hexdoc/minecraft/recipe/recipes.py b/src/hexdoc/minecraft/recipe/recipes.py index 20eb3f009..332ad8acd 100644 --- a/src/hexdoc/minecraft/recipe/recipes.py +++ b/src/hexdoc/minecraft/recipe/recipes.py @@ -1,19 +1,27 @@ from typing import Any, ClassVar, Iterator, Unpack -from pydantic import ConfigDict, Field, PrivateAttr, ValidationInfo, model_validator +from pydantic import ( + ConfigDict, + Field, + PrivateAttr, + TypeAdapter, + ValidationInfo, + model_validator, +) from typing_extensions import override from hexdoc.core import ( + I18n, + LocalizedStr, ModResourceLoader, ResourceLocation, ValueIfVersion, ) from hexdoc.core.compat import AtLeast_1_20, Before_1_20 +from hexdoc.graphics import HexdocImage, ImageField, ItemImage, TextureImage from hexdoc.model import ResourceModel, TypeTaggedTemplate from hexdoc.utils import Inherit, InheritType, classproperty -from ..assets import ImageTexture, ItemWithTexture, validate_texture -from ..i18n import I18n, LocalizedStr from .ingredients import ItemIngredient, ItemIngredientList, ItemResult @@ -42,7 +50,7 @@ class Recipe(TypeTaggedTemplate, ResourceModel): _workstation: ClassVar[ResourceLocation | None] = None _gui_name: LocalizedStr | None = PrivateAttr(None) - _gui_texture: ImageTexture | None = PrivateAttr(None) + _gui_texture: HexdocImage | None = PrivateAttr(None) def __init_subclass__( cls, @@ -106,10 +114,9 @@ def _load_gui_texture(self, info: ValidationInfo): self._gui_name = self._localize_workstation(I18n.of(info)) if self._gui_texture_id is not None: - self._gui_texture = validate_texture( + self._gui_texture = TypeAdapter(ImageField[TextureImage]).validate_python( self._gui_texture_id, - context=info, - model_type=ImageTexture, + context=info.context, ) return self @@ -142,7 +149,7 @@ def ingredients(self) -> Iterator[ItemIngredientList | None]: class CookingRecipe(Recipe): ingredient: ItemIngredientList - result: ItemWithTexture + result: ImageField[ItemImage] experience: float cookingtime: int @@ -190,7 +197,7 @@ def result_item(self): class SmithingTransformRecipe(SmithingRecipe, type="minecraft:smithing_transform"): - result: ItemWithTexture + result: ImageField[ItemImage] @property def result_item(self): @@ -207,5 +214,5 @@ class StonecuttingRecipe( workstation="minecraft:stonecutter", ): ingredient: ItemIngredientList - result: ItemWithTexture + result: ImageField[ItemImage] count: int diff --git a/src/hexdoc/minecraft/tags.py b/src/hexdoc/minecraft/tags.py index dd05b286c..ef9bb5131 100644 --- a/src/hexdoc/minecraft/tags.py +++ b/src/hexdoc/minecraft/tags.py @@ -120,9 +120,8 @@ def _export(self: Tag, current: Tag | None): def _load_values(self, loader: ModResourceLoader) -> Iterator[TagValue]: for value in self.values: match value: - case ( - (ResourceLocation() as child_id) - | OptionalTagValue(id=child_id) + case (ResourceLocation() as child_id) | OptionalTagValue( + id=child_id ) if child_id.is_tag: try: child = Tag.load(self.registry, child_id, loader) diff --git a/src/hexdoc/model/__init__.py b/src/hexdoc/model/__init__.py index 4853b46a4..f04c22249 100644 --- a/src/hexdoc/model/__init__.py +++ b/src/hexdoc/model/__init__.py @@ -1,5 +1,6 @@ __all__ = [ "DEFAULT_CONFIG", + "IGNORE_EXTRA_CONFIG", "Color", "HexdocModel", "HexdocSettings", @@ -8,19 +9,23 @@ "InlineItemModel", "InlineModel", "InternallyTaggedUnion", + "MustBeAnnotated", "NoValue", "NoValueType", "ResourceModel", "StripHiddenModel", "TagValue", + "TemplateModel", "TypeTaggedTemplate", "TypeTaggedUnion", + "UnionModel", "ValidationContextModel", "init_context", ] from .base import ( DEFAULT_CONFIG, + IGNORE_EXTRA_CONFIG, HexdocModel, HexdocSettings, HexdocTypeAdapter, @@ -35,7 +40,9 @@ NoValue, NoValueType, TagValue, + TemplateModel, TypeTaggedTemplate, TypeTaggedUnion, + UnionModel, ) -from .types import Color +from .types import Color, MustBeAnnotated diff --git a/src/hexdoc/model/base.py b/src/hexdoc/model/base.py index c2269c77b..8ba508339 100644 --- a/src/hexdoc/model/base.py +++ b/src/hexdoc/model/base.py @@ -1,14 +1,26 @@ from __future__ import annotations +import inspect +import typing from contextvars import ContextVar from typing import ( + Annotated, Any, ClassVar, + Sequence, cast, dataclass_transform, ) -from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationInfo, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + SkipValidation, + TypeAdapter, + ValidationInfo, + model_validator, +) +from pydantic.fields import FieldInfo from pydantic.functional_validators import ModelBeforeValidator from pydantic_settings import BaseSettings, SettingsConfigDict from yarl import URL @@ -50,6 +62,87 @@ class HexdocModel(BaseModel): __hexdoc_before_validator__: ClassVar[ModelBeforeValidator | None] = None + @classmethod + def __hexdoc_check_model_field__( + cls, + model_type: type[HexdocModel], + field_name: str, + field_info: FieldInfo, + origin_stack: Sequence[Any], + annotation_stack: Sequence[Any], + ) -> None: + """Called when initializing a model of type `model_type` for all places where + `cls` is in a type annotation.""" + + # global model field validation (mostly used for HexdocImage) + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: + super().__pydantic_init_subclass__(**kwargs) + + for field_name, field_info in cls.model_fields.items(): + cls._hexdoc_check_model_field(field_name, field_info) + + @classmethod + def _hexdoc_check_model_field(cls, field_name: str, field_info: FieldInfo): + if field_type := field_info.rebuild_annotation(): + cls._hexdoc_check_model_field_type( + field_name, field_info, field_type, [], [] + ) + + @classmethod + def _hexdoc_check_model_field_type( + cls, + field_name: str, + field_info: FieldInfo, + field_type: Any, + origin_stack: list[Any], + annotation_stack: list[Any], + ): + # TODO: better way to detect recursive types? + if len(origin_stack) > 25: + return + + if inspect.isclass(field_type) and issubclass(field_type, HexdocModel): + field_type.__hexdoc_check_model_field__( + cls, + field_name, + field_info, + origin_stack, + annotation_stack, + ) + + if origin := typing.get_origin(field_type): + args = typing.get_args(field_type) + + if origin is Annotated: + arg, *annotations = args + args = [arg] + else: + annotations = [] + + if any( + isinstance( + a, + SkipValidation, # pyright: ignore[reportArgumentType] + ) + for a in annotations + ): + return + + origin_stack.append(origin) + annotation_stack += reversed(annotations) + for arg in args: + cls._hexdoc_check_model_field_type( + field_name, + field_info, + arg, + origin_stack, + annotation_stack, + ) + origin_stack.pop() + if annotations: + del annotation_stack[-len(annotations) :] + def __init__(__pydantic_self__, **data: Any) -> None: # type: ignore __tracebackhide__ = True __pydantic_self__.__pydantic_validator__.validate_python( diff --git a/src/hexdoc/model/strip_hidden.py b/src/hexdoc/model/strip_hidden.py index fe2710270..38c145919 100644 --- a/src/hexdoc/model/strip_hidden.py +++ b/src/hexdoc/model/strip_hidden.py @@ -4,6 +4,7 @@ from pydantic.config import JsonDict from hexdoc.utils.deserialize.assertions import cast_or_raise +from hexdoc.utils.types import isdict from .base import DEFAULT_CONFIG, HexdocModel @@ -25,8 +26,8 @@ class StripHiddenModel(HexdocModel): ) @model_validator(mode="before") - def _pre_root_strip_hidden(cls, values: dict[Any, Any] | Any) -> Any: - if not isinstance(values, dict): + def _pre_root_strip_hidden(cls, values: Any) -> Any: + if not isdict(values): return values return { diff --git a/src/hexdoc/model/tagged_union.py b/src/hexdoc/model/tagged_union.py index 50ba3db71..c9bc81518 100644 --- a/src/hexdoc/model/tagged_union.py +++ b/src/hexdoc/model/tagged_union.py @@ -4,7 +4,16 @@ from abc import ABC, abstractmethod from collections import defaultdict -from typing import Any, ClassVar, Generator, Self, Unpack +from typing import ( + Any, + ClassVar, + Generator, + Iterable, + LiteralString, + Self, + TypeVar, + Unpack, +) import more_itertools from pydantic import ( @@ -32,6 +41,8 @@ from .base import HexdocModel +_T_UnionModel = TypeVar("_T_UnionModel", bound="UnionModel") + TagValue = str | NoValueType _RESOLVED = "__resolved" @@ -40,7 +51,96 @@ _is_loaded = False -class InternallyTaggedUnion(HexdocModel): +class UnionModel(HexdocModel): + @classmethod + def _resolve_union( + cls, + value: Any, + context: dict[str, Any] | None, + *, + model_types: Iterable[type[_T_UnionModel]], + allow_ambiguous: bool, + error_name: LiteralString = "HexdocUnionMatchError", + error_text: Iterable[LiteralString] = [], + error_data: dict[str, Any] = {}, + ) -> _T_UnionModel: + # try all the types + exceptions: list[InitErrorDetails] = [] + matches: dict[type[_T_UnionModel], _T_UnionModel] = {} + + for model_type in model_types: + try: + result = matches[model_type] = model_type.model_validate( + value, + context=context, + ) + if allow_ambiguous: + return result + except (ValueError, AssertionError, ValidationError) as e: + exceptions.append( + InitErrorDetails( + type=PydanticCustomError( + error_name, + "{exception_class}: {exception}", + { + "exception_class": e.__class__.__name__, + "exception": str(e), + }, + ), + loc=( + cls.__name__, + model_type.__name__, + ), + input=value, + ) + ) + + # ensure we only matched one + # if allow_ambiguous is True, we should have returned a value already + match len(matches): + case 1: + return matches.popitem()[1] + case x if x > 1: + ambiguous_types = ", ".join(str(t) for t in matches.keys()) + reason = f"Ambiguous union match: {ambiguous_types}" + case _: + reason = "No match found" + + # something went wrong, raise an exception + error = PydanticCustomError( + f"{error_name}Group", + "\n ".join( + ( + "Failed to match union {class_name}: {reason}", + "Types: {types}", + "Value: {value}", + *error_text, + ) + ), + { + "class_name": str(cls), + "reason": reason, + "types": ", ".join(str(t) for t in model_types), + "value": repr(value), + **error_data, + }, + ) + + if exceptions: + exceptions.insert( + 0, + InitErrorDetails( + type=error, + loc=(cls.__name__,), + input=value, + ), + ) + raise ValidationError.from_exception_data(error_name, exceptions) + + raise RuntimeError(str(error)) + + +class InternallyTaggedUnion(UnionModel): """Implements [internally tagged unions](https://serde.rs/enum-representations.html#internally-tagged) using the [Registry pattern](https://charlesreid1.github.io/python-patterns-the-registry.html). @@ -140,7 +240,7 @@ def _resolve_from_dict( case InternallyTaggedUnion() if isinstance(value, cls): return value case dict() if _RESOLVED not in value: - data: dict[str, Any] = value + data: dict[str, Any] = value # pyright: ignore[reportUnknownVariableType] data[_RESOLVED] = True case _: return handler(value) @@ -153,89 +253,35 @@ def _resolve_from_dict( if tag_types is None: raise TypeError(f"Unhandled tag: {tag_key}={tag_value} for {cls}: {data}") - # try all the types - exceptions: list[InitErrorDetails] = [] - matches: dict[type[Self], Self] = {} - - for inner_type in tag_types: - try: - matches[inner_type] = inner_type.model_validate( - data, context=info.context - ) - except Exception as e: - exceptions.append( - InitErrorDetails( - type=PydanticCustomError( - "TaggedUnionMatchError", - "{exception_class}: {exception}", - { - "exception_class": e.__class__.__name__, - "exception": str(e), - }, - ), - loc=( - cls.__name__, - inner_type.__name__, - ), - input=data, - ) - ) - - # ensure we only matched one - match len(matches): - case 1: - return matches.popitem()[1] - case x if x > 1: - ambiguous_types = ", ".join(str(t) for t in matches.keys()) - reason = f"Ambiguous union match: {ambiguous_types}" - case _: - reason = "No match found" - - # something went wrong, raise an exception - error = PydanticCustomError( - "TaggedUnionMatchErrorGroup", - ( - "Failed to match tagged union {class_name}: {reason}\n" - " Tag: {tag_key}={tag_value}\n" - " Types: {types}\n" - " Data: {data}" - ), - { - "class_name": str(cls), - "reason": reason, - "tag_key": cls._tag_key, - "tag_value": tag_value, - "types": ", ".join(str(t) for t in tag_types), - "data": repr(data), - }, - ) - - if exceptions: + try: + return cls._resolve_union( + data, + info.context, + model_types=tag_types, + allow_ambiguous=False, + error_name="TaggedUnionMatchError", + error_text=[ + "Tag: {tag_key}={tag_value}", + ], + error_data={ + "tag_key": cls._tag_key, + "tag_value": tag_value, + }, + ) + except Exception: if _RESOLVED in data: data.pop(_RESOLVED) # avoid interfering with other types - exceptions.insert( - 0, - InitErrorDetails( - type=error, - loc=(cls.__name__,), - input=data, - ), - ) - raise ValidationError.from_exception_data( - "TaggedUnionMatchError", exceptions - ) - - raise RuntimeError(str(error)) + raise @model_validator(mode="before") - def _pop_temporary_keys(cls, value: dict[Any, Any] | Any): + def _pop_temporary_keys(cls, value: Any) -> Any: if isinstance(value, dict) and _RESOLVED in value: # copy because this validator may be called multiple times # eg. two types with the same key - value = value.copy() + value = value.copy() # pyright: ignore[reportUnknownVariableType] value.pop(_RESOLVED) assert value.pop(cls._tag_key, NoValue) == cls._tag_value - return value + return value # pyright: ignore[reportUnknownVariableType] @classmethod @override @@ -343,26 +389,23 @@ def _tag_value_type(cls) -> type[Any]: return ResourceLocation -class TypeTaggedTemplate(TypeTaggedUnion, ABC, type=None): - __template_id: ClassVar[ResourceLocation] +class TemplateModel(HexdocModel, ABC): + _template_id: ClassVar[ResourceLocation | None] = None def __init_subclass__( cls, *, - type: str | InheritType | None = Inherit, - template_type: str | None = None, + template_id: str | ResourceLocation | InheritType | None = None, **kwargs: Unpack[ConfigDict], ) -> None: - super().__init_subclass__(type=type, **kwargs) - - # jinja template path - if template_type is not None: - template_id = ResourceLocation.from_str(template_type) - else: - template_id = cls.type - - if template_id: - cls.__template_id = template_id + super().__init_subclass__(**kwargs) + match template_id: + case str(): + cls._template_id = ResourceLocation.from_str(template_id) + case ResourceLocation() | None: + cls._template_id = template_id + case InheritType(): + pass @classproperty @classmethod @@ -376,4 +419,30 @@ def template(cls) -> str: @classproperty @classmethod def template_id(cls): - return cls.__template_id + assert cls._template_id is not None, f"Template id not initialized: {cls}" + return cls._template_id + + +class TypeTaggedTemplate(TypeTaggedUnion, TemplateModel, ABC, type=None): + def __init_subclass__( + cls, + *, + type: str | InheritType | None = Inherit, + template_type: str | ResourceLocation | None = None, + **kwargs: Unpack[ConfigDict], + ) -> None: + if template_type is None: + match type: + case str(): + template_type = type + case InheritType() if isinstance(cls.type, ResourceLocation): + template_type = cls.type + case _: + pass + + super().__init_subclass__( + type=type, + # pyright doesn't seem to understand multiple inheritance here + template_id=template_type, # pyright: ignore[reportCallIssue] + **kwargs, + ) diff --git a/src/hexdoc/model/types.py b/src/hexdoc/model/types.py index 9eed4dbc8..528300b78 100644 --- a/src/hexdoc/model/types.py +++ b/src/hexdoc/model/types.py @@ -1,12 +1,17 @@ +import inspect import string -from typing import Any +import textwrap +from typing import Any, ClassVar, Sequence -from pydantic import field_validator, model_validator +from pydantic import ConfigDict, field_validator, model_validator from pydantic.dataclasses import dataclass +from pydantic.fields import FieldInfo +from typing_extensions import Unpack +from hexdoc.utils import Inherit, InheritType from hexdoc.utils.json_schema import inherited, json_schema_extra_config, type_str -from .base import DEFAULT_CONFIG +from .base import DEFAULT_CONFIG, HexdocModel @dataclass( @@ -64,3 +69,44 @@ def _check_value(cls, value: Any) -> str: raise ValueError(f"invalid color code: {value}") return value + + +class MustBeAnnotated(HexdocModel): + _annotation_type: ClassVar[type[Any] | None] + + def __init_subclass__( + cls, + annotation: type[Any] | InheritType | None = Inherit, + **kwargs: Unpack[ConfigDict], + ): + super().__init_subclass__(**kwargs) + if annotation != Inherit: + if annotation and not inspect.isclass(annotation): + raise TypeError( + f"Expected annotation to be a type or None, got {type(annotation)}: {annotation}" + ) + cls._annotation_type = annotation + + @classmethod + def __hexdoc_check_model_field__( + cls, + model_type: type[HexdocModel], + field_name: str, + field_info: FieldInfo, + origin_stack: Sequence[Any], + annotation_stack: Sequence[Any], + ) -> None: + if cls._annotation_type is None: + return + if cls._annotation_type not in annotation_stack: + annotation = cls._annotation_type.__name__ + raise TypeError( + textwrap.dedent( + f"""\ + {cls.__name__} must be annotated with {annotation} (eg. {annotation}[{cls.__name__}]) when used as a validation type hint. + Model: {model_type} + Field: {field_name} + Type: {field_info.rebuild_annotation()} + """.rstrip() + ) + ) diff --git a/src/hexdoc/patchouli/book.py b/src/hexdoc/patchouli/book.py index 6ef4a7801..2223be432 100644 --- a/src/hexdoc/patchouli/book.py +++ b/src/hexdoc/patchouli/book.py @@ -6,14 +6,15 @@ from hexdoc.core import ( ItemStack, + LocalizedStr, ModResourceLoader, ResLoc, ResourceLocation, ) from hexdoc.core.compat import AtLeast_1_20, Before_1_20 -from hexdoc.minecraft import LocalizedStr from hexdoc.model import Color, HexdocModel from hexdoc.utils import ContextSource, cast_context, sorted_dict +from hexdoc.utils.types import isdict from .book_context import BookContext from .category import Category @@ -36,8 +37,12 @@ class Book(HexdocModel): """ # not in book.json - _categories: dict[ResourceLocation, Category] = PrivateAttr(default_factory=dict) - _all_entries: dict[ResourceLocation, Entry] = PrivateAttr(default_factory=dict) + _categories: dict[ResourceLocation, Category] = PrivateAttr( + default_factory=lambda: {} + ) + _all_entries: dict[ResourceLocation, Entry] = PrivateAttr( + default_factory=lambda: {} + ) # required name: LocalizedStr @@ -162,8 +167,8 @@ def _load_entries( @model_validator(mode="before") @classmethod - def _pre_root(cls, data: dict[Any, Any] | Any): - if isinstance(data, dict) and "index_icon" not in data: + def _pre_root(cls, data: Any): + if isdict(data) and "index_icon" not in data: data["index_icon"] = data.get("model") return data diff --git a/src/hexdoc/patchouli/category.py b/src/hexdoc/patchouli/category.py index a408feef2..b4aa61ba5 100644 --- a/src/hexdoc/patchouli/category.py +++ b/src/hexdoc/patchouli/category.py @@ -4,10 +4,9 @@ from pydantic import Field from pydantic.json_schema import SkipJsonSchema -from hexdoc.core import ResourceLocation +from hexdoc.core import LocalizedStr, ResourceLocation from hexdoc.core.loader import ModResourceLoader -from hexdoc.minecraft import LocalizedStr -from hexdoc.minecraft.assets import ItemWithTexture, NamedTexture +from hexdoc.graphics import ImageField, ItemImage, TextureImage from hexdoc.model import IDModel from hexdoc.utils import Sortable, sorted_dict from hexdoc.utils.graphs import TypedDiGraph @@ -25,13 +24,15 @@ class Category(IDModel, Sortable, Flagged): See: https://vazkiimods.github.io/Patchouli/docs/reference/category-json """ - entries: SkipJsonSchema[dict[ResourceLocation, Entry]] = Field(default_factory=dict) + entries: SkipJsonSchema[dict[ResourceLocation, Entry]] = Field( + default_factory=lambda: {} + ) is_spoiler: SkipJsonSchema[bool] = False # required name: LocalizedStr description: FormatTree - icon: ItemWithTexture | NamedTexture + icon: ImageField[ItemImage | TextureImage] # optional parent_id: ResourceLocation | None = Field(default=None, alias="parent") diff --git a/src/hexdoc/patchouli/entry.py b/src/hexdoc/patchouli/entry.py index 79f23c8b4..37fc082f0 100644 --- a/src/hexdoc/patchouli/entry.py +++ b/src/hexdoc/patchouli/entry.py @@ -3,9 +3,8 @@ from pydantic import Field, model_validator -from hexdoc.core import ItemStack, ResourceLocation -from hexdoc.minecraft import LocalizedStr -from hexdoc.minecraft.assets import ItemWithTexture, NamedTexture +from hexdoc.core import ItemStack, LocalizedStr, ResourceLocation +from hexdoc.graphics import ImageField, ItemImage, TextureImage from hexdoc.model import Color, IDModel from hexdoc.patchouli.page.abstract_pages import AccumulatorPage, PageWithAccumulator from hexdoc.utils import Sortable @@ -25,7 +24,7 @@ class Entry(IDModel, Sortable, AdvancementSpoilered, Flagged): # required (entry.json) name: LocalizedStr category_id: ResourceLocation = Field(alias="category") - icon: ItemWithTexture | NamedTexture + icon: ImageField[ItemImage | TextureImage] pages: list[Page] # optional (entry.json) diff --git a/src/hexdoc/patchouli/page/abstract_pages.py b/src/hexdoc/patchouli/page/abstract_pages.py index 1b626d65d..323f27593 100644 --- a/src/hexdoc/patchouli/page/abstract_pages.py +++ b/src/hexdoc/patchouli/page/abstract_pages.py @@ -7,8 +7,7 @@ from pydantic.functional_validators import ModelWrapValidatorHandler from typing_extensions import override -from hexdoc.core import ResourceLocation -from hexdoc.minecraft import LocalizedStr +from hexdoc.core import LocalizedStr, ResourceLocation from hexdoc.minecraft.recipe import Recipe from hexdoc.model import TypeTaggedTemplate from hexdoc.utils import Inherit, InheritType, NoValue, classproperty diff --git a/src/hexdoc/patchouli/page/pages.py b/src/hexdoc/patchouli/page/pages.py index 7945cb6c9..7d065484b 100644 --- a/src/hexdoc/patchouli/page/pages.py +++ b/src/hexdoc/patchouli/page/pages.py @@ -6,15 +6,17 @@ from pydantic import ( Field, PrivateAttr, + TypeAdapter, ValidationInfo, field_validator, model_validator, ) from typing_extensions import override -from hexdoc.core import Entity, ItemStack, ResourceLocation -from hexdoc.minecraft import I18n, LocalizedStr -from hexdoc.minecraft.assets import ItemWithTexture, PNGTexture, TagWithTexture, Texture +from hexdoc.core import Entity, LocalizedStr, ResourceLocation +from hexdoc.core.i18n import I18n +from hexdoc.graphics import ImageField, ItemImage, TextureImage +from hexdoc.graphics.validators import HexdocImage, TagImage from hexdoc.minecraft.recipe import ( BlastingRecipe, CampfireCookingRecipe, @@ -73,7 +75,7 @@ class EmptyPage(Page, type="patchouli:empty", template_type="patchouli:page"): class EntityPage(PageWithText, type="patchouli:entity"): _entity_name: LocalizedStr = PrivateAttr() - _texture: PNGTexture = PrivateAttr() + _texture: HexdocImage = PrivateAttr() entity: Entity scale: float = 1 @@ -103,14 +105,15 @@ def _get_texture(self, info: ValidationInfo) -> Self: assert info.context is not None i18n = I18n.of(info) self._entity_name = i18n.localize_entity(self.entity.id) - self._texture = PNGTexture.load_id( - id="textures/entities" / self.entity.id + ".png", context=info.context + self._texture = TypeAdapter(ImageField[TextureImage]).validate_python( + "textures/entities" / self.entity.id + ".png", + context=info.context, ) return self class ImagePage(PageWithTitle, type="patchouli:image"): - images: list[Texture] + images: list[ImageField[TextureImage]] border: bool = False @property @@ -130,7 +133,7 @@ class LinkPage(TextPage, type="patchouli:link"): class Multiblock(HexdocModel): """https://vazkiimods.github.io/Patchouli/docs/patchouli-basics/multiblocks/""" - mapping: dict[str, ItemWithTexture | TagWithTexture] + mapping: dict[str, ImageField[ItemImage | TextureImage]] pattern: list[list[str]] symmetrical: bool = False offset: tuple[int, int, int] | None = None @@ -166,18 +169,14 @@ def bill_of_materials(self): @classmethod def _add_default_mapping( cls, - mapping: dict[str, ItemWithTexture | TagWithTexture], + mapping: dict[str, ImageField[ItemImage | TagImage]], info: ValidationInfo, ): - i18n = I18n.of(info) return { - "_": ItemWithTexture( - id=ItemStack("hexdoc", "any"), - name=i18n.localize("hexdoc.any_block"), - texture=PNGTexture.load_id( - ResourceLocation("hexdoc", "textures/gui/any_block.png"), - context=info, - ), + # FIXME: this needs to be ItemImage somehow + "_": TextureImage.load_id( + id=ResourceLocation("hexdoc", "textures/gui/any_block.png"), + context=info.context or {}, ), } | mapping @@ -232,7 +231,7 @@ class StonecuttingPage( class SpotlightPage(PageWithText, type="patchouli:spotlight"): title_field: LocalizedStr | None = Field(default=None, alias="title") - item: ItemWithTexture + item: ImageField[ItemImage] link_recipe: bool = False @property diff --git a/src/hexdoc/patchouli/text.py b/src/hexdoc/patchouli/text.py index 7becd5504..06905ef02 100644 --- a/src/hexdoc/patchouli/text.py +++ b/src/hexdoc/patchouli/text.py @@ -6,7 +6,7 @@ import re from enum import Enum, auto from fnmatch import fnmatch -from typing import Literal, Self, final +from typing import Literal, Self, TypedDict, final from jinja2 import pass_context from jinja2.runtime import Context @@ -14,8 +14,7 @@ from pydantic.dataclasses import dataclass from pydantic.functional_validators import ModelWrapValidatorHandler -from hexdoc.core import Properties, ResourceLocation -from hexdoc.minecraft import I18n, LocalizedStr +from hexdoc.core import I18n, LocalizedStr, Properties, ResourceLocation from hexdoc.model import DEFAULT_CONFIG, HexdocModel, ValidationContextModel from hexdoc.plugin import PluginManager from hexdoc.utils import PydanticURL, TryGetEnum, classproperty @@ -286,6 +285,10 @@ class FunctionStyle(Style, frozen=True): value: str +class BookLinksDict(TypedDict): + book_links: BookLinks + + class LinkStyle(Style, frozen=True): type: Literal[SpecialStyleType.link] = SpecialStyleType.link value: str | BookLink @@ -314,7 +317,7 @@ def from_str( return cls(value=value, external=external) @pass_context - def href(self, context: Context | dict[{"book_links": BookLinks}]): # noqa + def href(self, context: Context | BookLinksDict): match self.value: case str(href): return href diff --git a/src/hexdoc/plugin/manager.py b/src/hexdoc/plugin/manager.py index 4cea978e8..1c7cd4603 100644 --- a/src/hexdoc/plugin/manager.py +++ b/src/hexdoc/plugin/manager.py @@ -1,7 +1,7 @@ from __future__ import annotations import importlib -from dataclasses import dataclass +from dataclasses import InitVar, dataclass, field from importlib.resources import Package from pathlib import Path from types import ModuleType @@ -29,8 +29,8 @@ if TYPE_CHECKING: from hexdoc.cli.app import LoadedBookInfo - from hexdoc.core import Properties, ResourceLocation - from hexdoc.minecraft import I18n + from hexdoc.core import I18n, Properties, ResourceLocation + from hexdoc.graphics.validators import ItemImage from hexdoc.patchouli import FormatTree import logging @@ -38,7 +38,7 @@ from .book_plugin import BookPlugin from .mod_plugin import DefaultRenderedTemplates, ModPlugin, ModPluginWithBook from .specs import HEXDOC_PROJECT_NAME, PluginSpec -from .types import HookReturns +from .types import HookReturn, HookReturns logger = logging.getLogger(__name__) @@ -92,21 +92,21 @@ class _NoCallTypedHookCaller(TypedHookCaller[_P, None]): def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> Never: ... -# TODO: convert to dataclass +@dataclass class PluginManager(ValidationContext): """Custom hexdoc plugin manager with helpers and stronger typing.""" - def __init__(self, branch: str, props: Properties, load: bool = True) -> None: - """Initialize the hexdoc plugin manager. + branch: str + props: Properties + load: InitVar[bool] = True + """If true (the default), calls `init_entrypoints` and `init_mod_plugins`.""" - If `load` is true (the default), calls `init_entrypoints` and `init_mod_plugins`. - """ - self.branch = branch - self.props = props - self.inner = pluggy.PluginManager(HEXDOC_PROJECT_NAME) - self.mod_plugins: dict[str, ModPlugin] = {} - self.book_plugins: dict[str, BookPlugin[Any]] = {} + mod_plugins: dict[str, ModPlugin] = field(default_factory=lambda: {}) + book_plugins: dict[str, BookPlugin[Any]] = field(default_factory=lambda: {}) + item_image_types: list[type[ItemImage]] = field(default_factory=lambda: []) + def __post_init__(self, load: bool): + self.inner = pluggy.PluginManager(HEXDOC_PROJECT_NAME) self.inner.add_hookspecs(PluginSpec) if load: self.init_entrypoints() @@ -122,18 +122,19 @@ def init_plugins(self): def _init_book_plugins(self): caller = self._hook_caller(PluginSpec.hexdoc_book_plugin) - for plugin in flatten(caller.try_call()): + for plugin in flatten_hook_returns(caller.try_call()): self.book_plugins[plugin.modid] = plugin def _init_mod_plugins(self): caller = self._hook_caller(PluginSpec.hexdoc_mod_plugin) - for plugin in flatten( + for plugin in flatten_hook_returns( caller.try_call( branch=self.branch, props=self.props, ) ): self.mod_plugins[plugin.modid] = plugin + self.item_image_types += flatten_hook_return(plugin.item_image_types()) def register(self, plugin: Any, name: str | None = None): self.inner.register(plugin, name) @@ -211,7 +212,7 @@ def validate_format_tree( def update_context(self, context: dict[str, Any]) -> Iterator[ValidationContext]: caller = self._hook_caller(PluginSpec.hexdoc_update_context) if returns := caller.try_call(context=context): - yield from flatten(returns) + yield from flatten_hook_returns(returns) def update_jinja_env(self, modids: Sequence[str], env: SandboxedEnvironment): for modid in modids: @@ -264,7 +265,7 @@ def post_render_book( def load_resources(self, modid: str) -> Iterator[ModuleType]: plugin = self.mod_plugin(modid) - for package in flatten([plugin.resource_dirs()]): + for package in flatten_hook_return(plugin.resource_dirs()): yield import_package(package) def load_tagged_unions(self) -> Iterator[ModuleType]: @@ -295,7 +296,7 @@ def _package_loaders_for(self, modids: Iterable[str]): package_name=import_package(package).__name__, package_path=package_path, ) - for package, package_path in flatten([result]) + for package, package_path in flatten_hook_return(result) ] ) @@ -331,7 +332,7 @@ def _import_from_hook( **kwargs: _P.kwargs, ) -> Iterator[ModuleType]: packages = self._hook_caller(__spec)(*args, **kwargs) - for package in flatten(packages): + for package in flatten_hook_returns(packages): yield import_package(package) def load_flags(self) -> dict[str, bool]: @@ -375,12 +376,16 @@ def _hook_caller(self, spec: Callable[_P, _R | None]) -> TypedHookCaller[_P, _R] return TypedHookCaller(None, caller) -def flatten(values: list[list[_T] | _T] | None) -> Iterator[_T]: +def flatten_hook_returns(values: HookReturns[_T] | None) -> Iterator[_T]: for value in values or []: - if isinstance(value, list): - yield from value - else: - yield value + yield from flatten_hook_return(value) + + +def flatten_hook_return(values: HookReturn[_T] | None) -> Iterator[_T]: + if isinstance(values, list): + yield from values + else: + yield values # type: ignore def import_package(package: Package) -> ModuleType: diff --git a/src/hexdoc/plugin/mod_plugin.py b/src/hexdoc/plugin/mod_plugin.py index a0849e676..5bd83b0c2 100644 --- a/src/hexdoc/plugin/mod_plugin.py +++ b/src/hexdoc/plugin/mod_plugin.py @@ -8,7 +8,6 @@ from jinja2.sandbox import SandboxedEnvironment from typing_extensions import override -from yarl import URL from hexdoc.utils import ContextSource @@ -16,8 +15,8 @@ if TYPE_CHECKING: from hexdoc.cli.app import LoadedBookInfo - from hexdoc.core import ModResourceLoader, Properties - from hexdoc.minecraft.assets import HexdocAssetLoader + from hexdoc.core import Properties + from hexdoc.graphics.validators import ItemImage DefaultRenderedTemplates = Mapping[ str | Path, @@ -168,6 +167,10 @@ def post_render_book(self, template_args: dict[str, Any], output_dir: Path) -> N """Called once per language, after all book files for that language are rendered.""" + def item_image_types(self) -> HookReturn[type[ItemImage[Any]]]: + """List of `ModelImage` types to attempt when loading a block/item model.""" + return [] + # utils def site_path(self, versioned: bool): @@ -205,24 +208,6 @@ def latest_site_path(self) -> Path: """ return self.site_root / "latest" / self.branch - def asset_loader( - self, - loader: ModResourceLoader, - *, - site_url: URL, - asset_url: URL, - render_dir: Path, - ) -> HexdocAssetLoader: - # unfortunately, this is necessary to avoid some *real* ugly circular imports - from hexdoc.minecraft.assets import HexdocAssetLoader - - return HexdocAssetLoader( - loader=loader, - site_url=site_url, - asset_url=asset_url, - render_dir=render_dir, - ) - @property def flags(self) -> dict[str, bool]: """Set default values for Patchouli config flags. diff --git a/src/hexdoc/plugin/specs.py b/src/hexdoc/plugin/specs.py index a836e3f5f..8659f72a6 100644 --- a/src/hexdoc/plugin/specs.py +++ b/src/hexdoc/plugin/specs.py @@ -12,8 +12,7 @@ from .types import HookReturn, HookReturns if TYPE_CHECKING: - from hexdoc.core import Properties, ResourceLocation - from hexdoc.minecraft import I18n + from hexdoc.core import I18n, Properties, ResourceLocation from hexdoc.patchouli import FormatTree HEXDOC_PROJECT_NAME = "hexdoc" diff --git a/src/hexdoc/utils/classproperties.py b/src/hexdoc/utils/classproperties.py index e86d8c20e..eee666170 100644 --- a/src/hexdoc/utils/classproperties.py +++ b/src/hexdoc/utils/classproperties.py @@ -2,27 +2,27 @@ from typing import Any, Callable, Generic, TypeVar -_T_cv = TypeVar("_T_cv", contravariant=True) +_T_co = TypeVar("_T_co", covariant=True) _R_co = TypeVar("_R_co", covariant=True) # https://discuss.python.org/t/add-a-supported-read-only-classproperty-decorator-in-the-stdlib/18090 -class ClassPropertyDescriptor(Generic[_T_cv, _R_co]): +class ClassPropertyDescriptor(Generic[_T_co, _R_co]): """Equivalent of `classmethod(property(...))`. Use `@classproperty`. Do not instantiate this class directly. """ - def __init__(self, func: classmethod[_T_cv, Any, _R_co]) -> None: + def __init__(self, func: classmethod[_T_co, Any, _R_co]) -> None: self.func = func - def __get__(self, _: Any, cls: type[_T_cv]) -> _R_co: + def __get__(self, _: Any, cls: type[_T_co]) -> _R_co: # type: ignore # HACK return self.func.__func__(cls) def classproperty( - func: Callable[[type[_T_cv]], _R_co], -) -> ClassPropertyDescriptor[_T_cv, _R_co]: - if isinstance(func, classmethod): - return ClassPropertyDescriptor(func) + func: Callable[[type[_T_co]], _R_co], +) -> ClassPropertyDescriptor[_T_co, _R_co]: + if isinstance(func, classmethod): # pyright: ignore[reportUnnecessaryIsInstance] + return ClassPropertyDescriptor(func) # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] return ClassPropertyDescriptor(classmethod(func)) diff --git a/src/hexdoc/utils/context.py b/src/hexdoc/utils/context.py index 3fe7c9a40..701fdf413 100644 --- a/src/hexdoc/utils/context.py +++ b/src/hexdoc/utils/context.py @@ -34,7 +34,7 @@ def of(cls, source: ContextSource, /) -> Self: case dict() | Context(): pass case _: - source = cast_or_raise(source.context, dict) + source = cast_or_raise(source.context, (dict, Context)) # pyright: ignore[reportUnknownVariableType] return cast_or_raise(source[cls.context_key], cls) def add_to_context(self, context: dict[str, Any], overwrite: bool = False): diff --git a/src/hexdoc/utils/deserialize/assertions.py b/src/hexdoc/utils/deserialize/assertions.py index 1738f1d74..d443e6b16 100644 --- a/src/hexdoc/utils/deserialize/assertions.py +++ b/src/hexdoc/utils/deserialize/assertions.py @@ -25,14 +25,20 @@ def isinstance_or_raise( ungenericed_classes = tuple(get_origin(t) or t for t in class_or_tuple) if not isinstance(val, ungenericed_classes): - # just in case the caller messed up the message formatting - subs = { + level = logger.getEffectiveLevel() + + # truncate absurdly long values + str_val = str(val) + if len(str_val) > 5000 and level >= logging.DEBUG: + str_val = str_val[:5000] + " [...truncated]" + + subs: dict[str, Any] = { "expected": list(class_or_tuple), "actual": type(val), - "value": val, + "value": str_val, } - if logger.getEffectiveLevel() >= logging.WARNING: + if level >= logging.WARNING: default_message = _DEFAULT_MESSAGE_SHORT else: default_message = _DEFAULT_MESSAGE_LONG @@ -40,6 +46,7 @@ def isinstance_or_raise( if message is None: raise TypeError(default_message.format(**subs)) + # just in case the caller messed up the message formatting try: raise TypeError(message.format(**subs)) except KeyError: diff --git a/src/hexdoc/utils/iterators.py b/src/hexdoc/utils/iterators.py index ac24bc41b..24a961aaf 100644 --- a/src/hexdoc/utils/iterators.py +++ b/src/hexdoc/utils/iterators.py @@ -18,6 +18,8 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Iterator[_T]: def listify(f: Callable[_P, Iterator[_T]]) -> Callable[_P, list[_T]]: + """Converts an iterator to a function returning a list.""" + @functools.wraps(f) def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> list[_T]: return list(f(*args, **kwargs)) diff --git a/src/hexdoc/utils/types.py b/src/hexdoc/utils/types.py index 0e8695e1d..8491f2b5a 100644 --- a/src/hexdoc/utils/types.py +++ b/src/hexdoc/utils/types.py @@ -1,7 +1,17 @@ import functools from abc import ABC, abstractmethod from enum import Enum, unique -from typing import Annotated, Any, Callable, Mapping, ParamSpec, Protocol, get_args +from typing import ( + Annotated, + Any, + Callable, + Mapping, + ParamSpec, + Protocol, + TypeGuard, + get_args, + overload, +) from ordered_set import OrderedSet, OrderedSetInitializer from pydantic import ( @@ -21,6 +31,8 @@ _T_float = TypeVar("_T_float", default=float) +_T_dict = TypeVar("_T_dict", bound=dict[Any, Any]) + Vec2 = tuple[_T_float, _T_float] Vec3 = tuple[_T_float, _T_float, _T_float] @@ -174,7 +186,7 @@ def typed_partial(f: Callable[_P, _R]) -> Callable[_P, Callable[_P, _R]]: def builder_builder(*partial_args: _P.args, **partial_kwargs: _P.kwargs): @functools.wraps(f) def builder(*args: _P.args, **kwargs: _P.kwargs): - return f(*partial_args, *args, **partial_kwargs, **kwargs) + return f(*partial_args, *args, **partial_kwargs, **kwargs) # type: ignore return builder @@ -187,3 +199,16 @@ def cast_nullable(value: _T) -> _T | None: At runtime, just returns the value as-is. """ return value + + +@overload +def isdict(value: _T_dict) -> TypeGuard[_T_dict]: ... + + +@overload +def isdict(value: Any) -> TypeGuard[dict[Any, Any]]: ... + + +def isdict(value: Any) -> TypeGuard[dict[Any, Any]]: + """As `isinstance(value, dict)`, but narrows to `dict[Any, Any]` instead of unknown.""" + return isinstance(value, dict) diff --git a/src/hexdoc_modonomicon/_hooks.py b/src/hexdoc_modonomicon/_hooks.py index 170b0f7a8..5965f05bd 100644 --- a/src/hexdoc_modonomicon/_hooks.py +++ b/src/hexdoc_modonomicon/_hooks.py @@ -14,7 +14,6 @@ hookimpl, ) from hexdoc.utils import ContextSource, JSONDict, cast_context - from hexdoc_modonomicon.book import Modonomicon diff --git a/src/hexdoc_modonomicon/book.py b/src/hexdoc_modonomicon/book.py index 882442e97..24518ca4f 100644 --- a/src/hexdoc_modonomicon/book.py +++ b/src/hexdoc_modonomicon/book.py @@ -1,8 +1,8 @@ -from hexdoc.core import ResourceLocation -from hexdoc.minecraft import LocalizedStr -from hexdoc.model import HexdocModel from pydantic import Field, model_validator +from hexdoc.core import LocalizedStr, ResourceLocation +from hexdoc.model import HexdocModel + def _gui_texture(name: str): return ResourceLocation("modonomicon", f"textures/gui/{name}.png") @@ -41,8 +41,8 @@ def _validate_constraints(self): if self.generate_book_item: assert self.model, "model is required if generate_book_item is True" else: - assert ( - self.custom_book_item - ), "custom_book_item is required if generate_book_item is False" + assert self.custom_book_item, ( + "custom_book_item is required if generate_book_item is False" + ) return self diff --git a/src/hexdoc_modonomicon/category.py b/src/hexdoc_modonomicon/category.py index c2bf8fc97..1d590931f 100644 --- a/src/hexdoc_modonomicon/category.py +++ b/src/hexdoc_modonomicon/category.py @@ -1,9 +1,9 @@ -from hexdoc.core import ResourceLocation -from hexdoc.minecraft import LocalizedStr -from hexdoc.minecraft.assets import ItemWithTexture, NamedTexture -from hexdoc.model import HexdocModel from pydantic import Field +from hexdoc.core import LocalizedStr, ResourceLocation +from hexdoc.graphics import ImageField, ItemImage, TextureImage +from hexdoc.model import HexdocModel + from .condition import Condition @@ -23,12 +23,12 @@ class Category(HexdocModel): """https://klikli-dev.github.io/modonomicon/docs/basics/structure/categories""" name: LocalizedStr - icon: ItemWithTexture | NamedTexture + icon: ImageField[ItemImage | TextureImage] sort_number: int = -1 condition: Condition | None = None background: ResourceLocation = _gui_texture("dark_slate_seamless") - background_parallax_layers: list[ParallaxLayer] = Field(default_factory=list) + background_parallax_layers: list[ParallaxLayer] = Field(default_factory=lambda: []) background_height: int = 512 background_width: int = 512 entry_textures: ResourceLocation = _gui_texture("entry_textures") diff --git a/src/hexdoc_modonomicon/entry.py b/src/hexdoc_modonomicon/entry.py index 8e4fc8f16..cdbbcf680 100644 --- a/src/hexdoc_modonomicon/entry.py +++ b/src/hexdoc_modonomicon/entry.py @@ -1,9 +1,9 @@ -from hexdoc.core import ResourceLocation -from hexdoc.minecraft import LocalizedStr -from hexdoc.minecraft.assets import ItemWithTexture, NamedTexture -from hexdoc.model import HexdocModel from pydantic import Field +from hexdoc.core import LocalizedStr, ResourceLocation +from hexdoc.graphics import ImageField, ItemImage, TextureImage +from hexdoc.model import HexdocModel + from .condition import Condition from .page import Page @@ -23,7 +23,7 @@ class Entry(HexdocModel): category: ResourceLocation name: LocalizedStr description: LocalizedStr - icon: ItemWithTexture | NamedTexture + icon: ImageField[ItemImage | TextureImage] x: int y: int @@ -31,7 +31,7 @@ class Entry(HexdocModel): background_u_index: int = 0 background_v_index: int = 0 condition: Condition | None = None - parents: list[EntryParent] = Field(default_factory=list) - pages: list[Page] = Field(default_factory=list) + parents: list[EntryParent] = Field(default_factory=lambda: []) + pages: list[Page] = Field(default_factory=lambda: []) category_to_open: ResourceLocation | None = None command_to_run_on_first_read: ResourceLocation | None = None diff --git a/src/hexdoc_modonomicon/page/abstract_pages.py b/src/hexdoc_modonomicon/page/abstract_pages.py index ba5f5e7a4..da21d577e 100644 --- a/src/hexdoc_modonomicon/page/abstract_pages.py +++ b/src/hexdoc_modonomicon/page/abstract_pages.py @@ -1,9 +1,10 @@ from typing import Unpack +from pydantic import ConfigDict + from hexdoc.core import ResourceLocation from hexdoc.model import TypeTaggedTemplate from hexdoc.utils import Inherit, InheritType, NoValue, classproperty -from pydantic import ConfigDict class Page(TypeTaggedTemplate, type=None): diff --git a/submodules/HexMod b/submodules/HexMod deleted file mode 160000 index 900e6d14f..000000000 --- a/submodules/HexMod +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 900e6d14f33458f97faa79a03ef8c947d3406509 diff --git a/submodules/HexMod_1.19 b/submodules/HexMod_1.19 new file mode 160000 index 000000000..82c95e946 --- /dev/null +++ b/submodules/HexMod_1.19 @@ -0,0 +1 @@ +Subproject commit 82c95e94626bde2de502a0e0d8f92bd02c5e7ccd diff --git a/submodules/HexMod_main b/submodules/HexMod_main new file mode 160000 index 000000000..fe17c7ea3 --- /dev/null +++ b/submodules/HexMod_main @@ -0,0 +1 @@ +Subproject commit fe17c7ea3b011680a2a7978b2c3eb815e348cff5 diff --git a/test/conftest.py b/test/conftest.py index 6e8a27649..64e397b18 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,15 +1,17 @@ +import os from fnmatch import fnmatch from pathlib import Path from typing import Any import pytest -from hexdoc.core.properties import Properties -from hexdoc.plugin import PluginManager from pytest import MonkeyPatch from syrupy.assertion import SnapshotAssertion from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode from syrupy.types import SerializableData, SerializedData +from hexdoc.core.properties import Properties +from hexdoc.plugin import PluginManager + collect_ignore = [ "noxfile.py", ] @@ -72,7 +74,9 @@ def env_overrides(): @pytest.fixture(scope="session") def hexcasting_props_file(): - return Path("submodules/HexMod/doc/hexdoc.toml") + if (submodule := os.getenv("TEST_SUBMODULE")) is None: + raise ValueError("Environment variable not set: TEST_SUBMODULE") + return Path(f"{submodule}/doc/hexdoc.toml") @pytest.fixture(autouse=True, scope="session") diff --git a/test/core/test_resource.py b/test/core/test_resource.py index 86fef5837..2e078ed46 100644 --- a/test/core/test_resource.py +++ b/test/core/test_resource.py @@ -1,7 +1,8 @@ import pytest -from hexdoc.core.resource import AssumeTag, ItemStack, ResLoc, ResourceLocation from pydantic import TypeAdapter +from hexdoc.core.resource import AssumeTag, ItemStack, ResLoc, ResourceLocation + resource_locations: list[tuple[str, ResourceLocation, str]] = [ ( "stone", diff --git a/test/graphics/model/test_element.py b/test/graphics/model/test_element.py new file mode 100644 index 000000000..add796fdf --- /dev/null +++ b/test/graphics/model/test_element.py @@ -0,0 +1,50 @@ +from typing import Any + +import pytest +from pydantic import TypeAdapter + +from hexdoc.graphics.model.element import ElementFaceTextureVariable, TextureVariable + + +@pytest.mark.parametrize( + ["type_", "value", "want"], + [ + [TextureVariable, "#a", "#a"], + [TextureVariable, "#all", "#all"], + [TextureVariable, "#ALL", "#ALL"], + [TextureVariable, "#_", "#_"], + [ElementFaceTextureVariable, "#a", "#a"], + [ElementFaceTextureVariable, "#all", "#all"], + [ElementFaceTextureVariable, "#ALL", "#ALL"], + [ElementFaceTextureVariable, "#_", "#_"], + [ElementFaceTextureVariable, "a", "#a"], + [ElementFaceTextureVariable, "all", "#all"], + [ElementFaceTextureVariable, "ALL", "#ALL"], + [ElementFaceTextureVariable, "_", "#_"], + ], +) +def test_texture_variable_validator_accepts_valid(type_: Any, value: str, want: str): + ta = TypeAdapter(type_) + got = ta.validate_python(value) + assert got == want + + +@pytest.mark.parametrize( + ["type_", "value"], + [ + [TextureVariable, ""], + [TextureVariable, "#"], + [TextureVariable, "##"], + [TextureVariable, "##all"], + [TextureVariable, "a"], + [TextureVariable, "all"], + [ElementFaceTextureVariable, ""], + [ElementFaceTextureVariable, "#"], + [ElementFaceTextureVariable, "##"], + [ElementFaceTextureVariable, "##all"], + ], +) +def test_texture_variable_validator_throws_invalid(type_: Any, value: str): + ta = TypeAdapter(type_) + with pytest.raises(ValueError, match="Malformed texture variable"): + ta.validate_python(value) diff --git a/test/hooks/test_manager.py b/test/hooks/test_manager.py index df766c6a8..435f0c2ff 100644 --- a/test/hooks/test_manager.py +++ b/test/hooks/test_manager.py @@ -4,6 +4,10 @@ from typing import Any, Callable import pytest +from jinja2.sandbox import SandboxedEnvironment +from markupsafe import Markup +from pytest import FixtureRequest, Mark + from hexdoc.jinja.render import create_jinja_env from hexdoc.plugin import ( ModPlugin, @@ -12,9 +16,6 @@ UpdateTemplateArgsImpl, hookimpl, ) -from jinja2.sandbox import SandboxedEnvironment -from markupsafe import Markup -from pytest import FixtureRequest, Mark RenderTemplate = Callable[[list[str]], str] diff --git a/test/integration/__snapshots__/test_copier.ambr b/test/integration/__snapshots__/test_copier.ambr index 1cfd5332d..8f600929f 100644 --- a/test/integration/__snapshots__/test_copier.ambr +++ b/test/integration/__snapshots__/test_copier.ambr @@ -60,6 +60,237 @@ 'v/latest/main/en_us/patterns/index.html', 'v/latest/main/en_us/patterns/spells', 'v/latest/main/en_us/patterns/spells/index.html', - 'v/latest/main/en_us/textures.css', + 'v/latest/main/renders', + 'v/latest/main/renders/farmersdelight', + 'v/latest/main/renders/farmersdelight/item', + 'v/latest/main/renders/farmersdelight/item/skillet.png', + 'v/latest/main/renders/hexcasting', + 'v/latest/main/renders/hexcasting/block', + 'v/latest/main/renders/hexcasting/block/quenched_allay_0.png', + 'v/latest/main/renders/hexcasting/item', + 'v/latest/main/renders/hexcasting/item/abacus.png', + 'v/latest/main/renders/hexcasting/item/akashic_bookshelf.png', + 'v/latest/main/renders/hexcasting/item/akashic_connector.png', + 'v/latest/main/renders/hexcasting/item/akashic_record.png', + 'v/latest/main/renders/hexcasting/item/amethyst_dust.png', + 'v/latest/main/renders/hexcasting/item/amethyst_dust_block.png', + 'v/latest/main/renders/hexcasting/item/amethyst_sconce.png', + 'v/latest/main/renders/hexcasting/item/amethyst_tiles.png', + 'v/latest/main/renders/hexcasting/item/ancient_scroll_paper.png', + 'v/latest/main/renders/hexcasting/item/ancient_scroll_paper_lantern.png', + 'v/latest/main/renders/hexcasting/item/artifact.png', + 'v/latest/main/renders/hexcasting/item/charged_amethyst.png', + 'v/latest/main/renders/hexcasting/item/cypher.png', + 'v/latest/main/renders/hexcasting/item/default_colorizer.gif', + 'v/latest/main/renders/hexcasting/item/directrix', + 'v/latest/main/renders/hexcasting/item/directrix/boolean.png', + 'v/latest/main/renders/hexcasting/item/directrix/empty.png', + 'v/latest/main/renders/hexcasting/item/directrix/redstone.png', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_blue.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_brown.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_cyan.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_gray.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_green.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_light_blue.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_light_gray.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_lime.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_magenta.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_orange.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_pink.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_purple.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_red.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_white.gif', + 'v/latest/main/renders/hexcasting/item/dye_colorizer_yellow.gif', + 'v/latest/main/renders/hexcasting/item/edified_button.png', + 'v/latest/main/renders/hexcasting/item/edified_door.png', + 'v/latest/main/renders/hexcasting/item/edified_log.png', + 'v/latest/main/renders/hexcasting/item/edified_log_amethyst.png', + 'v/latest/main/renders/hexcasting/item/edified_log_aventurine.png', + 'v/latest/main/renders/hexcasting/item/edified_log_citrine.png', + 'v/latest/main/renders/hexcasting/item/edified_log_purple.png', + 'v/latest/main/renders/hexcasting/item/edified_panel.png', + 'v/latest/main/renders/hexcasting/item/edified_planks.png', + 'v/latest/main/renders/hexcasting/item/edified_pressure_plate.png', + 'v/latest/main/renders/hexcasting/item/edified_slab.png', + 'v/latest/main/renders/hexcasting/item/edified_stairs.png', + 'v/latest/main/renders/hexcasting/item/edified_tile.png', + 'v/latest/main/renders/hexcasting/item/edified_trapdoor.png', + 'v/latest/main/renders/hexcasting/item/edified_wood.png', + 'v/latest/main/renders/hexcasting/item/focus.png', + 'v/latest/main/renders/hexcasting/item/impetus', + 'v/latest/main/renders/hexcasting/item/impetus/empty.png', + 'v/latest/main/renders/hexcasting/item/impetus/look.png', + 'v/latest/main/renders/hexcasting/item/impetus/redstone.png', + 'v/latest/main/renders/hexcasting/item/impetus/rightclick.png', + 'v/latest/main/renders/hexcasting/item/jeweler_hammer.png', + 'v/latest/main/renders/hexcasting/item/lens.png', + 'v/latest/main/renders/hexcasting/item/lore_fragment.png', + 'v/latest/main/renders/hexcasting/item/phial_small_0.png', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_agender.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_aroace.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_aromantic.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_asexual.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_bisexual.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_demiboy.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_demigirl.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_gay.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_genderfluid.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_genderqueer.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_intersex.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_lesbian.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_nonbinary.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_pansexual.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_plural.gif', + 'v/latest/main/renders/hexcasting/item/pride_colorizer_transgender.gif', + 'v/latest/main/renders/hexcasting/item/quenched_shard_0.png', + 'v/latest/main/renders/hexcasting/item/scroll_paper.png', + 'v/latest/main/renders/hexcasting/item/scroll_paper_lantern.png', + 'v/latest/main/renders/hexcasting/item/scroll_pristine_large.png', + 'v/latest/main/renders/hexcasting/item/scroll_pristine_medium.png', + 'v/latest/main/renders/hexcasting/item/scroll_pristine_small.png', + 'v/latest/main/renders/hexcasting/item/slate_blank.png', + 'v/latest/main/renders/hexcasting/item/slate_block.png', + 'v/latest/main/renders/hexcasting/item/spellbook.png', + 'v/latest/main/renders/hexcasting/item/staff', + 'v/latest/main/renders/hexcasting/item/staff/acacia.png', + 'v/latest/main/renders/hexcasting/item/staff/birch.png', + 'v/latest/main/renders/hexcasting/item/staff/crimson.png', + 'v/latest/main/renders/hexcasting/item/staff/dark_oak.png', + 'v/latest/main/renders/hexcasting/item/staff/edified.png', + 'v/latest/main/renders/hexcasting/item/staff/jungle.png', + 'v/latest/main/renders/hexcasting/item/staff/mangrove.png', + 'v/latest/main/renders/hexcasting/item/staff/mindsplice.png', + 'v/latest/main/renders/hexcasting/item/staff/oak.png', + 'v/latest/main/renders/hexcasting/item/staff/quenched_0.png', + 'v/latest/main/renders/hexcasting/item/staff/spruce.png', + 'v/latest/main/renders/hexcasting/item/staff/warped.png', + 'v/latest/main/renders/hexcasting/item/stripped_edified_log.png', + 'v/latest/main/renders/hexcasting/item/stripped_edified_wood.png', + 'v/latest/main/renders/hexcasting/item/thought_knot.png', + 'v/latest/main/renders/hexcasting/item/trinket.png', + 'v/latest/main/renders/hexcasting/item/uuid_colorizer.gif', + 'v/latest/main/renders/hexcasting/textures', + 'v/latest/main/renders/hexcasting/textures/gui', + 'v/latest/main/renders/hexcasting/textures/gui/entries', + 'v/latest/main/renders/hexcasting/textures/gui/entries/spell_circle.png', + 'v/latest/main/renders/hexdoc', + 'v/latest/main/renders/hexdoc/textures', + 'v/latest/main/renders/hexdoc/textures/item', + 'v/latest/main/renders/hexdoc/textures/item/tag.png', + 'v/latest/main/renders/minecraft', + 'v/latest/main/renders/minecraft/item', + 'v/latest/main/renders/minecraft/item/acacia_planks.png', + 'v/latest/main/renders/minecraft/item/amethyst_block.png', + 'v/latest/main/renders/minecraft/item/amethyst_shard.png', + 'v/latest/main/renders/minecraft/item/arrow.png', + 'v/latest/main/renders/minecraft/item/azalea.png', + 'v/latest/main/renders/minecraft/item/beacon.png', + 'v/latest/main/renders/minecraft/item/bedrock.png', + 'v/latest/main/renders/minecraft/item/birch_planks.png', + 'v/latest/main/renders/minecraft/item/blaze_rod.png', + 'v/latest/main/renders/minecraft/item/blue_dye.png', + 'v/latest/main/renders/minecraft/item/book.png', + 'v/latest/main/renders/minecraft/item/bookshelf.png', + 'v/latest/main/renders/minecraft/item/bread.png', + 'v/latest/main/renders/minecraft/item/brown_dye.png', + 'v/latest/main/renders/minecraft/item/budding_amethyst.png', + 'v/latest/main/renders/minecraft/item/bundle.png', + 'v/latest/main/renders/minecraft/item/carrot.png', + 'v/latest/main/renders/minecraft/item/chain.png', + 'v/latest/main/renders/minecraft/item/chorus_fruit.png', + 'v/latest/main/renders/minecraft/item/cobblestone.png', + 'v/latest/main/renders/minecraft/item/comparator.png', + 'v/latest/main/renders/minecraft/item/copper_ingot.png', + 'v/latest/main/renders/minecraft/item/crimson_planks.png', + 'v/latest/main/renders/minecraft/item/cyan_dye.png', + 'v/latest/main/renders/minecraft/item/dark_oak_planks.png', + 'v/latest/main/renders/minecraft/item/deepslate.png', + 'v/latest/main/renders/minecraft/item/egg.png', + 'v/latest/main/renders/minecraft/item/elytra.png', + 'v/latest/main/renders/minecraft/item/emerald.png', + 'v/latest/main/renders/minecraft/item/ender_pearl.png', + 'v/latest/main/renders/minecraft/item/feather.png', + 'v/latest/main/renders/minecraft/item/flint_and_steel.png', + 'v/latest/main/renders/minecraft/item/glass.png', + 'v/latest/main/renders/minecraft/item/glass_bottle.png', + 'v/latest/main/renders/minecraft/item/glowstone_dust.png', + 'v/latest/main/renders/minecraft/item/gold_ingot.png', + 'v/latest/main/renders/minecraft/item/gold_nugget.png', + 'v/latest/main/renders/minecraft/item/gray_dye.png', + 'v/latest/main/renders/minecraft/item/green_dye.png', + 'v/latest/main/renders/minecraft/item/honeycomb.png', + 'v/latest/main/renders/minecraft/item/iron_bars.png', + 'v/latest/main/renders/minecraft/item/iron_ingot.png', + 'v/latest/main/renders/minecraft/item/iron_nugget.png', + 'v/latest/main/renders/minecraft/item/item_frame.png', + 'v/latest/main/renders/minecraft/item/jungle_planks.png', + 'v/latest/main/renders/minecraft/item/knowledge_book.png', + 'v/latest/main/renders/minecraft/item/lava_bucket.png', + 'v/latest/main/renders/minecraft/item/leather.png', + 'v/latest/main/renders/minecraft/item/light_blue_dye.png', + 'v/latest/main/renders/minecraft/item/light_gray_dye.png', + 'v/latest/main/renders/minecraft/item/lime_dye.png', + 'v/latest/main/renders/minecraft/item/lodestone.png', + 'v/latest/main/renders/minecraft/item/magenta_dye.png', + 'v/latest/main/renders/minecraft/item/mangrove_planks.png', + 'v/latest/main/renders/minecraft/item/moss_block.png', + 'v/latest/main/renders/minecraft/item/music_disc_11.png', + 'v/latest/main/renders/minecraft/item/name_tag.png', + 'v/latest/main/renders/minecraft/item/nether_star.png', + 'v/latest/main/renders/minecraft/item/oak_planks.png', + 'v/latest/main/renders/minecraft/item/oak_sign.png', + 'v/latest/main/renders/minecraft/item/observer.png', + 'v/latest/main/renders/minecraft/item/orange_dye.png', + 'v/latest/main/renders/minecraft/item/paper.png', + 'v/latest/main/renders/minecraft/item/pig_spawn_egg.png', + 'v/latest/main/renders/minecraft/item/pink_dye.png', + 'v/latest/main/renders/minecraft/item/piston.png', + 'v/latest/main/renders/minecraft/item/potion.png', + 'v/latest/main/renders/minecraft/item/purple_candle.png', + 'v/latest/main/renders/minecraft/item/purple_dye.png', + 'v/latest/main/renders/minecraft/item/purpur_block.png', + 'v/latest/main/renders/minecraft/item/quartz.png', + 'v/latest/main/renders/minecraft/item/raw_copper.png', + 'v/latest/main/renders/minecraft/item/raw_iron.png', + 'v/latest/main/renders/minecraft/item/red_dye.png', + 'v/latest/main/renders/minecraft/item/red_mushroom.png', + 'v/latest/main/renders/minecraft/item/repeater.png', + 'v/latest/main/renders/minecraft/item/shulker_box.png', + 'v/latest/main/renders/minecraft/item/skeleton_skull.png', + 'v/latest/main/renders/minecraft/item/spruce_planks.png', + 'v/latest/main/renders/minecraft/item/spyglass.png', + 'v/latest/main/renders/minecraft/item/stick.png', + 'v/latest/main/renders/minecraft/item/stone_brick_wall.png', + 'v/latest/main/renders/minecraft/item/string.png', + 'v/latest/main/renders/minecraft/item/torch.png', + 'v/latest/main/renders/minecraft/item/warped_planks.png', + 'v/latest/main/renders/minecraft/item/water_bucket.png', + 'v/latest/main/renders/minecraft/item/wheat.png', + 'v/latest/main/renders/minecraft/item/wheat_seeds.png', + 'v/latest/main/renders/minecraft/item/white_dye.png', + 'v/latest/main/renders/minecraft/item/wither_skeleton_skull.png', + 'v/latest/main/renders/minecraft/item/wooden_pickaxe.png', + 'v/latest/main/renders/minecraft/item/writable_book.png', + 'v/latest/main/renders/minecraft/item/yellow_dye.png', + 'v/latest/main/renders/minecraft/textures', + 'v/latest/main/renders/minecraft/textures/entities', + 'v/latest/main/renders/minecraft/textures/entities/allay.png', + 'v/latest/main/renders/minecraft/textures/entities/villagers', + 'v/latest/main/renders/minecraft/textures/entities/villagers/cleric.png', + 'v/latest/main/renders/minecraft/textures/entities/villagers/librarian.png', + 'v/latest/main/renders/minecraft/textures/entities/villagers/mason.png', + 'v/latest/main/renders/minecraft/textures/entities/villagers/none.png', + 'v/latest/main/renders/minecraft/textures/entities/villagers/toolsmith.png', + 'v/latest/main/renders/minecraft/textures/gui', + 'v/latest/main/renders/minecraft/textures/gui/hexdoc', + 'v/latest/main/renders/minecraft/textures/gui/hexdoc/crafting_table.png', + 'v/latest/main/renders/minecraft/textures/item', + 'v/latest/main/renders/minecraft/textures/item/enchanted_book.png', + 'v/latest/main/renders/minecraft/textures/mob_effect', + 'v/latest/main/renders/minecraft/textures/mob_effect/blindness.png', + 'v/latest/main/renders/minecraft/textures/mob_effect/conduit_power.png', + 'v/latest/main/renders/minecraft/textures/mob_effect/levitation.png', + 'v/latest/main/renders/minecraft/textures/mob_effect/nausea.png', + 'v/latest/main/renders/minecraft/textures/mob_effect/poison.png', ]) # --- diff --git a/test/integration/__snapshots__/test_copier/test_index[vlatestmainen_usindex.html].raw b/test/integration/__snapshots__/test_copier/test_index[vlatestmainen_usindex.html].raw index 08011367d..204277bd0 100644 --- a/test/integration/__snapshots__/test_copier/test_index[vlatestmainen_usindex.html].raw +++ b/test/integration/__snapshots__/test_copier/test_index[vlatestmainen_usindex.html].raw @@ -139,38 +139,38 @@
Amethyst Shard Getting Started + title="Amethyst Shard" + alt="Amethyst Shard" + src="../../../../v/latest/main/renders/minecraft/item/amethyst_shard.png" + loading="lazy" + class="item-texture texture pixelated " +> Getting Started
Amethyst Shard Mod Book + title="Amethyst Shard" + alt="Amethyst Shard" + src="../../../../v/latest/main/renders/minecraft/item/amethyst_shard.png" + loading="lazy" + class="item-texture texture pixelated " +> Mod Book
@@ -178,12 +178,12 @@