#!/usr/bin/env python3 """Генерирует модели и registries AirDef, делегируя raster output `gen_tex.py`. Каждая часть — блок со своей моделью в одной ориентации: нос смотрит на север (-z), центр — (8, 8, 8). Поворачивает и масштабирует их движок форка (render.set-models), так что ракурсы больше не нужны. Координаты элементов — в пределах -16..32 (иначе Horizon отбрасывает модель). Запуск: python3 tools/gen_models.py [--check] Скрипт строит набор во временном каталоге, накладывает `airdef-mod/source-assets/assets/`, проверяет ссылки моделей и текстур, JSON и PNG, затем транзакционно переносит результат в airdef-mod/. Список управляемых файлов с sha256 лежит в airdef-mod/generated-manifest.txt (формат sha256sum). Удаляются и перезаписываются только файлы из этого списка и только если их не правили вручную; чужой файл на пути генерируемого останавливает запуск. --check ничего не пишет и выходит с кодом 1, если airdef-mod/ отличается от того, что дал бы запуск. """ import json import os import sys import random import tempfile import zlib 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.bounds import write_rust_bounds from airdef_assets.registry import write_json, write_registry 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 el(frm, to, tex, faces=None, rot=None): """Элемент; rot = (ось, угол) — поворот вокруг центра элемента.""" return {"from": [float(v) for v in frm], "to": [float(v) for v in to], "tex": tex, "faces": faces, "rot": rot} def mx(e): """Зеркальная копия относительно x = 8.""" rot = e.get("rot") if rot and rot[0] in ("y", "z"): rot = (rot[0], -rot[1]) return {**e, "from": [16 - e["to"][0], e["from"][1], e["from"][2]], "to": [16 - e["from"][0], e["to"][1], e["to"][2]], "rot": rot} def both(e): return [e, mx(e)] def face_uv(frm, to, face): dx, dy, dz = (abs(to[i] - frm[i]) for i in range(3)) w, h = {"north": (dx, dy), "south": (dx, dy), "east": (dz, dy), "west": (dz, dy), "up": (dx, dz), "down": (dx, dz)}[face] return [0, 0, max(0.25, min(16, w)), max(0.25, min(16, h))] VARIANTS = {} TEXMAP = {"barrel": "bare_barrel", "nozzle": "bare_nozzle", "dish": "bare_dish", "antenna": "bare_antenna", "glass": "glass_cab", "intake": "bare_nozzle"} STRETCH = {"nato_green", "flecktarn", "ru_green", "ru_khaki", "nato_tan", "track_side"} def _q(v): return 1 if v < 1.5 else 2 if v < 2.5 else 3 def vname(tex, qx, qy, burnt): tex = TEXMAP.get(tex, tex) if burnt and tex.startswith("glow_"): tex = "bare_nozzle" n = tex if tex in STRETCH and (qx > 1 or qy > 1): n += f"_s{qx}{qy}" if burnt: n += "_b" if n != tex: VARIANTS[n] = (tex, qx, qy, burnt) return n def model(parts, burnt=False): for p in parts: for i in range(3): assert -16 <= p["from"][i] <= 32 and -16 <= p["to"][i] <= 32, p textures, elements = {}, [] for p in parts: faces = {} for f in ("north", "south", "east", "west", "up", "down"): tex = (p["faces"] or {}).get(f, p["tex"]) uv = face_uv(p["from"], p["to"], f) dx, dy, dz = (abs(p["to"][i] - p["from"][i]) for i in range(3)) w, h = {"north": (dx, dy), "south": (dx, dy), "east": (dz, dy), "west": (dz, dy), "up": (dx, dz), "down": (dx, dz)}[f] tex = vname(tex, _q(w / 16), _q(h / 16), burnt) textures[tex] = f"{NS}:block/{tex}" faces[f] = {"uv": uv, "texture": "#" + tex} e = {"from": p["from"], "to": p["to"], "faces": faces} if p.get("rot"): axis, angle = p["rot"] origin = [(p["from"][i] + p["to"][i]) / 2 for i in range(3)] e["rotation"] = {"angle": angle, "axis": axis, "origin": origin} elements.append(e) textures["particle"] = next(iter(textures.values())) return {"ambientocclusion": False, "textures": textures, "elements": elements} def burnt_parts(parts, seed): """Остов: часть мелких деталей оторвана, антенны сорваны; текстуры — обгоревшие варианты (см. model(burnt=True)).""" rnd = random.Random(seed) out = [] for i, p in enumerate(parts): vol = 1.0 for k in range(3): vol *= abs(p["to"][k] - p["from"][k]) if i > 2 and ((vol < 40 and rnd.random() < 0.3) or p["tex"] in ("antenna", "dish")): continue out.append(p) return out def taper(tex, z0, z1, w0, w1, h0, h1, steps, y=8.0): """Корпус-веретено лесенкой: ширина w0→w1 и высота h0→h1 вдоль z0→z1.""" out = [] for i in range(steps): za = z0 + (z1 - z0) * i / steps zb = z0 + (z1 - z0) * (i + 1) / steps t = (i + 0.5) / steps w, h = w0 + (w1 - w0) * t, h0 + (h1 - h0) * t out.append(el((8 - w / 2, y - h / 2, za), (8 + w / 2, y + h / 2, zb), tex)) return out def delta(tex, z_root, z_tip, z_trail, half, steps, y0, y1): """Дельта-крыло: передняя кромка от корня (z_root) к законцовке (half, z_tip), задняя кромка — z_trail.""" out = [] for i in range(steps): za = z_root + (z_tip - z_root) * i / steps s = half * (i + 1) / steps out.append(el((8 - s, y0, za), (8 + s, y1, z_trail), tex)) return out def shahed(): p = taper("shahed_nose", -16, -10, 2.5, 5, 2.5, 5, 3) p += [el((5.5, 5.5, -10), (10.5, 10.5, 24), "shahed")] p += [el((6, 10.5, -6), (10, 11.5, 4), "dark_metal")] # люк ГСН/антенны p += delta("shahed", -2, 21, 26, 21, 9, 7.2, 8.4) p += both(el((-13.6, 6.5, 17), (-12.6, 14, 26), "shahed")) # законцовки-кили p += [el((6, 6, 24), (10, 10, 29), "dark_metal")] # двигатель p += both(el((6.5, 4, 25), (7.5, 6, 28), "dark_metal")) # выхлопы return p def shahed_prop(): return [el((7.6, -4, 7.5), (8.4, 20, 8.5), "prop"), el((-4, 7.6, 7.5), (20, 8.4, 8.5), "prop"), el((7, 7, 7), (9, 9, 9.5), "dark_metal")] def geran3(): p = taper("geran", -16, -9, 2.5, 5.5, 2.5, 5.5, 3) p += [el((5, 5, -9), (11, 11, 26), "geran")] p += delta("geran", 0, 20, 26, 18, 8, 7.2, 8.4) p += both(el((-10.6, 6.5, 18), (-9.6, 13, 26), "geran")) p += [el((4.5, 11, 6), (11.5, 16, 28), "geran_engine"), # реактивный двигатель сверху el((5, 11.5, 5), (11, 15.5, 6), "intake"), el((5.5, 11.5, 28), (10.5, 15.5, 30), "nozzle")] return p def geran3_flame(): return taper("flame", 6, 24, 4, 1, 4, 1, 5) def pac3(): p = taper("missile", -16, -11, 1, 3, 1, 3, 3) p += [el((6.5, 6.5, -11), (9.5, 9.5, 26), "missile")] p += both(el((1.5, 7.6, 20), (6.5, 8.4, 26), "missile")) p += [el((7.6, 1.5, 20), (8.4, 6.5, 26), "missile"), el((7.6, 9.5, 20), (8.4, 14.5, 26), "missile")] p += both(el((4.5, 7.7, -6), (6.5, 8.3, -2), "missile")) # рули ГСН p += [el((7, 7, 26), (9, 9, 28), "nozzle")] return p def irist_missile(): """IRIS-T SLM: тонкая белая ракета, две пары малых канардов у носа и четыре хвостовых киля.""" p = taper("white", -16, -10, 1, 3, 1, 3, 3) p += [el((6.5, 6.5, -10), (9.5, 9.5, 24), "white")] p += both(el((3, 7.6, -9), (6.5, 8.4, -5), "missile_grey")) # канарды у носа p += [el((7.6, 3, -9), (8.4, 6.5, -5), "missile_grey"), el((7.6, 9.5, -9), (8.4, 13, -5), "missile_grey")] p += both(el((2, 7.6, 18), (6.5, 8.4, 26), "white")) # хвостовые киля p += [el((7.6, 2, 18), (8.4, 6.5, 26), "white"), el((7.6, 9.5, 18), (8.4, 14, 26), "white")] p += [el((7, 7, 24), (9, 9, 28), "nozzle")] return p def amraam_missile(): """AMRAAM: белое тело с тёмным носом, четыре крыла на середине корпуса и четыре хвостовых киля.""" p = taper("nose_dark", -16, -9, 1, 3, 1, 3, 3) p += [el((6.5, 6.5, -9), (9.5, 9.5, 26), "white")] p += both(el((1, 7.6, 2), (6.5, 8.4, 9), "white")) # среднекорпусные крылья p += [el((7.6, 1, 2), (8.4, 6.5, 9), "white"), el((7.6, 9.5, 2), (8.4, 15, 9), "white")] p += both(el((4, 7.6, 19), (6.5, 8.4, 25), "white")) # хвостовые киля p += [el((7.6, 4, 19), (8.4, 6.5, 25), "white"), el((7.6, 9.5, 19), (8.4, 12, 25), "white")] p += [el((7, 7, 26), (9, 9, 28), "nozzle")] return p def flame(): """Факел двигателя: светящаяся «капля», хвост — на юг (+z).""" return taper("flame", -8, 26, 5, 1, 5, 1, 8) def patriot_launcher(): """Пакет 4 ТПК M903. Ось качания — задняя нижняя кромка (z = 30, y = 0).""" p = [el((-6, 1, -14), (22, 19, 30), "canister", {"north": "canister_front"})] p += [el((-6.5, 9.5, -14), (22.5, 10.5, 30), "dark_metal"), el((7.5, 1, -14), (8.5, 19, 30), "dark_metal"), el((-7, 0, 24), (23, 2, 30), "metal")] p += both(el((-8, 2, -10), (-6, 17, -6), "metal")) + both(el((-8, 2, 20), (-6, 17, 24), "metal")) return p def patriot_truck(): """Тягач (HEMTT): кабина, рама, колёса. Длина 3 блока при масштабе 1.""" p = [el((-6, 5, -16), (22, 9, 32), "nato_green"), el((-6, 9, -15), (22, 24, -1), "nato_green"), el((-5, 16, -16), (21, 23, -15), "glass"), el((-6.2, 16, -13), (-6, 23, -4), "glass"), el((22, 16, -13), (22.2, 23, -4), "glass"), el((-6, 9, 2), (22, 15, 16), "nato_green"), el((-2, 24, -12), (18, 25, -4), "dark_metal"), el((2, 9, 20), (14, 13, 30), "metal")] for z in (-12, 14, 23): p += wheel(-9, -5.5, z + 4) return p def patriot_trailer(): p = [el((-7, 6, -16), (23, 11, 32), "nato_green"), el((-1, 11, 14), (17, 16, 30), "metal"), el((-8, 3, -16), (24, 7, -12), "dark_metal")] for z in (14, 23): p += wheel(-10, -6.5, z + 4) p += both(el((-9, 0, -6), (-7, 6, -4), "metal")) # опоры return p def patriot_radar(): """РЛС AN/MPQ-65: фазированная решётка на прицепе.""" p = [el((-6, 6, -10), (22, 10, 26), "nato_green"), el((-4, 10, -6), (20, 26, 8), "nato_green"), el((-5, 12, -7), (21, 30, -6), "radar_face"), el((-2, 10, 10), (18, 18, 24), "metal")] for z in (14,): p += wheel(-9, -5.5, z + 4) return p def gepard_hull(): p = [el((-10, 4, -12), (26, 17, 30), "flecktarn"), el((-10, 4, -16), (26, 12, -12), "flecktarn"), el((-8, 17, 18), (24, 18, 30), "dark_metal"), el((-6, 17, -10), (4, 18, -4), "metal")] p += both(el((-14, 0, -14), (-10, 10, 31), "track")) p += both(el((-14.5, 6, -14), (-10, 11, 31), "flecktarn")) # надгусеничные полки return p def gepard_turret(): p = [el((-4, 0, -6), (20, 12, 16), "flecktarn"), el((-2, 12, -2), (18, 14, 14), "flecktarn"), el((3, 4, -10), (13, 12, -6), "radar_face"), # РЛС сопровождения el((7, 14, 10), (9, 18, 12), "metal")] return p def gepard_guns(): """Две 35-мм пушки по бортам башни; ось качания — центр модели.""" p = both(el((-8, 5, -16), (-5.5, 7.5, 12), "barrel")) p += both(el((-9, 3.5, 2), (-4, 9, 14), "flecktarn")) p += both(el((-8.5, 4.5, -16), (-5, 8, -13), "dark_metal")) # дульные тормоза return p def gepard_dish(): """Обзорная РЛС: вращается вокруг вертикальной оси.""" return [el((-4, 6, 6), (20, 14, 8), "dish"), el((7, 0, 6), (9, 8, 8), "metal")] def target_marker(): return [el((7, -8, 7), (9, 24, 9), "target"), el((2, 7.5, 2), (14, 8.5, 14), "target"), el((5, 3, 5), (11, 13, 11), "target")] def fireball(): return [el((2, 2, 2), (14, 14, 14), "fire"), el((0, 4, 4), (16, 12, 12), "fire"), el((4, 0, 4), (12, 16, 12), "fire"), el((4, 4, 0), (12, 12, 16), "fire")] def flash(): return [el((7, 7, -4), (9, 9, 20), "white"), el((-4, 7, 7), (20, 9, 9), "white"), el((7, -4, 7), (9, 20, 9), "white"), el((5, 5, 5), (11, 11, 11), "flame")] def debris(): return [el((5, 6, 3), (11, 9, 13), "dark_metal"), el((2, 7, 6), (14, 8, 10), "shahed")] def lancet(): p = taper("lancet", -16, -10, 2, 3.5, 2, 3.5, 3) p += [el((6.2, 6.2, -10), (9.8, 9.8, 20), "lancet"), el((6.8, 4.8, -9), (9.2, 6.2, -5), "intake")] # оптика for z0, z1, half in ((-5, 1, 13), (12, 18, 11)): # Х-образные крылья for ang in (45, -45): p.append(el((8 - half, 7.7, z0), (8 + half, 8.3, z1), "lancet", rot=("z", ang))) p += [el((6.8, 6.8, 20), (9.2, 9.2, 22), "dark_metal")] return p def gerbera(): p = taper("foam", -15, -10, 2.5, 4.5, 2.5, 4.5, 3) p += [el((5.8, 5.8, -10), (10.2, 10.2, 20), "foam")] p += delta("foam", 0, 18, 22, 17, 7, 7.3, 8.5) p += both(el((-9.6, 6.5, 15), (-8.6, 13, 22), "foam")) p += [el((6.5, 6.5, 20), (9.5, 9.5, 24), "dark_metal")] return p def kh101(): p = taper("kh101", -16, -9, 2, 6, 2, 4.5, 4, y=7.5) p += [el((5, 5.5, -9), (11, 8, 30), "kh101", {"down": "kh101_belly"}), el((6, 8, -6), (10, 10.5, 28), "kh101")] p += [el((-12, 7.1, 4), (28, 7.7, 10), "kh101")] # крылья p += [el((7.6, 10.5, 24), (8.4, 15, 30), "kh101")] # киль p += both(el((1, 7.2, 25), (6, 7.8, 30), "kh101")) p += [el((6, 2, 15), (10, 5.5, 28), "dark_metal"), # двигатель под хвостом el((6.5, 2.5, 14), (9.5, 5, 15), "intake"), el((6.5, 2.5, 28), (9.5, 5, 29.5), "nozzle")] return p def wheels(zs, x0=-9, x1=-5.5, tex="tire"): out = [] for z in zs: out += both(el((x0, 0, z), (x1, 9, z + 7), tex)) return out def pantsir_truck(): p = [el((-6, 5, -16), (22, 9, 32), "ru_green"), el((-6, 9, -16), (22, 21, -6), "ru_green", {"north": "glass"}), el((-5, 13, -16), (21, 20, -15.9), "glass"), el((-5, 9, -5), (21, 18, 6), "ru_green"), el((-4, 9, 8), (20, 12, 30), "dark_metal"), el((-6.5, 3, 26), (22.5, 6, 32), "dark_metal")] return p + wheels((-13, -4, 16, 24)) def pantsir_turret(): return [el((-2, 0, -6), (18, 10, 16), "ru_green"), el((1, 1, -11), (15, 11, -6), "radar_face"), el((7, 10, 4), (9, 17, 6), "metal"), el((0, 10, 8), (16, 12, 16), "ru_green")] def pantsir_pack(): """Пакеты ракет 57Э6 по бортам и спаренные 30-мм пушки над ними; ось качания — центр.""" p = both(el((-11, 2, -16), (-3, 12, 12), "ru_green", {"north": "tubes6"})) p += both(el((-9.5, 12.5, -16), (-8, 14, 6), "barrel")) p += both(el((-6.5, 12.5, -16), (-5, 14, 6), "barrel")) p += both(el((-10, 12, 2), (-4, 15, 10), "dark_metal")) return p def flat_radar(tex="radar_face"): """Плоская вращающаяся антенна (ось — вертикаль через центр модели).""" return [el((-6, 2, 7), (22, 16, 9), tex), el((-6.5, 1.5, 9), (22.5, 16.5, 10), "dark_metal"), el((7, -8, 8), (9, 2, 10), "metal")] def maz_chassis(tex): """8×8 шасси МАЗ-543/БАЗ с раздельными кабинами; рама под пусковой контейнер.""" p = [el((-6, 5, -16), (22, 10, 32), tex), el((-6, 10, -16), (3, 19, -7), tex, {"north": "glass"}), el((13, 10, -16), (22, 19, -7), tex, {"north": "glass"}), el((3, 10, -16), (13, 15, -6), "dark_metal"), el((-5, 10, -6), (21, 13, 4), tex), el((-3, 10, 26), (19, 14, 32), "dark_metal")] p += both(el((-8, 0, 28), (-6, 6, 31), "metal")) # опоры return p + wheels((-14, -5, 13, 22)) def tubes(n_wide, n_high, size, tex="tube"): """Пакет ТПК: n_wide × n_high труб сечением size, от z=-16 до 32. Ось подъёма — задняя нижняя кромка.""" p = [] w = n_wide * size + (n_wide - 1) x0 = 8 - w / 2 for i in range(n_wide): for j in range(n_high): x = x0 + i * (size + 1) y = j * (size + 1) p.append(el((x, y, -16), (x + size, y + size, 32), tex, {"north": "tube_cap", "south": "tube_cap"})) p += [el((x0 - 1, 0, 26), (x0 + w + 1, n_high * (size + 1) - 1, 28), "dark_metal"), el((x0 - 1, 0, -2), (x0 + w + 1, n_high * (size + 1) - 1, 0), "dark_metal")] return p def radar_trailer(): p = [el((-6, 5, -12), (22, 10, 26), "ru_khaki"), el((2, 10, 2), (14, 16, 14), "dark_metal"), el((-5, 10, 16), (21, 18, 26), "ru_khaki")] return p + wheels((-9, 17)) def big_radar_panel(): return [el((-10, 0, 7), (26, 26, 9), "radar_big"), el((-10.5, -0.5, 9), (26.5, 26.5, 10), "dark_metal"), el((6, -6, 8), (10, 0, 12), "metal")] def missile_57e6(): p = taper("missile_ru", -16, -12, 0.5, 2, 0.5, 2, 2) p += [el((7, 7, -12), (9, 9, 4), "missile_ru"), el((6.4, 6.4, 4), (9.6, 9.6, 28), "booster")] p += both(el((3.5, 7.7, 22), (6.4, 8.3, 28), "booster")) p += [el((7.7, 3.5, 22), (8.3, 6.4, 28), "booster"), el((7.7, 9.6, 22), (8.3, 12.5, 28), "booster")] return p def missile_48n6(): p = taper("missile_ru", -16, -11, 1, 3.5, 1, 3.5, 3) p += [el((6.25, 6.25, -11), (9.75, 9.75, 28), "missile_ru"), el((6.5, 6.5, 28), (9.5, 9.5, 30), "nozzle")] p += both(el((1.5, 7.7, 20), (6.25, 8.3, 28), "missile_ru")) p += [el((7.7, 1.5, 20), (8.3, 6.25, 28), "missile_ru"), el((7.7, 9.75, 20), (8.3, 14.5, 28), "missile_ru")] return p def missile_77n6(): p = taper("missile_grey", -16, -10, 1, 4, 1, 4, 3) p += [el((6, 6, -10), (10, 10, 30), "missile_grey"), el((6, 6, 4), (10, 10, 5), "booster"), el((6.5, 6.5, 30), (9.5, 9.5, 32), "nozzle")] p += both(el((3, 7.7, 24), (6, 8.3, 30), "missile_grey")) p += [el((7.7, 3, 24), (8.3, 6, 30), "missile_grey"), el((7.7, 10, 24), (8.3, 13, 30), "missile_grey")] return p def kalibr(): p = taper("missile_grey", -16, -11, 1.5, 4.5, 1.5, 4.5, 3) p += [el((5.75, 5.75, -11), (10.25, 10.25, 30), "missile_grey"), el((-6, 7.6, 6), (22, 8.4, 11), "missile_grey"), el((7.6, 10.25, 25), (8.4, 14, 30), "missile_grey"), el((6.5, 3, 18), (9.5, 5.75, 26), "dark_metal")] p += both(el((2, 7.6, 26), (5.75, 8.4, 30), "missile_grey")) return p def kh22(): """Х-22/Х-32: толстое тело, треугольные крылья, киль сверху и снизу.""" p = taper("missile_ru", -16, -8, 1.5, 6, 1.5, 6, 4) p += [el((5, 5, -8), (11, 11, 30), "missile_ru"), el((7.5, 11, 14), (8.5, 17, 30), "missile_ru"), el((7.5, -1, 18), (8.5, 5, 30), "missile_ru"), el((5.5, 5.5, 30), (10.5, 10.5, 32), "nozzle")] p += delta("missile_ru", 4, 22, 28, 12, 5, 7.6, 8.4) return p def zircon(): p = taper("dark_metal", -16, -6, 1, 5, 1, 4, 4) p += [el((5.5, 6, -6), (10.5, 10, 30), "kh101"), el((6, 3.5, 0), (10, 6, 24), "intake"), el((5.5, 6, 30), (10.5, 10, 32), "nozzle")] p += both(el((1, 7.6, 22), (5.5, 8.4, 30), "kh101")) p += [el((7.6, 10, 22), (8.4, 14, 30), "kh101")] return p def iskander(): """9М723: толстая ракета с конической головной частью и решётчатыми рулями.""" p = taper("ru_khaki", -16, -6, 1.5, 7, 1.5, 7, 5) p += [el((4.5, 4.5, -6), (11.5, 11.5, 30), "ru_khaki"), el((4.4, 4.4, 4), (11.6, 11.6, 5), "dark_metal"), el((5, 5, 30), (11, 11, 32), "nozzle")] p += both(el((1, 7.5, 24), (4.5, 8.5, 30), "ru_khaki")) p += [el((7.5, 1, 24), (8.5, 4.5, 30), "ru_khaki"), el((7.5, 11.5, 24), (8.5, 15, 30), "ru_khaki")] return p def kinzhal(): p = taper("missile_grey", -16, -8, 1.5, 6.5, 1.5, 6.5, 4) p += [el((4.75, 4.75, -8), (11.25, 11.25, 30), "dark_metal"), el((4.6, 4.6, -4), (11.4, 11.4, -3), "missile_grey"), el((5.5, 5.5, 30), (10.5, 10.5, 32), "nozzle")] p += both(el((0, 7.6, 22), (4.75, 8.4, 30), "dark_metal")) p += [el((7.6, 0, 22), (8.4, 4.75, 30), "dark_metal"), el((7.6, 11.25, 22), (8.4, 16, 30), "dark_metal")] return p def tracked(tex): """Гусеничное шасси (Бук, Тор): корпус, гусеницы и надгусеничные полки.""" p = [el((-10, 4, -14), (26, 15, 32), tex), el((-10, 4, -16), (26, 10, -14), tex), el((-8, 15, 22), (24, 16, 32), "dark_metal")] p += both(el((-14, 0, -15), (-10, 9, 31), "track")) p += both(el((-14.5, 5, -15), (-10, 10, 31), tex)) return p def buk_turret(): return [el((-4, 0, -4), (20, 9, 20), "ru_green"), el((1, 2, -10), (15, 14, -4), "radar_face"), # РЛС подсвета на башне el((-2, 9, 6), (18, 11, 18), "ru_green")] def buk_rails(): """Четыре ракеты 9М317 на направляющих; ось качания — центр.""" p = [] for x in (-9, -3, 13, 19): p += [el((x - 1.5, 4, -16), (x + 1.5, 7, 26), "missile_ru"), el((x - 1.8, 3.5, 20), (x + 1.8, 7.5, 27), "booster"), el((x - 0.5, 2, -6), (x + 0.5, 4, 16), "dark_metal")] p += [el((-12, 0, -2), (28, 2, 14), "ru_green")] return p def tor_turret(): """Башня Тора: блок вертикального пуска (8 ячеек сверху) и РЛС спереди; вращается целиком.""" p = [el((-4, 0, -6), (20, 16, 22), "ru_khaki"), el((-2, 2, -11), (18, 15, -6), "radar_face"), el((-3, 16, 2), (19, 17, 20), "dark_metal")] for i in range(4): for j in range(2): x, z = -1 + i * 5, 4 + j * 8 p.append(el((x, 16.5, z), (x + 4, 17.5, z + 6), "tube_cap")) return p def nasams_trailer(): p = [el((-6, 5, -10), (22, 9, 26), "nato_green"), el((-4, 9, 10), (20, 13, 24), "dark_metal"), el((6, 3, -16), (10, 6, -10), "metal")] return p + wheels((4, 14)) def p1sun(): """Дрон-перехватчик: короткий корпус, X-образные лучи с моторами; нос — север.""" p = [el((6.5, 6.5, -10), (9.5, 9.5, 10), "dark_metal"), el((7, 7, -14), (9, 9, -10), "missile")] for dx, dy in ((-1, -1), (-1, 1), (1, -1), (1, 1)): x, y = 8 + dx * 6, 8 + dy * 6 p += [el((min(8, x) , min(8, y), 0), (max(8, x), max(8, y), 1.5), "dark_metal", rot=None), el((x - 2.5, y - 2.5, 0), (x + 2.5, y + 2.5, 0.6), "prop")] return p def radar_truck(): return maz_chassis("ru_khaki") def depot(): """Склад боеприпасов: бетонная площадка, штабели ящиков и контейнеров ракет.""" p = [el((-14, 0, -14), (30, 2, 30), "concrete")] for x, z in ((-12, -12), (-12, 4), (4, -12)): p.append(el((x, 2, z), (x + 12, 10, z + 12), "crate")) for k in range(3): p.append(el((6, 2 + k * 5, 6), (28, 7 + k * 5, 10), "tube", {"north": "tube_cap", "south": "tube_cap"})) return p def tzm_truck(): """Транспортно-заряжающая машина: кабина, кузов с ракетами и кран.""" p = [el((-6, 5, -16), (22, 9, 32), "ru_green"), el((-6, 9, -16), (22, 20, -6), "ru_green", {"north": "glass"}), el((-4, 9, -2), (20, 12, 30), "dark_metal")] for x in (-3, 5, 13): p.append(el((x, 12, -1), (x + 6, 18, 29), "tube", {"north": "tube_cap", "south": "tube_cap"})) p += [el((18, 12, -4), (21, 26, -1), "metal"), el((6, 24, -4), (21, 26, -1), "metal")] return p + wheels((-12, -3, 16, 24)) def ew_truck(): """Станция РЭБ: кузов-фургон, мачта с антенными решётками.""" p = [el((-6, 5, -16), (22, 9, 32), "ru_khaki"), el((-6, 9, -16), (22, 19, -6), "ru_khaki", {"north": "glass"}), el((-5, 9, -4), (21, 22, 30), "ru_khaki"), el((7, 22, 10), (9, 32, 12), "antenna")] for y in (24, 28): p += both(el((-4, y, 9), (7, y + 3, 13), "antenna")) return p + wheels((-12, -3, 16, 24)) def searchlight(): """Прожектор на тумбе: тумба и голова отдельно (голова — `searchlight_head`).""" return [el((2, 0, 2), (14, 3, 14), "dark_metal"), el((6, 3, 6), (10, 12, 10), "dark_metal")] def searchlight_head(): """Голова прожектора: нос (стекло) смотрит на север; ось — центр.""" return [el((3, 3, -2), (13, 13, 14), "dark_metal"), el((3.5, 3.5, -2.5), (12.5, 12.5, -2), "lamp")] def beam(): """Луч прожектора: длинная светящаяся трубка вдоль z, от z=32 к -16 (масштабируется по длине).""" return [el((6.5, 6.5, -16), (9.5, 9.5, 32), "beam")] def flare(): return [el((6.5, 6.5, 6.5), (9.5, 9.5, 9.5), "lamp"), el((7.6, 9.5, 7.6), (8.4, 16, 8.4), "antenna")] # ---------------------------------------------------------------- обновлённая геометрия def B(x0, y0, z0, x1, y1, z1, tex, faces=None, rot=None): """Элемент с x относительно центра модели.""" return el((8 + x0, y0, z0), (8 + x1, y1, z1), tex, faces, rot) def wheel(x0, x1, zc, r=4.6, tex="tire", hub="hub"): """Колесо-восьмиугольник (квадрат + квадрат под 45 градусов) с диском на боковой грани, обе стороны. x0..x1 — абсолютные координаты левого колеса; ось колеса лежит на y = r.""" s = r * 1.8478 faces = {"east": hub, "west": hub} a = el((x0, r - s / 2, zc - s / 2), (x1, r + s / 2, zc + s / 2), tex, faces) b = el((x0 + 0.03, r - s / 2, zc - s / 2), (x1 - 0.03, r + s / 2, zc + s / 2), tex, faces, rot=("x", 45)) return both(a) + both(b) def wheels(zs, x0=-9, x1=-5.5, tex="tire"): out = [] for z in zs: out += wheel(x0, x1, z + 3.5, 4.5, tex) return out def tracked(tex): """Гусеничное шасси (Бук, Тор): корпус, скошенный лоб, гусеницы с катками, решётки МТО, люки.""" p = [el((-10, 4, -14), (26, 15, 32), tex), el((-10, 4, -16), (26, 10, -14), tex), el((-9, 10, -15), (25, 14, -12), tex, rot=("x", -22.5)), # лобовой лист el((-8, 15, 22), (24, 16, 32), "dark_metal"), el((-5, 15.2, 24), (21, 15.8, 31), "bare_nozzle"), # решётка МТО el((-1, 15, -10), (9, 16, -4), "dark_metal")] p += both(el((-14, 0, -15), (-10, 9, 31), "track", {"west": "track_side", "east": "track_side"})) p += both(el((-14.5, 5, -15), (-10, 10, 31), tex)) p += both(el((-4, 11, -16), (-1, 13, -15), "bare_lamp")) return p def oct_z(z0, z1, r, tex, x=0.0, y=8.0): """Восьмигранная «труба» вдоль z (квадрат + квадрат под 45 градусов): корпус ракеты.""" s = r * 1.8478 f = {"up": tex + "_t", "down": tex + "_t"} if tex in RINGED else None return [B(x - s / 2, y - s / 2, z0, x + s / 2, y + s / 2, z1, tex, f), B(x - s / 2, y - s / 2, z0 + 0.03, x + s / 2, y + s / 2, z1 - 0.03, tex, f, rot=("z", 45))] def ogive(z_tip, z_base, rb, tex, steps=4): out = [] for i in range(steps): a = z_tip + (z_base - z_tip) * i / steps b = z_tip + (z_base - z_tip) * (i + 1) / steps t = (i + 0.7) / steps out += oct_z(a, b, max(0.45, rb * min(1.0, t ** 0.55)), tex) return out def fins4(z0, z1, r, span, tex, sweep=3.0, th=0.7, y=8.0): """Четыре стреловидных оперения крестом: корневая и концевая части.""" m, k = r * 0.9, span * 0.55 p = both(B(m, y - th / 2, z0, m + k, y + th / 2, z1, tex)) p += both(B(m + k, y - th / 2, z0 + sweep, m + span, y + th / 2, z1, tex)) for sg in (1, -1): lo, hi = (y + m, y + m + k) if sg > 0 else (y - m - k, y - m) p.append(B(-th / 2, lo, z0, th / 2, hi, z1, tex)) lo, hi = (y + m + k, y + m + span) if sg > 0 else (y - m - span, y - m - k) p.append(B(-th / 2, lo, z0 + sweep, th / 2, hi, z1, tex)) return p def nozzle(z, r, y=8.0): """Сопло с раскалённым срезом (glow_ember — светящийся материал).""" return [B(-r * .8, y - r * .8, z, r * .8, y + r * .8, z + 1.6, "nozzle"), B(-r * .55, y - r * .55, z + 1.6, r * .55, y + r * .55, z + 1.9, "glow_ember")] def pac3(): p = ogive(-16, -9, 1.7, "nose_dark", 3) + oct_z(-9, 24, 1.7, "rm_pac3") p += oct_z(-8, -7, 2.2, "dark_metal") # кольцо двигателей ориентации p += fins4(18, 24, 1.7, 5, "missile", 3.5) + nozzle(24, 1.7) return p def irist_missile(): p = ogive(-16, -9, 1.7, "white", 3) + oct_z(-9, 22, 1.7, "rm_white") p += fins4(-8, -5.5, 1.7, 3.6, "missile_grey", 1.0) + fins4(16, 22, 1.7, 5, "white", 3.5) + nozzle(22, 1.7) return p def amraam_missile(): p = ogive(-16, -9, 1.7, "nose_dark", 3) + oct_z(-9, 24, 1.7, "rm_white") p += fins4(2, 8, 1.7, 5.5, "white", 3.5) + fins4(19, 24, 1.7, 3.8, "white", 2.5) + nozzle(24, 1.7) return p def missile_57e6(): p = ogive(-16, -9, 1.2, "missile_ru", 3) + oct_z(-9, 4, 1.2, "missile_ru") + oct_z(4, 25, 1.7, "rm_ru") p += fins4(18, 25, 1.7, 5, "booster", 3.0) + nozzle(25, 1.7) return p def missile_48n6(): p = ogive(-16, -9, 1.9, "missile_ru", 3) + oct_z(-9, 26, 1.9, "rm_ru") p += fins4(18, 26, 1.9, 6.2, "missile_ru", 4.0) + fins4(-6, -3, 1.9, 2.5, "missile_ru", 1.0) + nozzle(26, 1.9) return p def missile_77n6(): p = ogive(-16, -8, 2.2, "missile_grey", 4) + oct_z(-8, 28, 2.2, "rm_grey") p += oct_z(4, 6, 2.4, "booster") + fins4(22, 28, 2.2, 5.2, "missile_grey", 3.0) + nozzle(28, 2.2) return p def kalibr(): p = ogive(-16, -9, 2.2, "missile_grey", 4) + oct_z(-9, 28, 2.2, "rm_grey") p += [B(-14, 7.6, 6, 14, 8.4, 11, "missile_grey"), B(-0.8, 3.2, 17, 0.8, 5.4, 26, "dark_metal")] p += fins4(23, 28, 2.2, 4.5, "missile_grey", 2.5) + nozzle(28, 2.0) return p def iskander(): p = ogive(-16, -5, 3.6, "rm_khaki", 6) + oct_z(-5, 28, 3.6, "rm_khaki") p += oct_z(3, 4, 3.8, "dark_metal") + fins4(23, 28, 3.6, 6.5, "ru_khaki", 2.0, 0.9) + nozzle(28, 3.4) return p def kinzhal(): p = ogive(-16, -6, 3.4, "rm_dark", 5) + oct_z(-6, 28, 3.4, "rm_dark") p += fins4(20, 28, 3.4, 8.0, "dark_metal", 4.0, 0.9) + nozzle(28, 3.0) return p def zircon(): p = ogive(-16, -6, 2.6, "rm_dark", 4) + oct_z(-6, 28, 2.6, "rm_dark") p += [B(-1.8, 3.4, 0, 1.8, 5.2, 24, "intake")] p += fins4(22, 28, 2.6, 5.5, "dark_metal", 3.0) + nozzle(28, 2.4) return p # ---------------------------------------------------------------- часть 2: беспилотники, Х-101/Х-22, детали техники def delta2(tex, under, z_root, z_tip, z_trail, half, steps, y0, y1): """Дельта-крыло со своей нижней обшивкой.""" out = [] for i in range(steps): za = z_root + (z_tip - z_root) * i / steps sp = half * (i + 1) / steps out.append(B(-sp, y0, za, sp, y1, z_trail, tex, {"down": under})) return out def decal(tex, y0, y1, z0, z1, x, w=0.12): """Маркировка на борту (тонкая плита, обе стороны), x — внешняя грань корпуса от центра.""" return both(B(x, y0, z0, x + w, y1, z1, tex)) def shahed(): p = ogive(-16, -9, 2.6, "shahed_nose", 3) + oct_z(-9, 22, 2.6, "shahed") p += delta2("shahed", "kh101_belly", -2, 21, 26, 21, 9, 7.2, 8.4) p += both(B(-20.8, 5.5, 17, -19.8, 13.5, 26, "shahed")) # шайбы-кили на законцовках p += oct_z(22, 28, 1.9, "dark_metal") + both(B(0.8, 5.2, 24, 1.6, 6.8, 28, "bare_nozzle")) p += [B(-1.5, 10.5, -4, 1.5, 12, 8, "dark_metal"), # люк антенны ГНСС B(-0.3, 12, 2, 0.3, 14.5, 3, "bare_antenna"), B(-1.2, 4.8, -12, 1.2, 5.6, -6, "bare_lamp")] # оптика под носом return p def geran3(): p = ogive(-16, -9, 2.8, "geran", 3) + oct_z(-9, 26, 2.8, "geran") p += delta2("geran", "geran", 0, 20, 26, 18, 8, 7.2, 8.4) p += both(B(-10.6, 6.5, 18, -9.6, 13, 26, "geran")) p += oct_z(6, 28, 2.3, "geran_engine", 0, 14) # турбореактивный двигатель на пилоне p += [B(-1.2, 10.5, 8, 1.2, 12, 22, "geran"), B(-2, 12, 4, 2, 16, 6, "bare_nozzle"), B(-1.7, 12.3, 28, 1.7, 15.7, 29.5, "bare_nozzle"), B(-1.2, 12.8, 29.5, 1.2, 15.2, 29.9, "glow_ember"), B(-0.3, 10.8, -6, 0.3, 12.8, -5, "bare_antenna")] return p def lancet(): p = ogive(-16, -10, 2.0, "lancet", 3) + oct_z(-10, 20, 2.0, "lancet") p += [B(-1.1, 6.8, -16.4 + 0.4, 1.1, 8.8, -15.6, "bare_glass_dark")] if False else [] p += [B(-1.2, 5.2, -14, 1.2, 6.4, -9, "bare_lamp")] # оптоэлектронная головка for z0, z1, half in ((-5, 1, 13), (12, 18, 11)): # Х-образные крылья for ang in (45, -45): p.append(B(-half, 7.7, z0, half, 8.3, z1, "lancet", rot=("z", ang))) p += oct_z(20, 24, 1.5, "dark_metal") p += [B(-5, 7.85, 24, 5, 8.15, 24.5, "prop"), B(-0.15, 3, 24, 0.15, 13, 24.5, "prop")] return p def gerbera(): p = ogive(-15, -10, 2.3, "foam", 3) + oct_z(-10, 20, 2.3, "foam") p += delta2("foam", "foam", 0, 18, 22, 17, 7, 7.3, 8.5) p += both(B(-9.6, 6.5, 15, -8.6, 13, 22, "foam")) p += [B(-1.5, 10, -6, 1.5, 11.6, 4, "dark_metal"), B(-1.4, 5.0, -14, 1.4, 6.2, -9, "bare_lamp")] p += oct_z(20, 24, 1.6, "dark_metal") + [B(-0.2, 3, 24, 0.2, 13, 24.5, "prop"), B(-5, 7.8, 24, 5, 8.2, 24.5, "prop")] return p def kh101(): p = ogive(-16, -8, 2.9, "kh101", 4, ) + oct_z(-8, 27, 2.9, "kh101") p = [dict(e, **{"from": [e["from"][0], e["from"][1] - 0.5, e["from"][2]], "to": [e["to"][0], e["to"][1] - 0.5, e["to"][2]]}) for e in p] p += both(B(2.5, 6.9, 2, 14, 7.6, 11, "kh101", {"down": "kh101_belly"})) # крылья: корень и конец p += both(B(14, 6.9, 5, 20.5, 7.4, 11, "kh101", {"down": "kh101_belly"})) p += [B(-0.4, 10, 20, 0.4, 16, 28, "kh101")] + both(B(1.5, 6.9, 22, 8, 7.4, 28, "kh101")) p += [B(-1.9, 3.2, 12, 1.9, 5.4, 26, "dark_metal"), B(-1.6, 3.5, 11.4, 1.6, 5.1, 12, "bare_nozzle"), B(-1.5, 3.6, 26, 1.5, 5, 28, "bare_nozzle"), B(-1.1, 3.9, 28, 1.1, 4.7, 28.4, "glow_ember"), B(-1, 10, -2, 1, 10.6, 10, "dark_metal")] return p def kh22(): p = ogive(-16, -8, 3.0, "rm_ru", 4) + oct_z(-8, 28, 3.0, "rm_ru") p += both(B(3, 7.6, 4, 14, 8.4, 16, "missile_ru")) + both(B(14, 7.6, 8, 18, 8.2, 16, "missile_ru")) p += fins4(21, 28, 3.0, 6.5, "missile_ru", 4.0) p += [B(-0.5, 11, 14, 0.5, 17, 28, "missile_ru"), B(-0.5, -1, 16, 0.5, 5, 28, "missile_ru")] p += nozzle(28, 2.6) return p def patriot_launcher(): """Пакет 4 ТПК M903: четыре отдельных контейнера с торцами, ремни, рама. Центры труб — как у `Muzzle::Patriot`.""" p = [] for x0, x1 in ((-6, 7.8), (8.2, 22)): for y0, y1 in ((1.5, 9.5), (10.5, 18.5)): p.append(el((x0, y0, -14), (x1, y1, 30), "canister", {"north": "canister_front", "south": "dark_metal"})) for z in (-6, 8, 20): p.append(el((x0 - 0.2, y0 - 0.2, z), (x1 + 0.2, y1 + 0.2, z + 1.5), "dark_metal")) p += [el((7.5, 1, -14), (8.5, 19, 30), "dark_metal"), el((-7, 0, 24), (23, 2, 30), "metal")] p += both(el((-8, 2, -10), (-6, 17, -6), "metal")) + both(el((-8, 2, 20), (-6, 17, 24), "metal")) return p def _plus(fn, extra): """Обёртка: те же детали и ещё несколько.""" return lambda *a: fn(*a) + extra() # кабины: фары, бампер, зеркала, выхлоп, бак, номера _maz0 = maz_chassis def maz_chassis(tex): p = _maz0(tex) p += both(B(-12.5, 11, -16, -10.5, 12.6, -15.6, "bare_lamp")) + both(B(-12.5, 14.5, -16, -10.5, 15.5, -15.6, "bare_lamp")) p += [B(-9, 5.5, -16, 9, 7.5, -15, "dark_metal")] # бампер (z до -17 нельзя: ниже -16 отсекается) p += both(B(-18.5, 7, 4, -15, 10, 14, "dark_metal")) # топливные баки p += both(B(-14.5, 15, -9, -13.5, 22, -8, "bare_antenna")) # выхлопные трубы p += decal("mk_ru", 11, 15, -15, -7, 14) return p _tr0 = tracked def tracked(tex): p = _tr0(tex) p += decal("mk_ru2", 11, 14.5, 0, 8, 18) p += both(B(-5, 16, 2, -1, 17.4, 8, "dark_metal")) + [B(-3, 16, -8, 3, 17, -4, "bare_lamp")] return p _pt0 = patriot_truck def patriot_truck(): p = _pt0() p += both(B(-14.2, 10, -12, -14, 14, -4, "glass_cab")) p += decal("mk_us", 11, 15, -12, -4, 14) p += both(B(-14, 7, 6, -17, 11, 14, "dark_metal")) p += [B(-9, 5.5, -16, 9, 7.5, -15.2, "dark_metal")] p += both(B(-12.5, 10.5, -16, -10.5, 12.2, -15.4, "bare_lamp")) return p _pz0 = pantsir_truck def pantsir_truck(): p = _pz0() p += decal("mk_star", 12, 16, -14, -6, 14) p += both(B(-12.5, 10.5, -16, -10.5, 12.2, -15.4, "bare_lamp")) + both(B(-18.5, 7, 6, -15, 10, 14, "dark_metal")) return p _gt0 = gepard_turret def gepard_turret(): p = _gt0() p += decal("mk_de", 3, 6.5, -5.5, 1.5, 12) p += [B(-2.5, 14, 2, 2.5, 14.8, 7, "metal"), B(-6, 14, 5, -5, 16, 6, "bare_antenna")] p += both(B(-12.8, 7, -5, -12, 10, -2, "dark_metal")) + both(B(-12.8, 7, -1.5, -12, 10, 0.5, "dark_metal")) # дымовые гранатомёты return p _pu0 = pantsir_turret def pantsir_turret(): p = _pu0() p += [el((5, 10, -4), (11, 13, 0), "dark_metal"), el((6, 10.5, -4.4), (10, 12.5, -4), "bare_lamp"), el((1, 12, 10), (15, 12.6, 15), "metal")] p += decal("mk_ru", 3, 6.5, -4, 3, 10) return p _tz0 = tzm_truck def tzm_truck(): p = _tz0() p += decal("mk_ru2", 11, 15, -14, -7, 14) + both(B(-12.5, 10, -16, -10.5, 11.6, -15.4, "bare_lamp")) return p _ew0 = ew_truck def ew_truck(): p = _ew0() p += decal("mk_ru", 11, 15, -14, -7, 14) + both(B(-12.5, 10, -16, -10.5, 11.6, -15.4, "bare_lamp")) p += [B(-2, 22, -4, 2, 23.5, 0, "dark_metal")] return p def launch_ramp(): """Рампа-катапульта для дронов: основание, наклонные рельсы (нос на север поднят), опоры и ложе.""" p = [el((1, 0, -6), (15, 2, 30), "dark_metal"), el((6, 2, 0), (10, 4, 26), "metal")] p += both(el((3, 9, -14), (5, 11, 30), "metal", rot=("x", 22.5))) p += both(el((3, 0, -1), (5, 12, 1), "dark_metal")) + both(el((3, 0, 17), (5, 6, 19), "dark_metal")) p += [el((4, 10, 24), (12, 14, 30), "dark_metal"), el((1, 2, 28), (15, 6, 32), "ru_khaki")] return p def launcher_truck(): """Пусковая машина ракет: шасси МАЗ и наклонный пакет труб, смотрящий вперёд (на север) вверх.""" p = maz_chassis("ru_khaki") for x in (4, 10): p.append(el((x, 15, -4), (x + 5, 20, 30), "tube", {"north": "tube_cap", "south": "tube_cap"}, rot=("x", 45))) p += [el((3, 13, 22), (13, 17, 28), "dark_metal")] return p def barge(): """Баржа под пусковой комплект: плоская палуба 3×3 блока, корпус, борта, кнехты и скошенный нос.""" p = [el((-16, 0, -16), (32, 3, 32), "dark_metal"), el((-16, 3, -16), (32, 4, 32), "metal"), el((-15, 3, -15), (31, 4.4, 31), "ru_khaki")] p += both(el((-16, 4, -16), (-14, 6, 32), "dark_metal")) p += [el((-16, 4, 30), (32, 6, 32), "dark_metal"), el((-16, 4, -16), (32, 6, -14), "dark_metal")] for z in (-10, 8, 26): p += both(el((-12, 4.4, z), (-9, 6.5, z + 3), "metal")) return p MODELS = { "shahed": (shahed, "Шахед-136"), "shahed_prop": (shahed_prop, "Винт"), "geran3": (geran3, "Герань-3"), "geran3_flame": (geran3_flame, "Факел"), "pac3": (pac3, "PAC-3 MSE"), "irist_missile": (irist_missile, "IRIS-T SLM"), "amraam_missile": (amraam_missile, "AMRAAM"), "flame": (flame, "Факел"), "patriot_launcher": (patriot_launcher, "ПУ M903"), "patriot_truck": (patriot_truck, "Тягач"), "patriot_trailer": (patriot_trailer, "Полуприцеп"), "patriot_radar": (patriot_radar, "РЛС AN/MPQ-65"), "gepard_hull": (gepard_hull, "Корпус Гепарда"), "gepard_turret": (gepard_turret, "Башня Гепарда"), "gepard_guns": (gepard_guns, "Пушки Гепарда"), "gepard_dish": (gepard_dish, "РЛС Гепарда"), "target": (target_marker, "Цель"), "fireball": (fireball, "Огненный шар"), "flash": (flash, "Вспышка"), "debris": (debris, "Обломки"), "lancet": (lancet, "Ланцет-3"), "gerbera": (gerbera, "Гербера"), "kh101": (kh101, "Х-101"), "pantsir_truck": (pantsir_truck, "Шасси Панциря"), "pantsir_turret": (pantsir_turret, "Модуль Панциря"), "pantsir_pack": (pantsir_pack, "Ракеты и пушки Панциря"), "pantsir_dish": (flat_radar, "РЛС Панциря"), "s400_chassis": (lambda: maz_chassis("ru_green"), "Шасси С-400"), "s400_tubes": (lambda: tubes(2, 2, 7), "ТПК 48Н6"), "s500_chassis": (lambda: maz_chassis("ru_khaki"), "Шасси С-500"), "s500_tubes": (lambda: tubes(2, 1, 9.5), "ТПК 77Н6"), "radar_trailer": (radar_trailer, "Прицеп РЛС"), "radar_panel": (big_radar_panel, "РЛС обзора"), "m57e6": (missile_57e6, "57Э6"), "m48n6": (missile_48n6, "48Н6"), "m77n6": (missile_77n6, "77Н6"), "kalibr": (kalibr, "Калибр"), "kh22": (kh22, "Х-22"), "zircon": (zircon, "Циркон"), "iskander": (iskander, "Искандер-М"), "kinzhal": (kinzhal, "Кинжал"), "tracer": (lambda: [el((7.6, 7.6, -16), (8.4, 8.4, 32), "tracer"), el((7.3, 7.3, -16), (8.7, 8.7, -4), "tracer_hot")], "Трассер"), # Боевой блок «Орешника»: раскалённое ядро и длинный плазменный хвост. "irist_chassis": (lambda: maz_chassis("nato_green"), "Шасси IRIS-T SLM"), "irist_tubes": (lambda: tubes(4, 2, 4, "canister"), "Пакет IRIS-T"), "nasams_trailer": (nasams_trailer, "Прицеп NASAMS"), "nasams_launcher": (lambda: tubes(3, 2, 4, "canister"), "Пусковая NASAMS"), "buk_chassis": (lambda: tracked("ru_green"), "Шасси Бука"), "buk_turret": (buk_turret, "Башня Бука"), "buk_rails": (buk_rails, "Ракеты Бука"), "tor_chassis": (lambda: tracked("ru_khaki"), "Шасси Тора"), "tor_turret": (tor_turret, "Башня Тора"), "p1sun_launcher": (lambda: tubes(4, 2, 3, "dark_metal"), "Пусковая P1-SUN"), "p1sun": (p1sun, "Дрон-перехватчик P1-SUN"), "radar_truck": (radar_truck, "Шасси РЛС"), "depot": (depot, "Склад боеприпасов"), "tzm_truck": (tzm_truck, "Транспортно-заряжающая машина"), "barge": (barge, "Баржа"), "launch_ramp": (launch_ramp, "Рампа запуска дронов"), "launcher_truck": (launcher_truck, "Пусковая машина ракет"), "ew_truck": (ew_truck, "Станция РЭБ"), "searchlight": (searchlight, "Прожектор"), "searchlight_head": (searchlight_head, "Голова прожектора"), "beam": (beam, "Луч прожектора"), "flare": (flare, "Осветительная ракета"), "reentry": (lambda: [el((6.5, 6.5, -16), (9.5, 9.5, -8), "white"), el((7, 7, -8), (9, 9, 12), "tracer_hot"), el((7.5, 7.5, 12), (8.5, 8.5, 32), "flame")], "Боевой блок"), } WRECKS = ["patriot_trailer", "patriot_launcher", "gepard_hull", "gepard_turret", "gepard_guns", "pantsir_truck", "pantsir_turret", "pantsir_pack", "s400_chassis", "s400_tubes", "s500_chassis", "s500_tubes", "irist_chassis", "irist_tubes", "nasams_trailer", "nasams_launcher", "buk_chassis", "buk_turret", "buk_rails", "tor_chassis", "tor_turret", "p1sun_launcher", "radar_truck", "depot", "tzm_truck", "ew_truck", "searchlight", "searchlight_head"] CAM_MODELS = ["shahed", "geran3", "lancet", "gerbera", "kh101", "kalibr", "kh22", "zircon", "iskander", "kinzhal", "reentry"] def generate(out): # Пишет весь набор в каталог out (зеркало airdef-mod/). Звуки (tools/gen_sounds.py) и sounds.json не трогаем. a = os.path.join(out, "assets", NS) d = os.path.join(out, "data", NS, "horizon") os.makedirs(os.path.join(a, "textures", "block")) os.makedirs(os.path.join(a, "textures", "item")) for wid in WRECKS: fn, title = MODELS[wid] MODELS[wid + "_burnt"] = ((lambda fn=fn, wid=wid: burnt_parts(fn(), zlib.crc32(wid.encode()))), title + " (остов)") for name, (fn, _) in MODELS.items(): write_json(os.path.join(a, "models", "block", name + ".json"), model(fn(), name.endswith("_burnt"))) GT.write_block_textures(a, TEXTURES, VARIANTS, STRETCH_FN) for name, _, color in ITEMS: GT.write_item_icon(a, name, render_icon(name, color, zlib.crc32(name.encode()))) write_registry(out, NS, [(name, title) for name, (_, title) in MODELS.items()], ITEMS, CAM_MODELS) return len(MODELS), len(ITEMS), len(TEXTURES) # ---------------------------------------------------------------- проверка набора до переноса def check_png(path, rel): try: with Image.open(path) as im: w, h = im.size im.load() except Exception as e: # битый файл или не PNG return [f"{rel}: PNG не открывается: {e}"] errs = [] if w != h: errs.append(f"{rel}: текстура не квадратная, {w}x{h}") if w < 8 or w > 256 or w & (w - 1): errs.append(f"{rel}: размер {w}x{h}, ожидается степень двойки от 8 до 256") return errs def validate(stage): # Проверяет набор в каталоге stage до того, как он попадёт в airdef-mod/. Возвращает список ошибок. # Ссылки airdef:<путь> проверяем по набору; ссылки minecraft:... и без пространства имён — ванильные, их не проверяем. errors = [] files, docs = set(), {} for dirpath, _, names in os.walk(stage): for name in sorted(names): path = os.path.join(dirpath, name) rel = os.path.relpath(path, stage).replace(os.sep, "/") files.add(rel) if name.endswith(".json"): try: with open(path, encoding="utf-8") as f: docs[rel] = json.load(f) except (OSError, ValueError) as e: errors.append(f"{rel}: не разбирается как JSON: {e}") elif name.endswith(".png"): errors.extend(check_png(path, rel)) def check_ref(ref, kind, ext, owner): if not isinstance(ref, str) or not ref.startswith(NS + ":"): return path = ref[len(NS) + 1:] if not path or path.startswith("/") or ".." in path.split("/"): errors.append(f"{owner}: недопустимая ссылка {ref}") return rel = f"assets/{NS}/{kind}/{path}{ext}" if rel not in files: errors.append(f"{owner}: ссылка {ref} не найдена ({rel})") def pick(prefix): return sorted(rel for rel in docs if rel.startswith(f"assets/{NS}/{prefix}")) for rel in pick("models/"): doc = docs[rel] if not isinstance(doc, dict): errors.append(f"{rel}: модель должна быть объектом JSON") continue if "parent" in doc: check_ref(doc["parent"], "models", ".json", rel) tex = doc.get("textures", {}) for key, val in tex.items(): if isinstance(val, str) and val.startswith("#"): if val[1:] not in tex: errors.append(f"{rel}: текстура {val} не объявлена") else: check_ref(val, "textures", ".png", rel) for i, el in enumerate(doc.get("elements", [])): for corner in ("from", "to"): pos = el.get(corner, []) if len(pos) != 3 or any(not -16 <= c <= 32 for c in pos): errors.append(f"{rel}: элемент {i}, {corner} {pos} вне -16..32") for face, fd in el.get("faces", {}).items(): tex_ref = fd.get("texture") if tex_ref is None: errors.append(f"{rel}: элемент {i}, грань {face} без texture") elif tex_ref.startswith("#"): if tex_ref[1:] not in tex: errors.append(f"{rel}: элемент {i}, грань {face}: {tex_ref} не объявлена") else: check_ref(tex_ref, "textures", ".png", rel) for rel in pick("blockstates/"): for variant in docs[rel].get("variants", {}).values(): for m in (variant if isinstance(variant, list) else [variant]): check_ref(m.get("model"), "models", ".json", rel) for rel in pick("items/"): check_ref(docs[rel].get("model", {}).get("model"), "models", ".json", rel) # У каждого блока есть blockstate, и наоборот. blocks = {rel[len(f"assets/{NS}/models/block/"):] for rel in pick("models/block/")} states = {rel[len(f"assets/{NS}/blockstates/"):] for rel in pick("blockstates/")} for name in sorted(blocks - states): errors.append(f"models/block/{name}: нет blockstate") for name in sorted(states - blocks): errors.append(f"blockstates/{name}: нет модели блока") en = docs.get(f"assets/{NS}/lang/en_us.json") ru = docs.get(f"assets/{NS}/lang/ru_ru.json") if en is None or ru is None: errors.append("нет lang/en_us.json или lang/ru_ru.json") elif set(en) != set(ru): errors.append(f"lang: ключи en_us и ru_ru различаются: {sorted(set(en) ^ set(ru))[:5]}") return errors def prune_empty(root, path): d = os.path.dirname(path) 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}") write_rust_bounds(stage) 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 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__": main()