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 @@
+
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) -}}
{{ header_tag }}>
{%- 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) -%}
+
+{%- 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 -%}
-
- {%- 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() %}
-

- {{ 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 %}
-

- {{ 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="") -%}
{%- 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 @@
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
This is the online version of the {} documentation.
+ title="Nausea"
+ alt="Nausea"
+ src="../../../../v/latest/main/renders/minecraft/textures/mob_effect/nausea.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> This is the online version of the {} documentation.
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
This is the online version of the {} documentation.
+ title="Nausea"
+ alt="Nausea"
+ src="../../../../v/latest/main/renders/minecraft/textures/mob_effect/nausea.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> This is the online version of the {} documentation.
@@ -178,12 +178,12 @@
WHAT DID I SEE
+ title="Blindness"
+ alt="Blindness"
+ src="../../../../v/latest/main/renders/minecraft/textures/mob_effect/blindness.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> WHAT DID I SEE
Hex Casting
+ title="Oak Staff"
+ alt="Oak Staff"
+ src="../../../../v/latest/main/renders/hexcasting/item/oak_staff.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Hex Casting
Hexing 101
+ title="Edified Staff"
+ alt="Edified Staff"
+ src="../../../../v/latest/main/renders/hexcasting/item/edified_staff.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Hexing 101
A Primer on Vectors
+ title="Arrow"
+ alt="Arrow"
+ src="../../../../v/latest/main/renders/minecraft/item/arrow.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> A Primer on Vectors
Mishaps
+ title="Poison"
+ alt="Poison"
+ src="../../../../v/latest/main/renders/minecraft/textures/mob_effect/poison.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Mishaps
Stacks
+ title="Piston"
+ alt="Piston"
+ src="../../../../v/latest/main/renders/minecraft/item/piston.png"
+ loading="lazy"
+ class="item-texture texture "
+> Stacks
Naming Actions
+ title="Name Tag"
+ alt="Name Tag"
+ src="../../../../v/latest/main/renders/minecraft/item/name_tag.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Naming Actions
Influences
+ title="Spyglass"
+ alt="Spyglass"
+ src="../../../../v/latest/main/renders/minecraft/item/spyglass.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Influences
Enlightened Mishaps
+ title="Flint and Steel"
+ alt="Flint and Steel"
+ src="../../../../v/latest/main/renders/minecraft/item/flint_and_steel.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Enlightened Mishaps
Items
+ title="Focus"
+ alt="Focus"
+ src="../../../../v/latest/main/renders/hexcasting/item/focus.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Items
The Great Work
+ title="Music Disc"
+ alt="Music Disc"
+ src="../../../../v/latest/main/renders/minecraft/item/music_disc_11.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> The Great Work
The Work
+ title="Nether Star"
+ alt="Nether Star"
+ src="../../../../v/latest/main/renders/minecraft/item/nether_star.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> The Work
On the Flaying of Minds
+ title="Wither Skeleton Skull"
+ alt="Wither Skeleton Skull"
+ src="../../../../v/latest/main/renders/minecraft/item/wither_skeleton_skull.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> On the Flaying of Minds
Spell Circles
+ title="Lodestone"
+ alt="Lodestone"
+ src="../../../../v/latest/main/renders/minecraft/item/lodestone.png"
+ loading="lazy"
+ class="item-texture texture "
+> Spell Circles
Impetuses
+ title="Toolsmith Impetus"
+ alt="Toolsmith Impetus"
+ src="../../../../v/latest/main/renders/hexcasting/item/impetus_rightclick.png"
+ loading="lazy"
+ class="item-texture texture "
+> Impetuses
Directrices
+ title="Mason Directrix"
+ alt="Mason Directrix"
+ src="../../../../v/latest/main/renders/hexcasting/item/directrix_redstone.png"
+ loading="lazy"
+ class="item-texture texture "
+> Directrices
Akashic Libraries
+ title="Akashic Record"
+ alt="Akashic Record"
+ src="../../../../v/latest/main/renders/hexcasting/item/akashic_record.png"
+ loading="lazy"
+ class="item-texture texture "
+> Akashic Libraries
Lore
+ title="Lore Fragment"
+ alt="Lore Fragment"
+ src="../../../../v/latest/main/renders/hexcasting/item/lore_fragment.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Lore
Cardamom Steles, #1
+ title="Lore Fragment"
+ alt="Lore Fragment"
+ src="../../../../v/latest/main/renders/hexcasting/item/lore_fragment.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Cardamom Steles, #1
Cardamom Steles, #2
+ title="Lore Fragment"
+ alt="Lore Fragment"
+ src="../../../../v/latest/main/renders/hexcasting/item/lore_fragment.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Cardamom Steles, #2
Cardamom Steles, #3, 1/2
+ title="Lore Fragment"
+ alt="Lore Fragment"
+ src="../../../../v/latest/main/renders/hexcasting/item/lore_fragment.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Cardamom Steles, #3, 1/2
Cardamom Steles, #3, 2/2
+ title="Lore Fragment"
+ alt="Lore Fragment"
+ src="../../../../v/latest/main/renders/hexcasting/item/lore_fragment.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Cardamom Steles, #3, 2/2
Cardamom Steles, #4
+ title="Lore Fragment"
+ alt="Lore Fragment"
+ src="../../../../v/latest/main/renders/hexcasting/item/lore_fragment.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Cardamom Steles, #4
Wooleye Instance Notes
+ title="Lore Fragment"
+ alt="Lore Fragment"
+ src="../../../../v/latest/main/renders/hexcasting/item/lore_fragment.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Wooleye Instance Notes
Wooleye Interview Logs
+ title="Lore Fragment"
+ alt="Lore Fragment"
+ src="../../../../v/latest/main/renders/hexcasting/item/lore_fragment.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Wooleye Interview Logs
Restoration Log #72
+ title="Lore Fragment"
+ alt="Lore Fragment"
+ src="../../../../v/latest/main/renders/hexcasting/item/lore_fragment.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Restoration Log #72
Cross-Mod Compatibility
+ title="Chain"
+ alt="Chain"
+ src="../../../../v/latest/main/renders/minecraft/item/chain.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Cross-Mod Compatibility
Cross-Mod Compatibility
+ title="Chain"
+ alt="Chain"
+ src="../../../../v/latest/main/renders/minecraft/item/chain.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Cross-Mod Compatibility
Gravity Changer
+ title="Anvil"
+ alt="Anvil"
+ src="../../../../v/latest/main/renders/minecraft/item/anvil.png"
+ loading="lazy"
+ class="item-texture texture "
+> Gravity Changer
Pehkui
+ title="Red Mushroom"
+ alt="Red Mushroom"
+ src="../../../../v/latest/main/renders/minecraft/item/red_mushroom.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Pehkui
Patterns
+ title="Bookshelf"
+ alt="Bookshelf"
+ src="../../../../v/latest/main/renders/minecraft/item/bookshelf.png"
+ loading="lazy"
+ class="item-texture texture "
+> Patterns
How to Read this Section
+ title="Knowledge Book"
+ alt="Knowledge Book"
+ src="../../../../v/latest/main/renders/minecraft/item/knowledge_book.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> How to Read this Section
Basic Patterns
+ title="Wooden Pickaxe"
+ alt="Wooden Pickaxe"
+ src="../../../../v/latest/main/renders/minecraft/item/wooden_pickaxe.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Basic Patterns
Number Literals
+ title="Stick"
+ alt="Stick"
+ src="../../../../v/latest/main/renders/minecraft/item/stick.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Number Literals
Mathematics
+ title="Blaze Rod"
+ alt="Blaze Rod"
+ src="../../../../v/latest/main/renders/minecraft/item/blaze_rod.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Mathematics
Constants
+ title="Bedrock"
+ alt="Bedrock"
+ src="../../../../v/latest/main/renders/minecraft/item/bedrock.png"
+ loading="lazy"
+ class="item-texture texture "
+> Constants
Stack Manipulation
+ title="Piston"
+ alt="Piston"
+ src="../../../../v/latest/main/renders/minecraft/item/piston.png"
+ loading="lazy"
+ class="item-texture texture "
+> Stack Manipulation
Logical Operators
+ title="Redstone Comparator"
+ alt="Redstone Comparator"
+ src="../../../../v/latest/main/renders/minecraft/item/comparator.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Logical Operators
Entities
+ title="Pig Spawn Egg"
+ alt="Pig Spawn Egg"
+ src="../../../../v/latest/main/renders/minecraft/item/pig_spawn_egg.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Entities
List Manipulation
+ title="Oak Sign"
+ alt="Oak Sign"
+ src="../../../../v/latest/main/renders/minecraft/item/oak_sign.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> List Manipulation
Patterns as Iotas
+ title="Emerald"
+ alt="Emerald"
+ src="../../../../v/latest/main/renders/minecraft/item/emerald.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Patterns as Iotas
Reading and Writing
+ title="Book and Quill"
+ alt="Book and Quill"
+ src="../../../../v/latest/main/renders/minecraft/item/writable_book.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Reading and Writing
Advanced Mathematics
+ title="Nether Quartz"
+ alt="Nether Quartz"
+ src="../../../../v/latest/main/renders/minecraft/item/quartz.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Advanced Mathematics
Sets
+ title="Bundle"
+ alt="Bundle"
+ src="../../../../v/latest/main/renders/minecraft/item/bundle.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Sets
Meta-evaluation
+ title="Shulker Box"
+ alt="Shulker Box"
+ src="../../../../v/latest/main/renders/minecraft/item/shulker_box.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Meta-evaluation
Spell Circle Patterns
+ title="Empty Impetus"
+ alt="Empty Impetus"
+ src="../../../../v/latest/main/renders/hexcasting/item/empty_impetus.png"
+ loading="lazy"
+ class="item-texture texture "
+> Spell Circle Patterns
Akashic Patterns
+ title="Akashic Bookshelf"
+ alt="Akashic Bookshelf"
+ src="../../../../v/latest/main/renders/hexcasting/item/akashic_bookshelf.png"
+ loading="lazy"
+ class="item-texture texture "
+> Akashic Patterns
Spells
+ title="Enchanted Book"
+ alt="Enchanted Book"
+ src="../../../../v/latest/main/renders/minecraft/textures/item/enchanted_book.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Spells
Great Spells
+ title="Conduit Power"
+ alt="Conduit Power"
+ src="../../../../v/latest/main/renders/minecraft/textures/mob_effect/conduit_power.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Great Spells
Create Lava
+ title="Lava Bucket"
+ alt="Lava Bucket"
+ src="../../../../v/latest/main/renders/minecraft/item/lava_bucket.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Create Lava
Weather Manipulation
+ title="Levitation"
+ alt="Levitation"
+ src="../../../../v/latest/main/renders/minecraft/textures/mob_effect/levitation.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Weather Manipulation
Flight
+ title="Feather"
+ alt="Feather"
+ src="../../../../v/latest/main/renders/minecraft/item/feather.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Flight
Greater Teleport
+ title="Ender Pearl"
+ alt="Ender Pearl"
+ src="../../../../v/latest/main/renders/minecraft/item/ender_pearl.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Greater Teleport
Zeniths
+ title="Potion"
+ alt="Potion"
+ src="../../../../v/latest/main/renders/minecraft/item/potion.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Zeniths
Summon Greater Sentinel
+ title="Beacon"
+ alt="Beacon"
+ src="../../../../v/latest/main/renders/minecraft/item/beacon.png"
+ loading="lazy"
+ class="item-texture texture "
+> Summon Greater Sentinel
Craft Phial
+ title="Phial of Media"
+ alt="Phial of Media"
+ src="../../../../v/latest/main/renders/hexcasting/item/phial_small_0.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Craft Phial
Flay Mind
+ title="Skeleton Skull"
+ alt="Skeleton Skull"
+ src="../../../../v/latest/main/renders/minecraft/item/skeleton_skull.png"
+ loading="lazy"
+ class="item-texture texture pixelated "
+> Flay Mind
@@ -754,12 +756,12 @@