diff --git a/airdef-mod/source-assets/README.md b/airdef-mod/source-assets/README.md new file mode 100644 index 0000000..65ccd60 --- /dev/null +++ b/airdef-mod/source-assets/README.md @@ -0,0 +1,11 @@ +# Authored source assets + +Place artist-authored overrides under `assets/airdef/` in this directory, preserving the runtime-relative path. +For example, an authored icon at `source-assets/assets/airdef/textures/item/deploy_gepard.png` replaces the +procedural icon at the generated runtime path while the source copy remains untouched. The asset generator +copies this tree into its temporary staging tree, validates the merged runtime assets, then installs them using +the generated manifest. Do not edit generated files under `airdef-mod/assets/` directly; an untracked or +hand-edited output is treated as a conflict and left untouched. + +This directory is intentionally kept separate from runtime and generated output. It currently contains no +asset payloads. diff --git a/docs/REPO-MAP.md b/docs/REPO-MAP.md index 186fdf5..6063ac8 100644 --- a/docs/REPO-MAP.md +++ b/docs/REPO-MAP.md @@ -40,3 +40,15 @@ Первая версия мода: датапак на командах (`legacy/airdef-datapack/`) и контент-мод (`legacy/airdef-content/`) с генератором `legacy/gen_content.py`. Ничего из текущей сборки на него не ссылается: ни скрипты в `tools/`, ни `install.sh`, ни `Cargo.toml`, ни исходники мода. Не собирается и не устанавливается. Оставлен как история. Не редактировать и не удалять без отдельного решения. `legacy/airdef-content/data/` сейчас не отслеживается git и не входит в эту карту. + +## Авторские и сгенерированные ресурсы + +- `tools/gen_models.py` генерирует геометрию моделей, блоковые состояния, item registry, локализацию и data JSON. +- `tools/gen_tex.py` отвечает за вывод процедурных текстур и иконок; переиспользуемые примитивы палитры также находятся здесь. +- `tools/airdef_assets/texture_catalog.py` связывает стабильную палитру и процедурные функции с текстурами моделей, не смешивая каталог с геометрией. +- `tools/airdef_assets/icons.py` содержит каталог предметов и чистую функцию рендера иконки с явными параметрами `(name, color, seed)`. +- `tools/airdef_assets/pipeline.py` проверяет manifest, рассчитывает безопасный план обновления и откатывает записи при исключении. +- `airdef-mod/source-assets/assets/airdef/` — источник авторских переопределений. Генератор объединяет его с процедурными ресурсами во временном дереве, проверяет ссылки/JSON/PNG, затем устанавливает runtime-файлы. +- `airdef-mod/assets/` и `airdef-mod/data/` — упакованные generated output. Не редактируйте их вручную; добавляйте авторские исходники в `source-assets`. +- `airdef-mod/generated-manifest.txt` хранит SHA-256 только для управляемого результата. Неизменённые старые outputs можно заменить или удалить; посторонние и вручную правленные файлы блокируют конфликтующий запуск. +- `python3 tools/test_gen_assets.py` проверяет manifest, защиту ручных файлов, откат при ошибке записи, детерминизм и полный цикл authored icon override/validation. diff --git a/tools/airdef_assets/__init__.py b/tools/airdef_assets/__init__.py new file mode 100644 index 0000000..2260b5c --- /dev/null +++ b/tools/airdef_assets/__init__.py @@ -0,0 +1 @@ +"""Shared safety helpers for AirDef's generated asset tree.""" diff --git a/tools/airdef_assets/icons.py b/tools/airdef_assets/icons.py new file mode 100644 index 0000000..e534ad2 --- /dev/null +++ b/tools/airdef_assets/icons.py @@ -0,0 +1,393 @@ +"""Procedural item icon catalog and 16x16 raster renderer for AirDef.""" +import math +import random + +from PIL import Image + +from .texture_catalog import clamp + +# Предметы: (id, название, цвет иконки) +ITEMS = [ + ("designator", "Целеуказатель", (220, 50, 50)), + ("launch_shahed", "Пуск: Шахед-136", (90, 92, 96)), + ("launch_geran3", "Пуск: Герань-3", (30, 30, 34)), + ("deploy_patriot", "Развернуть Patriot", (76, 86, 58)), + ("deploy_gepard", "Развернуть Гепард", (104, 84, 54)), + ("deploy_pantsir", "Развернуть Панцирь-С1", (82, 96, 60)), + ("deploy_s400", "Развернуть С-400", (60, 80, 50)), + ("deploy_s500", "Развернуть С-500", (108, 110, 80)), + ("launch_lancet", "Пуск: Ланцет-3", (70, 78, 66)), + ("launch_kh101", "Пуск: Х-101", (84, 88, 92)), + ("launch_kalibr", "Пуск: Калибр", (150, 154, 156)), + ("launch_kh22", "Пуск: Х-22", (226, 228, 222)), + ("launch_zircon", "Пуск: Циркон", (52, 54, 52)), + ("launch_iskander", "Пуск: Искандер-М", (108, 110, 80)), + ("launch_kinzhal", "Пуск: Кинжал", (60, 62, 60)), + ("launch_oreshnik", "Пуск: Орешник", (230, 120, 40)), + ("deploy_irist", "Развернуть IRIS-T SLM", (76, 86, 58)), + ("deploy_nasams", "Развернуть NASAMS", (76, 86, 58)), + ("deploy_buk", "Развернуть Бук-М2", (82, 96, 60)), + ("deploy_tor", "Развернуть Тор-М2", (108, 110, 80)), + ("deploy_p1sun", "Развернуть P1-SUN", (52, 54, 52)), + ("deploy_radar", "Развернуть РЛС дальнего обнаружения", (108, 110, 80)), + ("deploy_depot", "Развернуть склад боеприпасов", (92, 78, 48)), + ("deploy_tzm", "Развернуть ТЗМ", (82, 96, 60)), + ("deploy_ew", "Развернуть станцию РЭБ", (108, 110, 80)), + ("deploy_light", "Развернуть прожектор", (52, 54, 52)), + ("mover", "Пульт перемещения", (60, 140, 220)), + ("flare_gun", "Осветительная ракета", (255, 240, 180)), + ("remover", "Свернуть ПВО", (200, 200, 200)), +] + + +# Иконки 16×16 без сглаживания. Каждый предмет рисуется своей функцией: категория задаёт основу +# (машина ПВО, пуск с пламенем, РЛС, склад, прожектор, целеуказатель, стрелки, сворачивание), +# внутри категории — мини-силуэт типа. Цвет предмета — только заливка, форма различает предметы. +class _Icon: + def __init__(self, color, seed): + self.rnd = random.Random(seed) + self.im = Image.new("RGBA", (16, 16), (0, 0, 0, 0)) + self.pal = { + "b": tuple(color), + "d": tuple(clamp(v * 0.6) for v in color), + "l": tuple(clamp(v + 45) for v in color), + "k": (30, 30, 34), "r": (255, 60, 60), "o": (255, 150, 40), + "y": (255, 232, 120), "w": (236, 236, 236), + } + + def px(self, x, y, key): + if 0 <= x < 16 and 0 <= y < 16: + c = self.pal[key] + if key in ("b", "d", "l"): + c = tuple(clamp(v + self.rnd.randint(-6, 6)) for v in c) + self.im.putpixel((x, y), c + (255,)) + + def rect(self, x0, y0, x1, y1, key): + for y in range(y0, y1 + 1): + for x in range(x0, x1 + 1): + self.px(x, y, key) + + def line(self, x0, y0, x1, y1, key): + dx, dy = abs(x1 - x0), -abs(y1 - y0) + sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1) + err = dx + dy + while True: + self.px(x0, y0, key) + if x0 == x1 and y0 == y1: + break + e2 = 2 * err + if e2 >= dy: + err += dy + x0 += sx + if e2 <= dx: + err += dx + y0 += sy + + def disc(self, cx, cy, r, key): + for y in range(cy - r - 1, cy + r + 2): + for x in range(cx - r - 1, cx + r + 2): + if (x - cx) ** 2 + (y - cy) ** 2 <= r * r + 0.5 * r: + self.px(x, y, key) + + def ring(self, cx, cy, lo, hi, key): + for y in range(16): + for x in range(16): + if lo <= (x - cx) ** 2 + (y - cy) ** 2 <= hi: + self.px(x, y, key) + + def poly(self, pts, key): + # Выпуклый многоугольник: заливка по строкам между крайними пересечениями рёбер. + n = len(pts) + for y in range(min(p[1] for p in pts), max(p[1] for p in pts) + 1): + xs = [] + for i in range(n): + (x1, y1), (x2, y2) = pts[i], pts[(i + 1) % n] + if y1 == y2: + if y1 == y: + xs += [x1, x2] + elif min(y1, y2) <= y <= max(y1, y2): + xs.append(x1 + (y - y1) * (x2 - x1) / (y2 - y1)) + if xs: + for x in range(math.ceil(min(xs) - 1e-9), math.floor(max(xs) + 1e-9) + 1): + self.px(x, y, key) + + +def _sam_base(c): + c.rect(2, 8, 13, 11, "b") + c.rect(3, 12, 4, 13, "k") + c.rect(11, 12, 12, 13, "k") + + +# Развёртывание ПВО: машина + пусковая установка. +def _gepard(c): # ствольная турель, два длинных ствола + _sam_base(c) + c.disc(7, 6, 2, "b") + c.line(9, 5, 15, 5, "d") + c.line(9, 7, 15, 7, "d") + + +def _pantsir(c): # ствольная турель с ракетными контейнерами сверху + _sam_base(c) + c.disc(7, 7, 2, "b") + c.rect(4, 3, 5, 5, "l") + c.rect(9, 3, 10, 5, "l") + c.line(9, 6, 11, 6, "d") + + +def _s400(c): # три вертикальных контейнера + _sam_base(c) + for x in (3, 6, 9): + c.rect(x, 2, x + 1, 7, "b") + c.rect(x, 2, x + 1, 2, "l") + + +def _s500(c): # четыре вертикальных контейнера + _sam_base(c) + for x in (2, 5, 8, 11): + c.rect(x, 2, x + 1, 7, "b") + c.rect(x, 2, x + 1, 2, "l") + + +def _patriot(c): # наклонный пусковой короб + _sam_base(c) + c.poly([(3, 8), (11, 3), (13, 5), (5, 10)], "b") + c.line(7, 7, 9, 5, "d") + + +def _buk(c): # наклонные направляющие с ракетой + _sam_base(c) + c.line(3, 8, 10, 2, "d") + c.line(5, 9, 12, 3, "d") + c.disc(12, 2, 1, "w") + + +def _irist(c): # два наклонных контейнера + _sam_base(c) + c.poly([(3, 8), (7, 3), (9, 4), (5, 9)], "b") + c.poly([(7, 9), (11, 3), (13, 4), (9, 10)], "b") + + +def _nasams(c): # два горизонтальных контейнера + _sam_base(c) + c.rect(3, 2, 12, 3, "b") + c.rect(3, 5, 12, 6, "b") + c.line(3, 4, 12, 4, "d") + + +def _tor(c): # сетка коротких вертикальных ячеек + _sam_base(c) + for x in (3, 6, 9): + c.rect(x, 2, x + 1, 3, "b") + c.rect(x, 5, x + 1, 6, "b") + + +def _p1sun(c): # короб с мачтой + _sam_base(c) + c.rect(3, 5, 12, 7, "b") + c.line(7, 1, 7, 4, "d") + c.px(7, 1, "r") + + +# Радиолокация и РЭБ. +def _radar(c): # параболическая антенна на стойке + c.disc(8, 5, 3, "l") + c.line(8, 8, 8, 12, "d") + c.rect(5, 13, 11, 14, "d") + + +def _ew(c): # штырь-антенна с сигнальными дугами + c.line(8, 6, 8, 14, "d") + c.disc(8, 4, 1, "b") + c.line(5, 2, 4, 4, "l") + c.line(4, 4, 4, 6, "l") + c.line(4, 6, 5, 8, "l") + c.line(11, 2, 12, 4, "l") + c.line(12, 4, 12, 6, "l") + c.line(12, 6, 11, 8, "l") + + +# Снабжение. +def _depot(c): # штабель ящиков на поддоне + c.rect(3, 8, 12, 13, "b") + c.line(3, 10, 12, 10, "d") + c.line(3, 12, 12, 12, "d") + c.rect(2, 14, 13, 14, "d") + c.rect(4, 4, 9, 7, "l") + c.line(4, 6, 9, 6, "d") + + +def _tzm(c): # грузовик с кабиной и кузовом + c.rect(2, 7, 5, 11, "b") + c.rect(3, 8, 4, 9, "w") + c.rect(6, 4, 14, 11, "l") + c.rect(3, 12, 4, 13, "k") + c.rect(10, 12, 11, 13, "k") + c.rect(13, 12, 14, 13, "k") + + +# Прожектор. +def _searchlight(c): + c.rect(3, 4, 10, 8, "b") + c.poly([(10, 4), (15, 1), (15, 11), (10, 8)], "y") + c.line(6, 9, 6, 13, "d") + c.rect(3, 14, 9, 14, "d") + + +# Целеуказатель: кольцо с засечками. +def _designator(c): + c.ring(8, 8, 16, 26, "r") + c.rect(7, 1, 8, 2, "r") + c.rect(7, 13, 8, 14, "r") + c.rect(1, 7, 2, 8, "r") + c.rect(13, 7, 14, 8, "r") + c.rect(7, 7, 8, 8, "r") + + +# Пуски: огонь сзади, силуэт типа спереди. +def _launch_base(c): + c.rect(0, 7, 1, 8, "o") + c.px(0, 7, "y") + + +def _shahed(c): # треугольное крыло + _launch_base(c) + c.poly([(5, 2), (5, 13), (13, 7)], "b") + + +def _geran3(c): # реактивный самолёт со стреловидным крылом + _launch_base(c) + c.rect(3, 7, 13, 8, "b") + c.poly([(6, 7), (9, 2), (11, 2), (9, 7)], "d") + c.poly([(6, 8), (9, 13), (11, 13), (9, 8)], "d") + c.poly([(3, 7), (4, 3), (5, 3), (5, 7)], "d") + + +def _lancet(c): # малый дрон с прямым крылом + _launch_base(c) + c.rect(3, 7, 12, 8, "b") + c.rect(7, 2, 8, 13, "d") + c.rect(3, 6, 4, 9, "d") + + +def _kh101(c): # крылатая ракета: стреловидные крылья и хвост + _launch_base(c) + c.rect(3, 7, 12, 8, "b") + c.poly([(12, 6), (15, 7), (15, 8), (12, 9)], "b") + c.poly([(6, 7), (9, 3), (10, 3), (8, 7)], "d") + c.poly([(6, 8), (9, 12), (10, 12), (8, 8)], "d") + c.rect(2, 5, 3, 10, "d") + + +def _kalibr(c): # крылатая ракета: тонкий корпус и четыре хвостовых киля + _launch_base(c) + c.rect(2, 7, 13, 8, "b") + c.poly([(13, 6), (15, 7), (15, 8), (13, 9)], "b") + c.rect(2, 4, 3, 5, "d") + c.rect(2, 10, 3, 11, "d") + + +def _kh22(c): # толстый корпус с большими дельта-крыльями + _launch_base(c) + c.rect(3, 6, 11, 9, "b") + c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "b") + c.poly([(4, 6), (8, 2), (9, 2), (7, 6)], "d") + c.poly([(4, 9), (8, 13), (9, 13), (7, 9)], "d") + + +def _zircon(c): # гиперзвук: корпус с одним передним стрейком + _launch_base(c) + c.rect(2, 7, 12, 8, "b") + c.poly([(12, 7), (15, 7), (12, 8)], "b") + c.poly([(8, 7), (12, 3), (13, 3), (11, 7)], "d") + + +def _iskander(c): # баллистическая: длинный корпус, конус, сопло + _launch_base(c) + c.rect(3, 7, 11, 8, "b") + c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "l") + c.rect(2, 6, 2, 9, "d") + + +def _oreshnik(c): # баллистическая с тремя головными частями + _launch_base(c) + c.rect(2, 6, 10, 9, "b") + c.poly([(10, 5), (13, 6), (10, 7)], "l") + c.poly([(10, 8), (13, 9), (10, 10)], "l") + c.poly([(10, 6), (15, 7), (10, 8)], "b") + + +def _kinzhal(c): # аэробаллистическая: корпус с треугольными килями + _launch_base(c) + c.rect(3, 7, 11, 8, "b") + c.poly([(11, 6), (15, 7), (11, 9)], "b") + c.poly([(4, 7), (7, 3), (8, 3), (6, 7)], "d") + c.poly([(4, 8), (7, 12), (8, 12), (6, 8)], "d") + + +def _mover(c): # четыре стрелки перемещения + c.poly([(8, 0), (4, 5), (12, 5)], "b") + c.rect(7, 4, 8, 7, "b") + c.poly([(8, 15), (4, 10), (12, 10)], "b") + c.rect(7, 8, 8, 11, "b") + c.poly([(0, 8), (5, 4), (5, 12)], "b") + c.rect(4, 7, 7, 8, "b") + c.poly([(15, 8), (10, 4), (10, 12)], "b") + c.rect(8, 7, 11, 8, "b") + + +def _remover(c): # стрелка вниз над лотком: свёртывание + c.poly([(8, 9), (3, 4), (13, 4)], "b") + c.rect(7, 1, 8, 4, "b") + c.rect(2, 11, 13, 13, "b") + c.line(2, 11, 13, 11, "l") + + +def _flare(c): # звезда осветительной ракеты на стержне + c.disc(8, 7, 1, "y") + c.line(8, 2, 8, 4, "y") + c.line(8, 10, 8, 12, "y") + c.line(3, 7, 5, 7, "y") + c.line(11, 7, 13, 7, "y") + c.line(4, 4, 5, 5, "w") + c.line(11, 4, 10, 5, "w") + c.line(4, 10, 5, 9, "w") + c.line(11, 10, 10, 9, "w") + c.rect(7, 12, 8, 15, "k") + + +ICON_DRAW = { + "designator": _designator, + "launch_shahed": _shahed, "launch_geran3": _geran3, "launch_lancet": _lancet, + "launch_kh101": _kh101, "launch_kalibr": _kalibr, "launch_kh22": _kh22, + "launch_zircon": _zircon, "launch_iskander": _iskander, "launch_oreshnik": _oreshnik, + "launch_kinzhal": _kinzhal, + "deploy_patriot": _patriot, "deploy_gepard": _gepard, "deploy_pantsir": _pantsir, + "deploy_s400": _s400, "deploy_s500": _s500, "deploy_irist": _irist, + "deploy_nasams": _nasams, "deploy_buk": _buk, "deploy_tor": _tor, "deploy_p1sun": _p1sun, + "deploy_radar": _radar, "deploy_ew": _ew, + "deploy_depot": _depot, "deploy_tzm": _tzm, + "deploy_light": _searchlight, + "mover": _mover, "remover": _remover, "flare_gun": _flare, +} + + +def render_icon(name, color, seed): + c = _Icon(color, seed) + ICON_DRAW[name](c) + # Контур по краю силуэта: тёмные предметы — светлым, светлые — тёмным, чтобы читались на обоих фонах. + # Альфа-маска не меняется: контур перекрашивает только уже нарисованные пиксели. + lum = 0.3 * color[0] + 0.59 * color[1] + 0.11 * color[2] + edge = (160, 160, 160) if lum < 100 else (70, 70, 70) if lum > 190 else None + if edge: + src = c.im.copy() + + def opaque(x, y): + return 0 <= x < 16 and 0 <= y < 16 and src.getpixel((x, y))[3] > 0 + + for y in range(16): + for x in range(16): + if opaque(x, y) and not all(opaque(x + dx, y + dy) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))): + c.im.putpixel((x, y), edge + (255,)) + return c.im + + diff --git a/tools/airdef_assets/pipeline.py b/tools/airdef_assets/pipeline.py new file mode 100644 index 0000000..24de3a6 --- /dev/null +++ b/tools/airdef_assets/pipeline.py @@ -0,0 +1,146 @@ +"""Manifest and transactional install helpers for generated AirDef assets.""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + + +def sha256(path: str | os.PathLike[str]) -> str: + digest = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_manifest(path: str | os.PathLike[str]) -> dict[str, str]: + """Read sha256sum-format entries and reject malformed or unsafe paths.""" + manifest = Path(path) + if not manifest.exists(): + return {} + entries: dict[str, str] = {} + for line_no, line in enumerate(manifest.read_text(encoding="utf-8").splitlines(), 1): + try: + digest, rel = line.split(" ", 1) + except ValueError as exc: + raise ValueError(f"{manifest}:{line_no}: invalid manifest row") from exc + parts = Path(rel).parts + if (len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest) + or not rel or Path(rel).is_absolute() or ".." in parts or rel in entries): + raise ValueError(f"{manifest}:{line_no}: invalid manifest entry {rel!r}") + entries[rel] = digest + return entries + + +@dataclass +class Plan: + create: list[str] + update: list[str] + same: list[str] + conflict: list[str] + remove: list[str] + keep: list[str] + + +def make_plan(root: str | os.PathLike[str], old: dict[str, str], new: dict[str, str]) -> Plan: + root = Path(root) + create: list[str] = [] + update: list[str] = [] + same: list[str] = [] + conflict: list[str] = [] + remove: list[str] = [] + keep: list[str] = [] + for rel, digest in sorted(new.items()): + target = root / rel + if not target.exists(): + create.append(rel) + elif not target.is_file(): + conflict.append(rel) + else: + current = sha256(target) + if current == digest: + same.append(rel) + elif old.get(rel) == current: + update.append(rel) + else: + conflict.append(rel) + for rel, digest in sorted(old.items()): + target = root / rel + if rel in new or not target.is_file(): + continue + (remove if sha256(target) == digest else keep).append(rel) + return Plan(create, update, same, conflict, remove, keep) + + +def manifest_text(entries: dict[str, str]) -> str: + return "".join(f"{entries[rel]} {rel}\n" for rel in sorted(entries)) + + +def overlay_sources(stage: str | os.PathLike[str], source_root: str | os.PathLike[str]) -> None: + """Merge authored runtime-relative files into staging without changing source files.""" + stage, source_root = Path(stage), Path(source_root) + asset_root = source_root / "assets" + if not asset_root.is_dir(): + return + for source in sorted(p for p in asset_root.rglob("*") if p.is_file()): + rel = source.relative_to(source_root) + target = stage / rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + + +def install( + root: str | os.PathLike[str], stage: str | os.PathLike[str], + manifest: str | os.PathLike[str], plan: Plan, entries: dict[str, str], + *, replace: Callable[[str, str], None] = os.replace, +) -> None: + """Install a validated staged tree; roll back all touched paths on exceptions.""" + root, stage, manifest = Path(root), Path(stage), Path(manifest) + touched = plan.create + plan.update + plan.remove + before = {rel: (root / rel).read_bytes() if (root / rel).is_file() else None for rel in touched} + had_manifest = manifest.exists() + old_manifest = manifest.read_bytes() if had_manifest else None + try: + for rel in plan.create + plan.update: + target = root / rel + target.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent) + os.close(fd) + try: + shutil.copyfile(stage / rel, temp_name) + replace(temp_name, str(target)) + finally: + if os.path.exists(temp_name): + os.unlink(temp_name) + for rel in plan.remove: + (root / rel).unlink() + manifest.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp(prefix=f".{manifest.name}.", dir=manifest.parent) + os.close(fd) + try: + with open(temp_name, "w", encoding="utf-8", newline="\n") as stream: + stream.write(manifest_text(entries)) + replace(temp_name, str(manifest)) + finally: + if os.path.exists(temp_name): + os.unlink(temp_name) + except Exception: + for rel, data in before.items(): + target = root / rel + if data is None: + if target.exists(): + target.unlink() + else: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + if had_manifest: + manifest.write_bytes(old_manifest or b"") + elif manifest.exists(): + manifest.unlink() + raise diff --git a/tools/airdef_assets/texture_catalog.py b/tools/airdef_assets/texture_catalog.py new file mode 100644 index 0000000..439b85b --- /dev/null +++ b/tools/airdef_assets/texture_catalog.py @@ -0,0 +1,272 @@ +"""AirDef's deterministic model texture catalog and generated variants.""" +import random +import zlib + +from PIL import Image +import gen_tex as GT + +# ---------------------------------------------------------------- текстуры +def clamp(v): + return max(0, min(255, int(v))) + + +def noise(base, amp, seed, size=16): + rnd = random.Random(seed) + im = Image.new("RGBA", (size, size)) + for y in range(size): + for x in range(size): + d = rnd.randint(-amp, amp) + im.putpixel((x, y), tuple(clamp(c + d) for c in base) + (255,)) + return im + + +def panels(base, seed, size=16, every=8): + im = noise(base, 4, seed, size) + line = tuple(clamp(c - 16) for c in base) + (255,) + lite = tuple(clamp(c + 10) for c in base) + (255,) + for i in range(size): + for k in range(0, size, every): + im.putpixel((i, k), line) + im.putpixel((k, i), line) + if k + 1 < size: + im.putpixel((i, k + 1), lite) + rnd = random.Random(seed + 99) + for _ in range(size // 2): # заклёпки + x, y = rnd.randrange(size), rnd.randrange(size) + im.putpixel((x, y), lite) + return im + + +def camo(colors, seed, size=32, blobs=7): + rnd = random.Random(seed) + im = noise(colors[0], 4, seed, size) + for col in colors[1:]: + for _ in range(blobs): + cx, cy = rnd.randrange(size), rnd.randrange(size) + rx, ry = rnd.randint(2, 6), rnd.randint(1, 4) + for y in range(cy - ry, cy + ry + 1): + for x in range(cx - rx, cx + rx + 1): + if ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1 + rnd.random() * 0.3: + im.putpixel((x % size, y % size), tuple(clamp(c + rnd.randint(-5, 5)) for c in col) + (255,)) + return im + + +def stripes(base, line, step, seed, size=16): + im = noise(base, 3, seed, size) + for y in range(0, size, step): + for x in range(size): + im.putpixel((x, y), line + (255,)) + return im + + +def gradient(top, bottom, size=16): + im = Image.new("RGBA", (size, size)) + for y in range(size): + t = y / (size - 1) + c = tuple(clamp(top[i] + (bottom[i] - top[i]) * t) for i in range(3)) + for x in range(size): + im.putpixel((x, y), c + (255,)) + return im + + +def canister_front(): + im = noise((92, 100, 72), 3, 11) + for cx in (4, 11): + for cy in (4, 11): + for y in range(16): + for x in range(16): + d = (x - cx + 0.5) ** 2 + (y - cy + 0.5) ** 2 + if d <= 9: + im.putpixel((x, y), (36, 38, 30, 255)) + elif d <= 12: + im.putpixel((x, y), (120, 124, 100, 255)) + return im + + +def radar_face(): + im = noise((70, 76, 56), 3, 17) + for y in range(1, 15): + for x in range(1, 15): + if (x + y) % 3 == 0: + im.putpixel((x, y), (54, 58, 44, 255)) + return im + + +def glass(): + im = noise((40, 58, 78), 5, 31) + for i in range(16): + im.putpixel((i, 15 - i), (130, 160, 180, 255)) + if i < 15: + im.putpixel((i + 1, 15 - i), (100, 130, 150, 255)) + return im + + +def missile(): + im = noise((232, 232, 226), 3, 21) + for x in range(16): + for y in (3, 4): + im.putpixel((x, y), (40, 40, 40, 255)) + return im + + +def target(): + im = Image.new("RGBA", (16, 16)) + for y in range(16): + for x in range(16): + d = abs(x - 7.5) + abs(y - 7.5) + im.putpixel((x, y), (255, clamp(40 + d * 12), 40, 255)) + return im + + +def fire(seed): + rnd = random.Random(seed) + im = Image.new("RGBA", (16, 16)) + for y in range(16): + for x in range(16): + t = rnd.random() + im.putpixel((x, y), (255, clamp(140 + 110 * t), clamp(30 + 120 * t * t), 255)) + return im + + +def missile_ru(): + im = noise((226, 228, 222), 3, 49) + for x in range(16): + im.putpixel((x, 5), (180, 40, 40, 255)) + im.putpixel((x, 11), (40, 40, 40, 255)) + return im + + +def tube_cap(): + im = noise((80, 88, 60), 3, 50) + for y in range(16): + for x in range(16): + d = (x - 7.5) ** 2 + (y - 7.5) ** 2 + if d <= 36: + im.putpixel((x, y), (44, 48, 36, 255)) + elif d <= 48: + im.putpixel((x, y), (120, 128, 96, 255)) + return im + + +def tubes6(): + im = noise((82, 96, 60), 3, 51) + for cx in (3, 8, 13): + for cy in (4, 11): + for y in range(16): + for x in range(16): + if (x - cx) ** 2 + (y - cy) ** 2 <= 4: + im.putpixel((x, y), (30, 32, 26, 255)) + return im + + +def radar_big(): + im = noise((92, 100, 70), 3, 52) + for y in range(16): + for x in range(16): + if x % 2 == 0 and y % 2 == 0: + im.putpixel((x, y), (60, 66, 46, 255)) + return im + + +TEXTURES = { + "shahed": lambda: panels((66, 68, 70), 1), + "shahed_nose": lambda: noise((104, 106, 108), 4, 2), + "geran": lambda: panels((28, 28, 31), 3), + "geran_engine": lambda: panels((60, 60, 64), 4), + "intake": lambda: noise((14, 14, 14), 2, 5), + "prop": lambda: noise((22, 22, 22), 3, 6), + "missile": missile, + "nozzle": lambda: noise((44, 42, 40), 4, 7), + "flame": lambda: gradient((255, 250, 210), (255, 120, 20)), + "fire": lambda: fire(8), + "nato_green": lambda: camo([(76, 86, 58), (58, 68, 44), (44, 48, 34)], 9), + "flecktarn": lambda: camo([(92, 100, 62), (62, 74, 44), (104, 84, 54), (30, 32, 26)], 10), + "track": lambda: stripes((36, 36, 34), (74, 74, 70), 3, 11), + "tire": lambda: stripes((24, 24, 24), (40, 40, 40), 4, 12), + "canister": lambda: panels((94, 102, 74), 13), + "canister_front": canister_front, + "radar_face": radar_face, + "glass": glass, + "metal": lambda: panels((118, 120, 122), 14), + "dark_metal": lambda: panels((52, 54, 52), 15), + "barrel": lambda: noise((40, 42, 38), 3, 16), + "dish": lambda: noise((176, 178, 174), 5, 18), + "target": target, + "foam": lambda: panels((196, 180, 150), 40), + "kh101": lambda: panels((84, 88, 92), 41), + "kh101_belly": lambda: noise((150, 152, 150), 4, 42), + "lancet": lambda: panels((70, 78, 66), 43), + "ru_green": lambda: camo([(82, 96, 60), (54, 70, 40), (110, 96, 64), (34, 36, 28)], 44, blobs=6), + "ru_khaki": lambda: camo([(108, 110, 80), (84, 90, 60), (60, 64, 44)], 45, blobs=5), + "missile_ru": missile_ru, + "missile_grey": lambda: panels((150, 154, 156), 46), + "booster": lambda: noise((60, 62, 58), 3, 47), + "tube": lambda: stripes((96, 106, 74), (70, 78, 54), 4, 48), + "tube_cap": tube_cap, + "tubes6": tubes6, + "radar_big": radar_big, + "tracer": lambda: noise((255, 40, 20), 6, 60), + "tracer_hot": lambda: noise((255, 190, 150), 6, 61), + "white": lambda: noise((250, 250, 246), 2, 19), + "nato_tan": lambda: camo([(150, 130, 96), (124, 106, 76), (96, 84, 62)], 62, blobs=5), + "crate": lambda: stripes((92, 78, 48), (70, 58, 34), 5, 63), + "concrete": lambda: panels((132, 130, 124), 64), + "lamp": lambda: gradient((255, 255, 240), (255, 230, 170)), + "beam": lambda: gradient((255, 250, 225), (255, 240, 200)), + "antenna": lambda: noise((70, 72, 70), 3, 65), + "nose_dark": lambda: noise((58, 60, 62), 3, 66), +} + + +RINGED = set() + + +def _ring(name, base, bands, seed, text=None): + RINGED.add(name) + TEXTURES[name] = lambda: GT.band_tex(base, bands, seed, text=text) + TEXTURES[name + "_t"] = lambda: GT.band_tex(base, bands, seed, transpose=True, text=text) + + +STRETCH_FN = {} +for _i, _k in enumerate(("nato_green", "flecktarn", "ru_green", "ru_khaki", "nato_tan")): + STRETCH_FN[_k] = (lambda sx, sy, k=_k, i=_i: GT.camo(k, 100 + i, 64, sx, sy)) + TEXTURES[_k] = (lambda k=_k, i=_i: GT.camo(k, 100 + i, 64)) +STRETCH_FN["track_side"] = lambda sx, sy: GT.track_side(7, 32, sx, sy) +PAL_RU, PAL_RU2 = GT.PAL["ru_green"][0], GT.PAL["ru_khaki"][0] +TEXTURES.update({ + "track_side": lambda: GT.track_side(7), + "track": lambda: GT.track_tex(8), + "tire": lambda: GT.tire_tex(9), + "hub": lambda: GT.hub_tex(10), + "glass_cab": lambda: GT.glass_tex(11), + "bare_lamp": lambda: GT.glass_tex(12, tint=(200, 205, 190), refl=False), + "bare_barrel": lambda: GT.metal_tex((44, 46, 44), 13), + "bare_nozzle": lambda: GT.metal_tex((66, 58, 52), 14), + "bare_dish": lambda: GT.metal_tex((178, 180, 176), 15), + "bare_antenna": lambda: GT.metal_tex((112, 114, 112), 16), + "canister_front": lambda: GT.cap_tex(28), + "mk_ru": lambda: GT.marking("digits", PAL_RU, 40, "312"), + "mk_ru2": lambda: GT.marking("digits", PAL_RU2, 41, "507"), + "mk_us": lambda: GT.marking("digits", (76, 86, 58), 42, "1-7"), + "mk_star": lambda: GT.marking("star", PAL_RU, 43), + "mk_de": lambda: GT.marking("cross", (88, 98, 62), 44), + "glow_ember": lambda: GT.Canvas(16, 1, 1, (255, 170, 80), 17).out(), + "metal": lambda: GT.painted((118, 120, 122), 18, lift=1.15), + "dark_metal": lambda: GT.painted((52, 54, 52), 19, lift=1.1), + "canister": lambda: GT.painted((92, 102, 74), 20), + "shahed": lambda: GT.painted((92, 96, 98), 21, panel=(20, 10), lift=1.2), + "geran": lambda: GT.painted((28, 28, 31), 22, panel=(20, 10), lift=1.0), + "kh101": lambda: GT.painted((84, 88, 92), 23, panel=(18, 10)), + "lancet": lambda: GT.painted((74, 82, 70), 24), + "missile_grey": lambda: GT.painted((150, 154, 156), 25, lift=1.0), + "radar_face": lambda: GT.lattice(26, 32, (96, 104, 76), (170, 176, 150)), + "radar_big": lambda: GT.lattice(27, 32, (112, 124, 92), (190, 196, 168)), +}) +_ring("rm_pac3", (228, 228, 222), [(0.12, 2, (30, 30, 30)), (0.78, 1.5, (200, 170, 40)), (0.9, 1, (30, 30, 30))], 30, [(8, 14, 10)]) +_ring("rm_white", (232, 232, 226), [(0.1, 2, (200, 40, 36)), (0.82, 1.5, (40, 40, 44))], 31, [(10, 12, 8)]) +_ring("rm_ru", (226, 228, 222), [(0.15, 2, (170, 40, 36)), (0.55, 1, (40, 40, 40)), (0.85, 2, (170, 40, 36))], 32, [(9, 20, 10)]) +_ring("rm_grey", (148, 152, 154), [(0.2, 1.5, (210, 210, 205)), (0.8, 1.5, (210, 210, 205))], 33, [(6, 14, 12)]) +_ring("rm_khaki", (110, 112, 84), [(0.25, 2, (60, 62, 48)), (0.7, 1.5, (200, 190, 150))], 34, [(8, 18, 8)]) +_ring("rm_dark", (58, 60, 62), [(0.3, 1.5, (150, 150, 146)), (0.85, 1.5, (150, 150, 146))], 35) + + diff --git a/tools/gen_models.py b/tools/gen_models.py index 59c22bc..05ac7a7 100644 --- a/tools/gen_models.py +++ b/tools/gen_models.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Генерирует контент мода AirDef (airdef-mod/assets и data): текстуры, модели частей, предметы. +"""Генерирует модели и registries AirDef, делегируя raster output `gen_tex.py`. Каждая часть — блок со своей моделью в одной ориентации: нос смотрит на север (-z), центр — (8, 8, 8). Поворачивает и масштабирует их движок форка (render.set-models), так что ракурсы больше не нужны. @@ -7,296 +7,29 @@ Запуск: python3 tools/gen_models.py [--check] -Скрипт пишет во временный каталог, проверяет набор (validate: ссылки моделей и текстур, JSON, PNG) и только потом -переносит результат в airdef-mod/; при ошибке проверки ничего не записывает. Список созданных им файлов +Скрипт строит набор во временном каталоге, накладывает `airdef-mod/source-assets/assets/`, проверяет ссылки +моделей и текстур, JSON и PNG, затем транзакционно переносит результат в airdef-mod/. Список управляемых файлов с sha256 лежит в airdef-mod/generated-manifest.txt (формат sha256sum). Удаляются и перезаписываются только файлы из этого списка и только если их не правили вручную; чужой файл на пути генерируемого останавливает запуск. --check ничего не пишет и выходит с кодом 1, если airdef-mod/ отличается от того, что дал бы запуск. """ -import hashlib import json -import math import os import sys import random -import shutil import tempfile import zlib -from PIL import Image - -import gen_tex as GT - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from PIL import Image +import gen_tex as GT +from airdef_assets import pipeline +from airdef_assets.texture_catalog import RINGED, STRETCH_FN, TEXTURES +from airdef_assets.icons import ITEMS, render_icon ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "airdef-mod")) NS = "airdef" -# ---------------------------------------------------------------- текстуры -def clamp(v): - return max(0, min(255, int(v))) - - -def noise(base, amp, seed, size=16): - rnd = random.Random(seed) - im = Image.new("RGBA", (size, size)) - for y in range(size): - for x in range(size): - d = rnd.randint(-amp, amp) - im.putpixel((x, y), tuple(clamp(c + d) for c in base) + (255,)) - return im - - -def panels(base, seed, size=16, every=8): - im = noise(base, 4, seed, size) - line = tuple(clamp(c - 16) for c in base) + (255,) - lite = tuple(clamp(c + 10) for c in base) + (255,) - for i in range(size): - for k in range(0, size, every): - im.putpixel((i, k), line) - im.putpixel((k, i), line) - if k + 1 < size: - im.putpixel((i, k + 1), lite) - rnd = random.Random(seed + 99) - for _ in range(size // 2): # заклёпки - x, y = rnd.randrange(size), rnd.randrange(size) - im.putpixel((x, y), lite) - return im - - -def camo(colors, seed, size=32, blobs=7): - rnd = random.Random(seed) - im = noise(colors[0], 4, seed, size) - for col in colors[1:]: - for _ in range(blobs): - cx, cy = rnd.randrange(size), rnd.randrange(size) - rx, ry = rnd.randint(2, 6), rnd.randint(1, 4) - for y in range(cy - ry, cy + ry + 1): - for x in range(cx - rx, cx + rx + 1): - if ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1 + rnd.random() * 0.3: - im.putpixel((x % size, y % size), tuple(clamp(c + rnd.randint(-5, 5)) for c in col) + (255,)) - return im - - -def stripes(base, line, step, seed, size=16): - im = noise(base, 3, seed, size) - for y in range(0, size, step): - for x in range(size): - im.putpixel((x, y), line + (255,)) - return im - - -def gradient(top, bottom, size=16): - im = Image.new("RGBA", (size, size)) - for y in range(size): - t = y / (size - 1) - c = tuple(clamp(top[i] + (bottom[i] - top[i]) * t) for i in range(3)) - for x in range(size): - im.putpixel((x, y), c + (255,)) - return im - - -def canister_front(): - im = noise((92, 100, 72), 3, 11) - for cx in (4, 11): - for cy in (4, 11): - for y in range(16): - for x in range(16): - d = (x - cx + 0.5) ** 2 + (y - cy + 0.5) ** 2 - if d <= 9: - im.putpixel((x, y), (36, 38, 30, 255)) - elif d <= 12: - im.putpixel((x, y), (120, 124, 100, 255)) - return im - - -def radar_face(): - im = noise((70, 76, 56), 3, 17) - for y in range(1, 15): - for x in range(1, 15): - if (x + y) % 3 == 0: - im.putpixel((x, y), (54, 58, 44, 255)) - return im - - -def glass(): - im = noise((40, 58, 78), 5, 31) - for i in range(16): - im.putpixel((i, 15 - i), (130, 160, 180, 255)) - if i < 15: - im.putpixel((i + 1, 15 - i), (100, 130, 150, 255)) - return im - - -def missile(): - im = noise((232, 232, 226), 3, 21) - for x in range(16): - for y in (3, 4): - im.putpixel((x, y), (40, 40, 40, 255)) - return im - - -def target(): - im = Image.new("RGBA", (16, 16)) - for y in range(16): - for x in range(16): - d = abs(x - 7.5) + abs(y - 7.5) - im.putpixel((x, y), (255, clamp(40 + d * 12), 40, 255)) - return im - - -def fire(seed): - rnd = random.Random(seed) - im = Image.new("RGBA", (16, 16)) - for y in range(16): - for x in range(16): - t = rnd.random() - im.putpixel((x, y), (255, clamp(140 + 110 * t), clamp(30 + 120 * t * t), 255)) - return im - - -def missile_ru(): - im = noise((226, 228, 222), 3, 49) - for x in range(16): - im.putpixel((x, 5), (180, 40, 40, 255)) - im.putpixel((x, 11), (40, 40, 40, 255)) - return im - - -def tube_cap(): - im = noise((80, 88, 60), 3, 50) - for y in range(16): - for x in range(16): - d = (x - 7.5) ** 2 + (y - 7.5) ** 2 - if d <= 36: - im.putpixel((x, y), (44, 48, 36, 255)) - elif d <= 48: - im.putpixel((x, y), (120, 128, 96, 255)) - return im - - -def tubes6(): - im = noise((82, 96, 60), 3, 51) - for cx in (3, 8, 13): - for cy in (4, 11): - for y in range(16): - for x in range(16): - if (x - cx) ** 2 + (y - cy) ** 2 <= 4: - im.putpixel((x, y), (30, 32, 26, 255)) - return im - - -def radar_big(): - im = noise((92, 100, 70), 3, 52) - for y in range(16): - for x in range(16): - if x % 2 == 0 and y % 2 == 0: - im.putpixel((x, y), (60, 66, 46, 255)) - return im - - -TEXTURES = { - "shahed": lambda: panels((66, 68, 70), 1), - "shahed_nose": lambda: noise((104, 106, 108), 4, 2), - "geran": lambda: panels((28, 28, 31), 3), - "geran_engine": lambda: panels((60, 60, 64), 4), - "intake": lambda: noise((14, 14, 14), 2, 5), - "prop": lambda: noise((22, 22, 22), 3, 6), - "missile": missile, - "nozzle": lambda: noise((44, 42, 40), 4, 7), - "flame": lambda: gradient((255, 250, 210), (255, 120, 20)), - "fire": lambda: fire(8), - "nato_green": lambda: camo([(76, 86, 58), (58, 68, 44), (44, 48, 34)], 9), - "flecktarn": lambda: camo([(92, 100, 62), (62, 74, 44), (104, 84, 54), (30, 32, 26)], 10), - "track": lambda: stripes((36, 36, 34), (74, 74, 70), 3, 11), - "tire": lambda: stripes((24, 24, 24), (40, 40, 40), 4, 12), - "canister": lambda: panels((94, 102, 74), 13), - "canister_front": canister_front, - "radar_face": radar_face, - "glass": glass, - "metal": lambda: panels((118, 120, 122), 14), - "dark_metal": lambda: panels((52, 54, 52), 15), - "barrel": lambda: noise((40, 42, 38), 3, 16), - "dish": lambda: noise((176, 178, 174), 5, 18), - "target": target, - "foam": lambda: panels((196, 180, 150), 40), - "kh101": lambda: panels((84, 88, 92), 41), - "kh101_belly": lambda: noise((150, 152, 150), 4, 42), - "lancet": lambda: panels((70, 78, 66), 43), - "ru_green": lambda: camo([(82, 96, 60), (54, 70, 40), (110, 96, 64), (34, 36, 28)], 44, blobs=6), - "ru_khaki": lambda: camo([(108, 110, 80), (84, 90, 60), (60, 64, 44)], 45, blobs=5), - "missile_ru": missile_ru, - "missile_grey": lambda: panels((150, 154, 156), 46), - "booster": lambda: noise((60, 62, 58), 3, 47), - "tube": lambda: stripes((96, 106, 74), (70, 78, 54), 4, 48), - "tube_cap": tube_cap, - "tubes6": tubes6, - "radar_big": radar_big, - "tracer": lambda: noise((255, 40, 20), 6, 60), - "tracer_hot": lambda: noise((255, 190, 150), 6, 61), - "white": lambda: noise((250, 250, 246), 2, 19), - "nato_tan": lambda: camo([(150, 130, 96), (124, 106, 76), (96, 84, 62)], 62, blobs=5), - "crate": lambda: stripes((92, 78, 48), (70, 58, 34), 5, 63), - "concrete": lambda: panels((132, 130, 124), 64), - "lamp": lambda: gradient((255, 255, 240), (255, 230, 170)), - "beam": lambda: gradient((255, 250, 225), (255, 240, 200)), - "antenna": lambda: noise((70, 72, 70), 3, 65), - "nose_dark": lambda: noise((58, 60, 62), 3, 66), -} - - -RINGED = set() - - -def _ring(name, base, bands, seed, text=None): - RINGED.add(name) - TEXTURES[name] = lambda: GT.band_tex(base, bands, seed, text=text) - TEXTURES[name + "_t"] = lambda: GT.band_tex(base, bands, seed, transpose=True, text=text) - - -STRETCH_FN = {} -for _i, _k in enumerate(("nato_green", "flecktarn", "ru_green", "ru_khaki", "nato_tan")): - STRETCH_FN[_k] = (lambda sx, sy, k=_k, i=_i: GT.camo(k, 100 + i, 64, sx, sy)) - TEXTURES[_k] = (lambda k=_k, i=_i: GT.camo(k, 100 + i, 64)) -STRETCH_FN["track_side"] = lambda sx, sy: GT.track_side(7, 32, sx, sy) -PAL_RU, PAL_RU2 = GT.PAL["ru_green"][0], GT.PAL["ru_khaki"][0] -TEXTURES.update({ - "track_side": lambda: GT.track_side(7), - "track": lambda: GT.track_tex(8), - "tire": lambda: GT.tire_tex(9), - "hub": lambda: GT.hub_tex(10), - "glass_cab": lambda: GT.glass_tex(11), - "bare_lamp": lambda: GT.glass_tex(12, tint=(200, 205, 190), refl=False), - "bare_barrel": lambda: GT.metal_tex((44, 46, 44), 13), - "bare_nozzle": lambda: GT.metal_tex((66, 58, 52), 14), - "bare_dish": lambda: GT.metal_tex((178, 180, 176), 15), - "bare_antenna": lambda: GT.metal_tex((112, 114, 112), 16), - "canister_front": lambda: GT.cap_tex(28), - "mk_ru": lambda: GT.marking("digits", PAL_RU, 40, "312"), - "mk_ru2": lambda: GT.marking("digits", PAL_RU2, 41, "507"), - "mk_us": lambda: GT.marking("digits", (76, 86, 58), 42, "1-7"), - "mk_star": lambda: GT.marking("star", PAL_RU, 43), - "mk_de": lambda: GT.marking("cross", (88, 98, 62), 44), - "glow_ember": lambda: GT.Canvas(16, 1, 1, (255, 170, 80), 17).out(), - "metal": lambda: GT.painted((118, 120, 122), 18, lift=1.15), - "dark_metal": lambda: GT.painted((52, 54, 52), 19, lift=1.1), - "canister": lambda: GT.painted((92, 102, 74), 20), - "shahed": lambda: GT.painted((92, 96, 98), 21, panel=(20, 10), lift=1.2), - "geran": lambda: GT.painted((28, 28, 31), 22, panel=(20, 10), lift=1.0), - "kh101": lambda: GT.painted((84, 88, 92), 23, panel=(18, 10)), - "lancet": lambda: GT.painted((74, 82, 70), 24), - "missile_grey": lambda: GT.painted((150, 154, 156), 25, lift=1.0), - "radar_face": lambda: GT.lattice(26, 32, (96, 104, 76), (170, 176, 150)), - "radar_big": lambda: GT.lattice(27, 32, (112, 124, 92), (190, 196, 168)), -}) -_ring("rm_pac3", (228, 228, 222), [(0.12, 2, (30, 30, 30)), (0.78, 1.5, (200, 170, 40)), (0.9, 1, (30, 30, 30))], 30, [(8, 14, 10)]) -_ring("rm_white", (232, 232, 226), [(0.1, 2, (200, 40, 36)), (0.82, 1.5, (40, 40, 44))], 31, [(10, 12, 8)]) -_ring("rm_ru", (226, 228, 222), [(0.15, 2, (170, 40, 36)), (0.55, 1, (40, 40, 40)), (0.85, 2, (170, 40, 36))], 32, [(9, 20, 10)]) -_ring("rm_grey", (148, 152, 154), [(0.2, 1.5, (210, 210, 205)), (0.8, 1.5, (210, 210, 205))], 33, [(6, 14, 12)]) -_ring("rm_khaki", (110, 112, 84), [(0.25, 2, (60, 62, 48)), (0.7, 1.5, (200, 190, 150))], 34, [(8, 18, 8)]) -_ring("rm_dark", (58, 60, 62), [(0.3, 1.5, (150, 150, 146)), (0.85, 1.5, (150, 150, 146))], 35) - - # ---------------------------------------------------------------- модели def el(frm, to, tex, faces=None, rot=None): """Элемент; rot = (ось, угол) — поворот вокруг центра элемента.""" @@ -1309,391 +1042,6 @@ WRECKS = ["patriot_trailer", "patriot_launcher", "gepard_hull", "gepard_turret", CAM_MODELS = ["shahed", "geran3", "lancet", "gerbera", "kh101", "kalibr", "kh22", "zircon", "iskander", "kinzhal", "reentry"] -# Предметы: (id, название, цвет иконки) -ITEMS = [ - ("designator", "Целеуказатель", (220, 50, 50)), - ("launch_shahed", "Пуск: Шахед-136", (90, 92, 96)), - ("launch_geran3", "Пуск: Герань-3", (30, 30, 34)), - ("deploy_patriot", "Развернуть Patriot", (76, 86, 58)), - ("deploy_gepard", "Развернуть Гепард", (104, 84, 54)), - ("deploy_pantsir", "Развернуть Панцирь-С1", (82, 96, 60)), - ("deploy_s400", "Развернуть С-400", (60, 80, 50)), - ("deploy_s500", "Развернуть С-500", (108, 110, 80)), - ("launch_lancet", "Пуск: Ланцет-3", (70, 78, 66)), - ("launch_kh101", "Пуск: Х-101", (84, 88, 92)), - ("launch_kalibr", "Пуск: Калибр", (150, 154, 156)), - ("launch_kh22", "Пуск: Х-22", (226, 228, 222)), - ("launch_zircon", "Пуск: Циркон", (52, 54, 52)), - ("launch_iskander", "Пуск: Искандер-М", (108, 110, 80)), - ("launch_kinzhal", "Пуск: Кинжал", (60, 62, 60)), - ("launch_oreshnik", "Пуск: Орешник", (230, 120, 40)), - ("deploy_irist", "Развернуть IRIS-T SLM", (76, 86, 58)), - ("deploy_nasams", "Развернуть NASAMS", (76, 86, 58)), - ("deploy_buk", "Развернуть Бук-М2", (82, 96, 60)), - ("deploy_tor", "Развернуть Тор-М2", (108, 110, 80)), - ("deploy_p1sun", "Развернуть P1-SUN", (52, 54, 52)), - ("deploy_radar", "Развернуть РЛС дальнего обнаружения", (108, 110, 80)), - ("deploy_depot", "Развернуть склад боеприпасов", (92, 78, 48)), - ("deploy_tzm", "Развернуть ТЗМ", (82, 96, 60)), - ("deploy_ew", "Развернуть станцию РЭБ", (108, 110, 80)), - ("deploy_light", "Развернуть прожектор", (52, 54, 52)), - ("mover", "Пульт перемещения", (60, 140, 220)), - ("flare_gun", "Осветительная ракета", (255, 240, 180)), - ("remover", "Свернуть ПВО", (200, 200, 200)), -] - - -# Иконки 16×16 без сглаживания. Каждый предмет рисуется своей функцией: категория задаёт основу -# (машина ПВО, пуск с пламенем, РЛС, склад, прожектор, целеуказатель, стрелки, сворачивание), -# внутри категории — мини-силуэт типа. Цвет предмета — только заливка, форма различает предметы. -class _Icon: - def __init__(self, color, seed): - self.rnd = random.Random(seed) - self.im = Image.new("RGBA", (16, 16), (0, 0, 0, 0)) - self.pal = { - "b": tuple(color), - "d": tuple(clamp(v * 0.6) for v in color), - "l": tuple(clamp(v + 45) for v in color), - "k": (30, 30, 34), "r": (255, 60, 60), "o": (255, 150, 40), - "y": (255, 232, 120), "w": (236, 236, 236), - } - - def px(self, x, y, key): - if 0 <= x < 16 and 0 <= y < 16: - c = self.pal[key] - if key in ("b", "d", "l"): - c = tuple(clamp(v + self.rnd.randint(-6, 6)) for v in c) - self.im.putpixel((x, y), c + (255,)) - - def rect(self, x0, y0, x1, y1, key): - for y in range(y0, y1 + 1): - for x in range(x0, x1 + 1): - self.px(x, y, key) - - def line(self, x0, y0, x1, y1, key): - dx, dy = abs(x1 - x0), -abs(y1 - y0) - sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1) - err = dx + dy - while True: - self.px(x0, y0, key) - if x0 == x1 and y0 == y1: - break - e2 = 2 * err - if e2 >= dy: - err += dy - x0 += sx - if e2 <= dx: - err += dx - y0 += sy - - def disc(self, cx, cy, r, key): - for y in range(cy - r - 1, cy + r + 2): - for x in range(cx - r - 1, cx + r + 2): - if (x - cx) ** 2 + (y - cy) ** 2 <= r * r + 0.5 * r: - self.px(x, y, key) - - def ring(self, cx, cy, lo, hi, key): - for y in range(16): - for x in range(16): - if lo <= (x - cx) ** 2 + (y - cy) ** 2 <= hi: - self.px(x, y, key) - - def poly(self, pts, key): - # Выпуклый многоугольник: заливка по строкам между крайними пересечениями рёбер. - n = len(pts) - for y in range(min(p[1] for p in pts), max(p[1] for p in pts) + 1): - xs = [] - for i in range(n): - (x1, y1), (x2, y2) = pts[i], pts[(i + 1) % n] - if y1 == y2: - if y1 == y: - xs += [x1, x2] - elif min(y1, y2) <= y <= max(y1, y2): - xs.append(x1 + (y - y1) * (x2 - x1) / (y2 - y1)) - if xs: - for x in range(math.ceil(min(xs) - 1e-9), math.floor(max(xs) + 1e-9) + 1): - self.px(x, y, key) - - -def _sam_base(c): - c.rect(2, 8, 13, 11, "b") - c.rect(3, 12, 4, 13, "k") - c.rect(11, 12, 12, 13, "k") - - -# Развёртывание ПВО: машина + пусковая установка. -def _gepard(c): # ствольная турель, два длинных ствола - _sam_base(c) - c.disc(7, 6, 2, "b") - c.line(9, 5, 15, 5, "d") - c.line(9, 7, 15, 7, "d") - - -def _pantsir(c): # ствольная турель с ракетными контейнерами сверху - _sam_base(c) - c.disc(7, 7, 2, "b") - c.rect(4, 3, 5, 5, "l") - c.rect(9, 3, 10, 5, "l") - c.line(9, 6, 11, 6, "d") - - -def _s400(c): # три вертикальных контейнера - _sam_base(c) - for x in (3, 6, 9): - c.rect(x, 2, x + 1, 7, "b") - c.rect(x, 2, x + 1, 2, "l") - - -def _s500(c): # четыре вертикальных контейнера - _sam_base(c) - for x in (2, 5, 8, 11): - c.rect(x, 2, x + 1, 7, "b") - c.rect(x, 2, x + 1, 2, "l") - - -def _patriot(c): # наклонный пусковой короб - _sam_base(c) - c.poly([(3, 8), (11, 3), (13, 5), (5, 10)], "b") - c.line(7, 7, 9, 5, "d") - - -def _buk(c): # наклонные направляющие с ракетой - _sam_base(c) - c.line(3, 8, 10, 2, "d") - c.line(5, 9, 12, 3, "d") - c.disc(12, 2, 1, "w") - - -def _irist(c): # два наклонных контейнера - _sam_base(c) - c.poly([(3, 8), (7, 3), (9, 4), (5, 9)], "b") - c.poly([(7, 9), (11, 3), (13, 4), (9, 10)], "b") - - -def _nasams(c): # два горизонтальных контейнера - _sam_base(c) - c.rect(3, 2, 12, 3, "b") - c.rect(3, 5, 12, 6, "b") - c.line(3, 4, 12, 4, "d") - - -def _tor(c): # сетка коротких вертикальных ячеек - _sam_base(c) - for x in (3, 6, 9): - c.rect(x, 2, x + 1, 3, "b") - c.rect(x, 5, x + 1, 6, "b") - - -def _p1sun(c): # короб с мачтой - _sam_base(c) - c.rect(3, 5, 12, 7, "b") - c.line(7, 1, 7, 4, "d") - c.px(7, 1, "r") - - -# Радиолокация и РЭБ. -def _radar(c): # параболическая антенна на стойке - c.disc(8, 5, 3, "l") - c.line(8, 8, 8, 12, "d") - c.rect(5, 13, 11, 14, "d") - - -def _ew(c): # штырь-антенна с сигнальными дугами - c.line(8, 6, 8, 14, "d") - c.disc(8, 4, 1, "b") - c.line(5, 2, 4, 4, "l") - c.line(4, 4, 4, 6, "l") - c.line(4, 6, 5, 8, "l") - c.line(11, 2, 12, 4, "l") - c.line(12, 4, 12, 6, "l") - c.line(12, 6, 11, 8, "l") - - -# Снабжение. -def _depot(c): # штабель ящиков на поддоне - c.rect(3, 8, 12, 13, "b") - c.line(3, 10, 12, 10, "d") - c.line(3, 12, 12, 12, "d") - c.rect(2, 14, 13, 14, "d") - c.rect(4, 4, 9, 7, "l") - c.line(4, 6, 9, 6, "d") - - -def _tzm(c): # грузовик с кабиной и кузовом - c.rect(2, 7, 5, 11, "b") - c.rect(3, 8, 4, 9, "w") - c.rect(6, 4, 14, 11, "l") - c.rect(3, 12, 4, 13, "k") - c.rect(10, 12, 11, 13, "k") - c.rect(13, 12, 14, 13, "k") - - -# Прожектор. -def _searchlight(c): - c.rect(3, 4, 10, 8, "b") - c.poly([(10, 4), (15, 1), (15, 11), (10, 8)], "y") - c.line(6, 9, 6, 13, "d") - c.rect(3, 14, 9, 14, "d") - - -# Целеуказатель: кольцо с засечками. -def _designator(c): - c.ring(8, 8, 16, 26, "r") - c.rect(7, 1, 8, 2, "r") - c.rect(7, 13, 8, 14, "r") - c.rect(1, 7, 2, 8, "r") - c.rect(13, 7, 14, 8, "r") - c.rect(7, 7, 8, 8, "r") - - -# Пуски: огонь сзади, силуэт типа спереди. -def _launch_base(c): - c.rect(0, 7, 1, 8, "o") - c.px(0, 7, "y") - - -def _shahed(c): # треугольное крыло - _launch_base(c) - c.poly([(5, 2), (5, 13), (13, 7)], "b") - - -def _geran3(c): # реактивный самолёт со стреловидным крылом - _launch_base(c) - c.rect(3, 7, 13, 8, "b") - c.poly([(6, 7), (9, 2), (11, 2), (9, 7)], "d") - c.poly([(6, 8), (9, 13), (11, 13), (9, 8)], "d") - c.poly([(3, 7), (4, 3), (5, 3), (5, 7)], "d") - - -def _lancet(c): # малый дрон с прямым крылом - _launch_base(c) - c.rect(3, 7, 12, 8, "b") - c.rect(7, 2, 8, 13, "d") - c.rect(3, 6, 4, 9, "d") - - -def _kh101(c): # крылатая ракета: стреловидные крылья и хвост - _launch_base(c) - c.rect(3, 7, 12, 8, "b") - c.poly([(12, 6), (15, 7), (15, 8), (12, 9)], "b") - c.poly([(6, 7), (9, 3), (10, 3), (8, 7)], "d") - c.poly([(6, 8), (9, 12), (10, 12), (8, 8)], "d") - c.rect(2, 5, 3, 10, "d") - - -def _kalibr(c): # крылатая ракета: тонкий корпус и четыре хвостовых киля - _launch_base(c) - c.rect(2, 7, 13, 8, "b") - c.poly([(13, 6), (15, 7), (15, 8), (13, 9)], "b") - c.rect(2, 4, 3, 5, "d") - c.rect(2, 10, 3, 11, "d") - - -def _kh22(c): # толстый корпус с большими дельта-крыльями - _launch_base(c) - c.rect(3, 6, 11, 9, "b") - c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "b") - c.poly([(4, 6), (8, 2), (9, 2), (7, 6)], "d") - c.poly([(4, 9), (8, 13), (9, 13), (7, 9)], "d") - - -def _zircon(c): # гиперзвук: корпус с одним передним стрейком - _launch_base(c) - c.rect(2, 7, 12, 8, "b") - c.poly([(12, 7), (15, 7), (12, 8)], "b") - c.poly([(8, 7), (12, 3), (13, 3), (11, 7)], "d") - - -def _iskander(c): # баллистическая: длинный корпус, конус, сопло - _launch_base(c) - c.rect(3, 7, 11, 8, "b") - c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "l") - c.rect(2, 6, 2, 9, "d") - - -def _oreshnik(c): # баллистическая с тремя головными частями - _launch_base(c) - c.rect(2, 6, 10, 9, "b") - c.poly([(10, 5), (13, 6), (10, 7)], "l") - c.poly([(10, 8), (13, 9), (10, 10)], "l") - c.poly([(10, 6), (15, 7), (10, 8)], "b") - - -def _kinzhal(c): # аэробаллистическая: корпус с треугольными килями - _launch_base(c) - c.rect(3, 7, 11, 8, "b") - c.poly([(11, 6), (15, 7), (11, 9)], "b") - c.poly([(4, 7), (7, 3), (8, 3), (6, 7)], "d") - c.poly([(4, 8), (7, 12), (8, 12), (6, 8)], "d") - - -def _mover(c): # четыре стрелки перемещения - c.poly([(8, 0), (4, 5), (12, 5)], "b") - c.rect(7, 4, 8, 7, "b") - c.poly([(8, 15), (4, 10), (12, 10)], "b") - c.rect(7, 8, 8, 11, "b") - c.poly([(0, 8), (5, 4), (5, 12)], "b") - c.rect(4, 7, 7, 8, "b") - c.poly([(15, 8), (10, 4), (10, 12)], "b") - c.rect(8, 7, 11, 8, "b") - - -def _remover(c): # стрелка вниз над лотком: свёртывание - c.poly([(8, 9), (3, 4), (13, 4)], "b") - c.rect(7, 1, 8, 4, "b") - c.rect(2, 11, 13, 13, "b") - c.line(2, 11, 13, 11, "l") - - -def _flare(c): # звезда осветительной ракеты на стержне - c.disc(8, 7, 1, "y") - c.line(8, 2, 8, 4, "y") - c.line(8, 10, 8, 12, "y") - c.line(3, 7, 5, 7, "y") - c.line(11, 7, 13, 7, "y") - c.line(4, 4, 5, 5, "w") - c.line(11, 4, 10, 5, "w") - c.line(4, 10, 5, 9, "w") - c.line(11, 10, 10, 9, "w") - c.rect(7, 12, 8, 15, "k") - - -ICON_DRAW = { - "designator": _designator, - "launch_shahed": _shahed, "launch_geran3": _geran3, "launch_lancet": _lancet, - "launch_kh101": _kh101, "launch_kalibr": _kalibr, "launch_kh22": _kh22, - "launch_zircon": _zircon, "launch_iskander": _iskander, "launch_oreshnik": _oreshnik, - "launch_kinzhal": _kinzhal, - "deploy_patriot": _patriot, "deploy_gepard": _gepard, "deploy_pantsir": _pantsir, - "deploy_s400": _s400, "deploy_s500": _s500, "deploy_irist": _irist, - "deploy_nasams": _nasams, "deploy_buk": _buk, "deploy_tor": _tor, "deploy_p1sun": _p1sun, - "deploy_radar": _radar, "deploy_ew": _ew, - "deploy_depot": _depot, "deploy_tzm": _tzm, - "deploy_light": _searchlight, - "mover": _mover, "remover": _remover, "flare_gun": _flare, -} - - -def item_icon(name, color, seed): - c = _Icon(color, seed) - ICON_DRAW[name](c) - # Контур по краю силуэта: тёмные предметы — светлым, светлые — тёмным, чтобы читались на обоих фонах. - # Альфа-маска не меняется: контур перекрашивает только уже нарисованные пиксели. - lum = 0.3 * color[0] + 0.59 * color[1] + 0.11 * color[2] - edge = (160, 160, 160) if lum < 100 else (70, 70, 70) if lum > 190 else None - if edge: - src = c.im.copy() - - def opaque(x, y): - return 0 <= x < 16 and 0 <= y < 16 and src.getpixel((x, y))[3] > 0 - - for y in range(16): - for x in range(16): - if opaque(x, y) and not all(opaque(x + dx, y + dy) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))): - c.im.putpixel((x, y), edge + (255,)) - return c.im - - def write_json(path, obj): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: @@ -1716,15 +1064,9 @@ def generate(out): write_json(os.path.join(d, "block", name + ".json"), {"template": "minecraft:stone", "item": False}) lang[f"block.{NS}.{name}"] = title - for name, fn in TEXTURES.items(): - fn().save(os.path.join(a, "textures", "block", name + ".png")) - for name, (base, qx, qy, burnt) in VARIANTS.items(): - im = STRETCH_FN[base](qx, qy) if (base in STRETCH_FN and (qx > 1 or qy > 1)) else TEXTURES[base]() - if burnt: - im = GT.burn(im, zlib.crc32(name.encode()), "glass" if base.startswith("glass") else "paint") - im.save(os.path.join(a, "textures", "block", name + ".png")) + GT.write_block_textures(a, TEXTURES, VARIANTS, STRETCH_FN) for i, (name, title, color) in enumerate(ITEMS): - item_icon(name, color, zlib.crc32(name.encode())).save(os.path.join(a, "textures", "item", name + ".png")) + GT.write_item_icon(a, name, render_icon(name, color, zlib.crc32(name.encode()))) write_json(os.path.join(a, "models", "item", name + ".json"), {"parent": "minecraft:item/generated", "textures": {"layer0": f"{NS}:item/{name}"}}) write_json(os.path.join(a, "items", name + ".json"), @@ -1845,114 +1187,81 @@ def validate(stage): return errors -MANIFEST = os.path.join(ROOT, "generated-manifest.txt") - - -def file_sha(path): - with open(path, "rb") as f: - return hashlib.sha256(f.read()).hexdigest() - - -def read_manifest(): - # Строки " <путь от airdef-mod>", как у sha256sum. Только файлы, которые записал этот скрипт. - if not os.path.exists(MANIFEST): - return {} - entries = {} - with open(MANIFEST, encoding="utf-8") as f: - for line in f: - digest, rel = line.rstrip("\n").split(" ", 1) - if rel.startswith("/") or ".." in rel.split("/"): - sys.exit(f"{MANIFEST}: недопустимый путь {rel}") - entries[rel] = digest - return entries - - -def prune_empty(path): +def prune_empty(root, path): d = os.path.dirname(path) - while d != ROOT and os.path.isdir(d) and not os.listdir(d): + while d != root and os.path.isdir(d) and not os.listdir(d): os.rmdir(d) d = os.path.dirname(d) +def regenerate(root=ROOT, source_root=None, manifest=None, *, check=False, report=False): + """Build, validate, plan and install one complete generated runtime tree.""" + root = os.path.abspath(root) + source_root = source_root or os.path.join(root, "source-assets") + manifest = manifest or os.path.join(root, "generated-manifest.txt") + try: + old = pipeline.read_manifest(manifest) + except ValueError as error: + raise ValueError(str(error)) from error + with tempfile.TemporaryDirectory() as stage: + counts = generate(stage) + pipeline.overlay_sources(stage, source_root) + errors = validate(stage) + if errors: + details = "\n".join(f" - {err}" for err in errors) + raise ValueError(f"ПРОВЕРКА НАБОРА НЕ ПРОЙДЕНА, ошибок: {len(errors)}. Ничего не записано в {root}.\n{details}") + new = {} + for dirpath, dirs, names in os.walk(stage): + dirs.sort() + for name in sorted(names): + path = os.path.join(dirpath, name) + new[os.path.relpath(path, stage).replace(os.sep, "/")] = pipeline.sha256(path) + plan = pipeline.make_plan(root, old, new) + create, update, same = plan.create, plan.update, plan.same + conflict, remove, keep = plan.conflict, plan.remove, plan.keep + if conflict: + names = "\n".join(f" - {rel}" for rel in conflict) + raise RuntimeError(f"КОНФЛИКТ: файлы заняты или правлены вручную:\n{names}\nНичего не записано.") + manifest_text = pipeline.manifest_text(new) + if os.path.exists(manifest): + with open(manifest, encoding="utf-8") as stream: + manifest_old = stream.read() + else: + manifest_old = None + manifest_changed = manifest_text != manifest_old + differs = bool(create or update or remove or manifest_changed) + if report: + for rel in keep: + print(f"ВНИМАНИЕ: {rel} больше не генерируется, но правлен вручную: оставлен, из manifest убран") + for label, rels in (("новый", create), ("обновить", update), ("удалить", remove)): + for rel in rels: + print(f"{label}: {rel}") + if manifest_changed: + print("manifest: обновить") + if check: + print("различий нет" if not differs else "различия с генератором: см. выше") + if not check and differs: + pipeline.install(root, stage, manifest, plan, new) + for rel in remove: + prune_empty(root, os.path.join(root, rel)) + models, items, textures = counts + if report and not check: + print(f"{models} моделей, {items} предметов, {textures} текстур -> {root}: " + f"создано {len(create)}, обновлено {len(update)}, удалено {len(remove)}, без изменений {len(same)}") + return differs + + def main(): args = sys.argv[1:] if any(arg != "--check" for arg in args): sys.exit("Использование: python3 tools/gen_models.py [--check]") check = "--check" in args - old = read_manifest() - with tempfile.TemporaryDirectory() as stage: - counts = generate(stage) - errors = validate(stage) - if errors: - print(f"ПРОВЕРКА НАБОРА НЕ ПРОЙДЕНА, ошибок: {len(errors)}. Ничего не записано в {ROOT}.") - for err in errors: - print(f" - {err}") - sys.exit(1) - new = {} - for dirpath, dirs, names in os.walk(stage): - dirs.sort() - for name in sorted(names): - path = os.path.join(dirpath, name) - new[os.path.relpath(path, stage).replace(os.sep, "/")] = file_sha(path) - # create: файла нет; update: файл записан генератором и с тех пор не менялся; same: уже совпадает; - # conflict: на пути лежит чужой или вручную правленный файл, поэтому ничего не пишем. - create, update, same, conflict = [], [], [], [] - for rel in sorted(new): - target = os.path.join(ROOT, rel) - if not os.path.exists(target): - create.append(rel) - elif not os.path.isfile(target): - conflict.append(rel) - else: - cur = file_sha(target) - if cur == new[rel]: - same.append(rel) - elif rel in old and old[rel] == cur: - update.append(rel) - else: - conflict.append(rel) - # remove: генератор больше не создаёт файл и он не менялся; keep: не создаёт, но правлен вручную, оставляем. - remove, keep = [], [] - for rel in sorted(old): - target = os.path.join(ROOT, rel) - if rel in new or not os.path.isfile(target): - continue - (remove if file_sha(target) == old[rel] else keep).append(rel) - if conflict: - for rel in conflict: - print(f"КОНФЛИКТ: {rel} занят файлом, которого нет в manifest (или он правлен вручную)") - sys.exit("Ничего не записано. Уберите или переименуйте эти файлы и запустите снова.") - manifest_text = "".join(f"{new[rel]} {rel}\n" for rel in sorted(new)) - manifest_old = None - if os.path.exists(MANIFEST): - with open(MANIFEST, encoding="utf-8") as f: - manifest_old = f.read() - manifest_changed = manifest_text != manifest_old - for rel in keep: - print(f"ВНИМАНИЕ: {rel} больше не генерируется, но правлен вручную: оставлен, из manifest убран") - for label, rels in (("новый", create), ("обновить", update), ("удалить", remove)): - for rel in rels: - print(f"{label}: {rel}") - if manifest_changed: - print("manifest: обновить") - if check: - differs = create or update or remove or manifest_changed - print("различий нет" if not differs else "различия с генератором: см. выше") - sys.exit(1 if differs else 0) - for rel in create + update: - dst = os.path.join(ROOT, rel) - os.makedirs(os.path.dirname(dst), exist_ok=True) - shutil.copyfile(os.path.join(stage, rel), dst) - for rel in remove: - path = os.path.join(ROOT, rel) - os.remove(path) - prune_empty(path) - if manifest_changed: - with open(MANIFEST, "w", encoding="utf-8", newline="\n") as f: - f.write(manifest_text) - models, items, textures = counts - print(f"{models} моделей, {items} предметов, {textures} текстур -> {ROOT}: " - f"создано {len(create)}, обновлено {len(update)}, удалено {len(remove)}, без изменений {len(same)}") + try: + differs = regenerate(check=check, report=True) + except (ValueError, RuntimeError) as error: + sys.exit(str(error)) + if check and differs: + sys.exit(1) if __name__ == "__main__": diff --git a/tools/gen_tex.py b/tools/gen_tex.py index 79be7c1..dfaab4d 100644 --- a/tools/gen_tex.py +++ b/tools/gen_tex.py @@ -8,9 +8,33 @@ (в моделях грань длиннее 16 единиц тянет всю текстуру), картина заранее сжата, чтобы пятна камуфляжа не вытягивались. """ import random +import os +import zlib from PIL import Image + +def write_block_textures(asset_root, textures, variants, stretch_functions): + """Render the palette's block textures into a staged `assets/` tree.""" + block_dir = os.path.join(asset_root, "textures", "block") + os.makedirs(block_dir, exist_ok=True) + for name, render in textures.items(): + render().save(os.path.join(block_dir, name + ".png")) + for name, (base, qx, qy, burnt) in variants.items(): + image = (stretch_functions[base](qx, qy) + if base in stretch_functions and (qx > 1 or qy > 1) + else textures[base]()) + if burnt: + image = burn(image, zlib.crc32(name.encode()), "glass" if base.startswith("glass") else "paint") + image.save(os.path.join(block_dir, name + ".png")) + + +def write_item_icon(asset_root, name, image): + """Write one generated item icon under a staged assets namespace.""" + item_dir = os.path.join(asset_root, "textures", "item") + os.makedirs(item_dir, exist_ok=True) + image.save(os.path.join(item_dir, name + ".png")) + # ------------------------------------------------------------------ палитра PAL = { "nato_green": [(70, 80, 54), (86, 74, 50), (34, 38, 30)], # NATO: зелёный, коричневый, чёрный diff --git a/tools/test_gen_assets.py b/tools/test_gen_assets.py new file mode 100644 index 0000000..1072d05 --- /dev/null +++ b/tools/test_gen_assets.py @@ -0,0 +1,156 @@ +"""Safety and reproducibility tests for AirDef's asset generation pipeline.""" + +import hashlib +import importlib.util +import os +import sys +import tempfile +import unittest +from pathlib import Path + +from PIL import Image + +TOOLS = Path(__file__).resolve().parent +sys.path.insert(0, str(TOOLS)) + +from airdef_assets import pipeline +from airdef_assets import icons +import gen_tex + +SPEC = importlib.util.spec_from_file_location("gen_models", TOOLS / "gen_models.py") +gen_models = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(gen_models) + + +class ManifestTests(unittest.TestCase): + def test_manifest_rejects_traversal_and_duplicate_paths(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "manifest.txt" + path.write_text("0" * 64 + " ../outside\n", encoding="utf-8") + with self.assertRaises(ValueError): + pipeline.read_manifest(path) + path.write_text("0" * 64 + " safe.txt\n" + "1" * 64 + " safe.txt\n", encoding="utf-8") + with self.assertRaises(ValueError): + pipeline.read_manifest(path) + + def test_plan_protects_untracked_and_modified_files_and_keeps_edited_obsolete_file(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "tracked.txt").write_text("edited", encoding="utf-8") + (root / "obsolete.txt").write_text("artist edit", encoding="utf-8") + (root / "untracked.txt").write_text("user", encoding="utf-8") + old = { + "tracked.txt": hashlib.sha256(b"old generated").hexdigest(), + "obsolete.txt": hashlib.sha256(b"old generated").hexdigest(), + } + plan = pipeline.make_plan(root, old, {"tracked.txt": hashlib.sha256(b"new").hexdigest(), + "untracked.txt": hashlib.sha256(b"output").hexdigest()}) + self.assertEqual(plan.conflict, ["tracked.txt", "untracked.txt"]) + self.assertEqual(plan.keep, ["obsolete.txt"]) + self.assertEqual(plan.remove, []) + + def test_install_rolls_back_files_and_manifest_after_replace_error(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "runtime" + stage = Path(tmp) / "stage" + root.mkdir() + stage.mkdir() + manifest = root / "generated-manifest.txt" + manifest.write_text("old manifest\n", encoding="utf-8") + for name in ("a.txt", "b.txt"): + (root / name).write_text("old " + name, encoding="utf-8") + (stage / name).write_text("new " + name, encoding="utf-8") + old = {name: pipeline.sha256(root / name) for name in ("a.txt", "b.txt")} + new = {name: pipeline.sha256(stage / name) for name in ("a.txt", "b.txt")} + plan = pipeline.make_plan(root, old, new) + real_replace = os.replace + calls = 0 + + def fail_once(src, dst): + nonlocal calls + calls += 1 + if calls == 3: + raise OSError("simulated write failure") + real_replace(src, dst) + + with self.assertRaisesRegex(OSError, "simulated"): + pipeline.install(root, stage, manifest, plan, new, replace=fail_once) + self.assertEqual((root / "a.txt").read_text(), "old a.txt") + self.assertEqual((root / "b.txt").read_text(), "old b.txt") + self.assertEqual(manifest.read_text(), "old manifest\n") + + +class ReproducibilityTests(unittest.TestCase): + def test_texture_renders_are_seed_deterministic(self): + first = gen_tex.camo("nato_green", 1234, 32) + second = gen_tex.camo("nato_green", 1234, 32) + self.assertEqual(first.tobytes(), second.tobytes()) + + def test_authored_override_is_merged_without_changing_source(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source-assets" + stage = root / "stage" + authored = source / "assets/airdef/textures/item/custom.png" + authored.parent.mkdir(parents=True) + authored.write_bytes(b"authored") + stage.mkdir() + pipeline.overlay_sources(stage, source) + self.assertEqual((stage / "assets/airdef/textures/item/custom.png").read_bytes(), b"authored") + self.assertEqual(authored.read_bytes(), b"authored") + + def test_full_pipeline_installs_valid_icon_override_and_rejects_bad_one_without_mutation(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "mod" + root.mkdir() + source = root / "source-assets" + manifest = root / "generated-manifest.txt" + icon_source = source / "assets/airdef/textures/item/deploy_gepard.png" + icon_source.parent.mkdir(parents=True) + + self.assertTrue(gen_models.regenerate(root, source, manifest)) + previous_icon = (root / "assets/airdef/textures/item/deploy_gepard.png").read_bytes() + previous_manifest = manifest.read_bytes() + self.assertFalse(gen_models.regenerate(root, source, manifest)) + + Image.new("RGBA", (16, 16), (12, 34, 56, 255)).save(icon_source) + authored_bytes = icon_source.read_bytes() + self.assertTrue(gen_models.regenerate(root, source, manifest)) + installed = root / "assets/airdef/textures/item/deploy_gepard.png" + self.assertEqual(installed.read_bytes(), authored_bytes) + self.assertEqual(icon_source.read_bytes(), authored_bytes) + self.assertNotEqual(installed.read_bytes(), previous_icon) + + installed_before_bad_override = installed.read_bytes() + manifest_before_bad_override = manifest.read_bytes() + Image.new("RGBA", (20, 16), (255, 0, 0, 255)).save(icon_source) + invalid_source = icon_source.read_bytes() + with self.assertRaisesRegex(ValueError, "не квадратная"): + gen_models.regenerate(root, source, manifest) + self.assertEqual(installed.read_bytes(), installed_before_bad_override) + self.assertEqual(manifest.read_bytes(), manifest_before_bad_override) + self.assertEqual(icon_source.read_bytes(), invalid_source) + self.assertNotEqual(previous_manifest, manifest_before_bad_override) + + def test_icon_catalog_has_stable_explicit_renderer_api(self): + entry = next(item for item in icons.ITEMS if item[0] == "deploy_gepard") + first = icons.render_icon(entry[0], entry[2], 1234) + second = icons.render_icon(entry[0], entry[2], 1234) + self.assertEqual(first.tobytes(), second.tobytes()) + + def test_full_generated_tree_is_reproducible(self): + with tempfile.TemporaryDirectory() as tmp: + outputs = [] + for name in ("first", "second"): + root = Path(tmp) / name + root.mkdir() + gen_models.generate(str(root)) + outputs.append({ + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(root.rglob("*")) if path.is_file() + }) + self.assertEqual(outputs[0], outputs[1]) + + +if __name__ == "__main__": + unittest.main()