"""
Packages the add-on into a zip Blender will install.
python3 blender/build_addon.py --out /tmp/ifs_fractal.zip
Blender installs a zip whose TOP LEVEL is a single directory containing
`__init__.py`, and the directory name becomes the module name. This repo's folder
is called `blender/`, which would be a terrible module name and would collide with
anything else named the same, so the archive renames it to `ifs_fractal/`.
The zip is SELF-CONTAINED: it carries the 46 transform plugins, the kernel
template, the includes and the templates under `assets/`, because an installed
add-on sits in Blender's addons directory where the repo is nowhere to be found.
Run from a checkout the add-on finds no bundled `assets/` and reads the repo's
live files instead, which is what keeps development honest about the plugins it
actually compiles.
Longer term the cleaner boundary is baking the generated kernel at build time and
shipping only that - see the note in ifs_codegen. Shipping the sources keeps the
add-on able to recompile, which is worth more while the kernel is still moving.
"""
from __future__ import annotations
import argparse
import os
import zipfile
MODULE = "ifs_fractal"
# Everything the add-on needs at runtime. Tests are excluded: they are for the
# repo, not the installed artifact, and they import a .NET-free corpus that will
# not be present beside an installed add-on.
RUNTIME_FILES = (
"__init__.py",
"ifs_codegen.py",
"ifs_layout.py",
"ifs_host.py",
"ifs_document.py",
"ifs_render.py",
"ifs_viewport.py",
"ifs_volume.py",
"ifs_points.py",
"ifs_hdri.py",
"ifs_splat.py",
"ifs_nodes.py",
)
# Repo-relative asset trees, copied under `assets/` in the archive. The kernel
# template must come too: the add-on compiles it at load time.
ASSET_DIRS = (
("Transforms", ".ifstf"),
(os.path.join("WpfDisplay", "Library", "Includes"), None),
(os.path.join("WpfDisplay", "Library", "Templates"), ".ifsjson"),
(os.path.join("IFSEngine", "Rendering", "Shaders"), ".shader"),
)
def build(source_dir: str, out_path: str) -> str:
missing = [f for f in RUNTIME_FILES
if not os.path.isfile(os.path.join(source_dir, f))]
if missing:
raise FileNotFoundError(f"missing runtime files: {missing}")
repo = os.path.dirname(source_dir)
count = 0
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as archive:
for name in RUNTIME_FILES:
archive.write(os.path.join(source_dir, name), f"{MODULE}/{name}")
count += 1
for rel, suffix in ASSET_DIRS:
absolute = os.path.join(repo, rel)
if not os.path.isdir(absolute):
raise FileNotFoundError(f"asset directory missing: {absolute}")
for entry in sorted(os.listdir(absolute)):
path = os.path.join(absolute, entry)
if not os.path.isfile(path):
continue
if suffix and not entry.endswith(suffix):
continue
archive.write(path, f"{MODULE}/assets/{rel}/{entry}")
count += 1
print(f"packed {count} files")
return out_path
def main(argv: list[str] | None = None) -> int:
here = os.path.dirname(os.path.abspath(__file__))
parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
parser.add_argument("--out", default=os.path.join(here, "ifs_fractal.zip"))
args = parser.parse_args(argv)
path = build(here, args.out)
size = os.path.getsize(path)
print(f"wrote {path} ({size / 1024:.1f} KB, {len(RUNTIME_FILES)} files)")
print(f"install: Edit > Preferences > Add-ons > Install from Disk -> {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())