"""Agent paper-doll authoring bridge: Blender / raster PNG -> SpriteForge. Два входа, один выход. Blender-конвейер (`blender-init` + `blender-render`) детерминированно рендерит компонент под одной неподвижной ортографической изометрической камерой. Растровый конвейер (`ingest`) принимает 4x2 доски с хромакеем от генератора картинок. Оба дают один и тот же лист: направления подряд, ячейка 70x90, pivot (35,87). Ключ к выравниванию бумажной куклы — общий для слоёв, а не индивидуальный подгон: * в Blender начало координат рига всегда проецируется в центр кадра, поэтому упаковщик режет все кадры ОДНИМ прямоугольником — bbox не участвует вовсе; * в растре масштаб и смещение считаются один раз по слою тела и лежат в `_calibration.yaml`; броня и оружие проходят через ту же самую аффинную подстановку, поэтому сохраняют свои координаты относительно ПОЛНОЙ ячейки. Индивидуальный fit по bbox для экипировки запрещён: он растянул бы кинжал на всю клетку и посадил бы его на уровень стоп. """ from __future__ import annotations import argparse import json import os import re import shutil import subprocess import sys import tempfile from dataclasses import dataclass from pathlib import Path from PIL import Image, ImageChops import yaml DEFAULT_BLENDER = r"C:\Program Files\Blender Foundation\Blender 5.2\blender.exe" # Фигура тела занимает по высоте ячейку минус этот зазор. FIT_MARGIN_Y = 6 # Допуски проверок (в пикселях листа). PIVOT_BOTTOM_SLACK = 5 PIVOT_CENTER_SLACK = 8 # Насколько экипировке разрешено выходить за силуэт тела. EQUIP_MARGIN = 10 # Порог зеркального совпадения силуэтов для проверки порядка направлений. MIRROR_IOU_MIN = 0.55 # Выше этого юг и север считаются одной и той же картинкой. DISTINCT_IOU_MAX = 0.98 def fnv1a(text: str) -> int: value = 0x811C9DC5 for byte in text.encode("utf-8"): value = ((value ^ byte) * 0x01000193) & 0xFFFFFFFF return value def symbol(text: str) -> str: return re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").upper() # --------------------------------------------------------------------------- # Пути и конфигурация # --------------------------------------------------------------------------- @dataclass(frozen=True) class Workspace: """Все пути конвейера в одном месте: тесты подставляют временный корень.""" root: Path @property def catalog(self) -> Path: return self.root / "assets/agents/catalog.yaml" @property def source_root(self) -> Path: return self.root / "assets/sources/agents/sleek" @property def manifest(self) -> Path: return self.root / "assets/manifests/agents.generated.yaml" @property def header(self) -> Path: return self.root / "generated/agent_paperdoll.h" @property def blend(self) -> Path: return self.root / "assets/agents/agent_rig.blend" @property def calibration(self) -> Path: return self.source_root / "_calibration.yaml" @property def rig_script(self) -> Path: return self.root / "tools/blender/agent_rig.py" def source_path(self, component: str, animation: str) -> Path: return self.source_root / f"{component}_{animation}.png" WORKSPACE = Workspace(Path(__file__).resolve().parents[1]) @dataclass(frozen=True) class Config: width: int height: int pivot_x: int pivot_y: int directions: tuple[str, ...] animations: dict components: dict camera: dict # Кадр рендера симметричен относительно pivot: начало координат рига # смотрит в его центр, значит вырезка — постоянный прямоугольник. @property def render_width(self) -> int: return 2 * max(self.pivot_x, self.width - self.pivot_x) @property def render_height(self) -> int: return 2 * max(self.pivot_y, self.height - self.pivot_y) @property def crop_x(self) -> int: return self.render_width // 2 - self.pivot_x @property def crop_y(self) -> int: return self.render_height // 2 - self.pivot_y def frames(self, animation: str) -> int: return int(self.animations[animation]["frames"]) def cells(self, animation: str) -> int: return len(self.directions) * self.frames(animation) def sheet_size(self, animation: str) -> tuple[int, int]: return (self.width * self.cells(animation), self.height) def slot(self, component: str) -> str: return self.components[component]["slot"] def load_config(ws: Workspace = WORKSPACE) -> Config: raw = yaml.safe_load(ws.catalog.read_text(encoding="utf-8")) if raw.get("version") != 1: raise ValueError("assets/agents/catalog.yaml: only version 1 is supported") frame = raw["frame"] return Config(frame["width"], frame["height"], frame["pivot_x"], frame["pivot_y"], tuple(raw["directions"]), raw["animations"], raw["components"], raw.get("camera", {})) def check_names(cfg: Config, component: str, animation: str) -> None: if component not in cfg.components: raise ValueError(f"unknown component: {component}") if animation not in cfg.animations: raise ValueError(f"unknown animation: {animation}") def asset_id(component: str, animation: str) -> str: return f"agent_{component}_{animation}" # --------------------------------------------------------------------------- # Работа с картинками # --------------------------------------------------------------------------- def resize_rgba(image: Image.Image, size: tuple[int, int]) -> Image.Image: """LANCZOS по предумноженной альфе: иначе по кромке ползёт чёрная кайма.""" if image.size == size: return image.copy() r, g, b, a = image.convert("RGBA").split() premultiplied = Image.merge("RGBA", (ImageChops.multiply(r, a), ImageChops.multiply(g, a), ImageChops.multiply(b, a), a)) scaled = premultiplied.resize(size, Image.Resampling.LANCZOS) px = scaled.load() for y in range(scaled.height): for x in range(scaled.width): pr, pg, pb, pa = px[x, y] if pa: px[x, y] = (min(255, pr * 255 // pa), min(255, pg * 255 // pa), min(255, pb * 255 // pa), pa) else: px[x, y] = (0, 0, 0, 0) return scaled def remove_key(image: Image.Image) -> Image.Image: rgba = image.convert("RGBA") px = rgba.load(); key = px[0, 0][:3] for y in range(rgba.height): for x in range(rgba.width): r, g, b, _ = px[x, y] distance = max(abs(r-key[0]), abs(g-key[1]), abs(b-key[2])) alpha = 0 if distance <= 18 else (255 if distance >= 64 else (distance-18)*255//46) px[x, y] = (r, g, b, alpha) return rgba def mask_of(image: Image.Image) -> Image.Image: return image.convert("RGBA").getchannel("A").point(lambda v: 255 if v >= 24 else 0).convert("1") def cell_at(cfg: Config, sheet: Image.Image, index: int) -> Image.Image: x = index * cfg.width return sheet.crop((x, 0, x + cfg.width, cfg.height)) def paste_cell(cfg: Config, sheet: Image.Image, index: int, layer: Image.Image) -> None: sheet.paste(layer, (index * cfg.width, 0)) # --------------------------------------------------------------------------- # Blender: создание .blend и рендер # --------------------------------------------------------------------------- def blender_binary(explicit: str | None = None) -> str: candidate = explicit or os.environ.get("BLENDER_BIN") or DEFAULT_BLENDER if Path(candidate).is_file(): return candidate found = shutil.which(candidate) or shutil.which("blender") if found: return found raise ValueError(f"blender not found: {candidate} (set BLENDER_BIN or pass --blender)") def run_blender(binary: str, args: list[str]) -> None: process = subprocess.run([binary, *args], capture_output=True, text=True, encoding="utf-8", errors="replace") for line in (process.stdout or "").splitlines(): if line.startswith("[agent_rig]") or line.startswith("Error"): print(line) if process.returncode != 0: sys.stderr.write(process.stdout or "") sys.stderr.write(process.stderr or "") raise ValueError(f"blender exited with {process.returncode}") def blender_config(cfg: Config) -> dict: return {"width": cfg.width, "height": cfg.height, "pivot_x": cfg.pivot_x, "pivot_y": cfg.pivot_y, "render_width": cfg.render_width, "render_height": cfg.render_height, "directions": list(cfg.directions), "animations": cfg.animations, "camera": cfg.camera} def blender_init(ws: Workspace, cfg: Config, blender: str | None, force: bool) -> Path: if ws.blend.is_file() and not force: raise ValueError(f"{ws.blend.relative_to(ws.root).as_posix()} already exists; pass --force to rebuild") binary = blender_binary(blender) ws.blend.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory() as work: config_path = Path(work) / "config.json" config_path.write_text(json.dumps(blender_config(cfg)), encoding="utf-8") run_blender(binary, ["--factory-startup", "-b", "--python", str(ws.rig_script), "--", "build", "--config", str(config_path), "--out", str(ws.blend)]) if not ws.blend.is_file(): raise ValueError("blender finished but wrote no .blend") print(f"wrote {ws.blend.relative_to(ws.root).as_posix()}") return ws.blend def pack_render_frames(cfg: Config, animation: str, images: list[Image.Image], supersample: int) -> Image.Image: """Кадры Blender -> лист. Одна вырезка на все ячейки, никакого bbox. Именно здесь живёт гарантия выравнивания: прямоугольник вырезки зависит только от каталога, а не от содержимого кадра, поэтому тело, броня и оружие не могут разъехаться, даже если их силуэты совсем разные. """ expected = cfg.cells(animation) if len(images) != expected: raise ValueError(f"{animation}: expected {expected} rendered cell(s), got {len(images)}") if supersample < 1: raise ValueError("supersample must be >= 1") source = (cfg.render_width * supersample, cfg.render_height * supersample) box = (cfg.crop_x * supersample, cfg.crop_y * supersample, (cfg.crop_x + cfg.width) * supersample, (cfg.crop_y + cfg.height) * supersample) sheet = Image.new("RGBA", cfg.sheet_size(animation), (0, 0, 0, 0)) for index, image in enumerate(images): rgba = image.convert("RGBA") if rgba.size != source: raise ValueError(f"cell {index}: render is {rgba.size}, expected {source}") paste_cell(cfg, sheet, index, resize_rgba(rgba.crop(box), (cfg.width, cfg.height))) return sheet def blender_render(ws: Workspace, cfg: Config, component: str, animation: str, blender: str | None, blend: Path | None, supersample: int, keep: Path | None = None) -> Path: check_names(cfg, component, animation) blend_path = blend or ws.blend if not blend_path.is_file(): raise ValueError(f"no rig at {blend_path}; run `agent_assets.py blender-init` first") binary = blender_binary(blender) with tempfile.TemporaryDirectory() as work: outdir = Path(work) / "frames" payload = blender_config(cfg) payload.update({"component": component, "animation": animation, "frames": cfg.frames(animation), "supersample": supersample, "outdir": str(outdir)}) config_path = Path(work) / "config.json" config_path.write_text(json.dumps(payload), encoding="utf-8") run_blender(binary, ["--factory-startup", "-b", str(blend_path), "--python", str(ws.rig_script), "--", "render", "--config", str(config_path)]) images = [] for index in range(cfg.cells(animation)): frame = outdir / f"frame_{index:03d}.png" if not frame.is_file(): raise ValueError(f"blender did not write {frame.name}") images.append(Image.open(frame).copy()) if keep is not None: keep.mkdir(parents=True, exist_ok=True) for frame in sorted(outdir.glob("frame_*.png")): shutil.copy2(frame, keep / frame.name) sheet = pack_render_frames(cfg, animation, images, supersample) target = ws.source_path(component, animation) target.parent.mkdir(parents=True, exist_ok=True) sheet.save(target) return target # --------------------------------------------------------------------------- # Растровый конвейер: калибровка и ingest # --------------------------------------------------------------------------- @dataclass(frozen=True) class Calibration: """Преобразование ячейки доски в кадр листа, общее для ВСЕХ слоёв. Числа нормированы на размер ячейки, поэтому доски разных компонентов могут приходить в разном разрешении — подстановка от этого не зависит. """ height_frac: float # высота силуэта тела в долях высоты ячейки anchor_u: float # центр силуэта по X в долях ширины ячейки anchor_v: float # низ силуэта по Y в долях высоты ячейки source: str = "" def to_yaml(self) -> str: return ("# Записано `agent_assets.py ingest` по слою тела.\n" "# Броня и оружие проходят через ЭТУ ЖЕ подстановку — руками не править.\n" "version: 1\n" f"source: {self.source}\n" f"height_frac: {self.height_frac:.6f}\n" f"anchor_u: {self.anchor_u:.6f}\n" f"anchor_v: {self.anchor_v:.6f}\n") @staticmethod def from_yaml(text: str) -> "Calibration": raw = yaml.safe_load(text) or {} return Calibration(float(raw["height_frac"]), float(raw["anchor_u"]), float(raw["anchor_v"]), str(raw.get("source", ""))) def load_calibration(ws: Workspace) -> Calibration | None: if not ws.calibration.is_file(): return None return Calibration.from_yaml(ws.calibration.read_text(encoding="utf-8")) def board_cells(cfg: Config, boards: list[Image.Image]) -> list[list[Image.Image]]: """boards[frame] -> cells[direction][frame], строгая сетка 4x2.""" for board in boards: if board.width % 4 or board.height % 2: raise ValueError("every direction board must divide exactly into a 4x2 grid") grid = [] for direction in range(len(cfg.directions)): row = [] for board in boards: cw, ch = board.width // 4, board.height // 2 row.append(board.crop(((direction % 4) * cw, (direction // 4) * ch, (direction % 4 + 1) * cw, (direction // 4 + 1) * ch))) grid.append(row) return grid def measure_calibration(cfg: Config, grid: list[list[Image.Image]], source: str) -> Calibration: """Один масштаб на весь ingest: объединённый bbox по всем направлениям. Подгонять каждое направление отдельно нельзя — фигура бы дышала при повороте, а слои разъехались бы уже между собой. """ cw, ch = grid[0][0].size left, top, right, bottom = cw, ch, 0, 0 for row in grid: for cell in row: box = cell.getchannel("A").getbbox() if box is None: continue left, top = min(left, box[0]), min(top, box[1]) right, bottom = max(right, box[2]), max(bottom, box[3]) if right <= left or bottom <= top: raise ValueError("every cell of the calibration board is empty") return Calibration(height_frac=(bottom - top) / ch, anchor_u=(left + right) / 2.0 / cw, anchor_v=bottom / ch, source=source) def cell_transform(cfg: Config, cal: Calibration, cell_size: tuple[int, int]): """(scale, offset_x, offset_y) для ячейки: куда ложится её левый верхний угол.""" cw, ch = cell_size scale = (cfg.height - FIT_MARGIN_Y) / (cal.height_frac * ch) return scale, cfg.pivot_x - cal.anchor_u * cw * scale, cfg.pivot_y - cal.anchor_v * ch * scale def place_cell(cfg: Config, cal: Calibration, cell: Image.Image) -> Image.Image: """Кладёт ЦЕЛУЮ ячейку в кадр 70x90 общей подстановкой. Обрезки по bbox тут нет намеренно: кинжал в руке обязан остаться в руке, а не съехать к стопам и не растянуться на всю клетку. """ cw, ch = cell.size scale, ox, oy = cell_transform(cfg, cal, cell.size) scaled = resize_rgba(cell, (max(1, round(cw * scale)), max(1, round(ch * scale)))) layer = Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0)) layer.paste(scaled, (round(ox), round(oy))) return layer def ingest(ws: Workspace, cfg: Config, component: str, animation: str, inputs: list[Path], calibrate: bool = False) -> Path: check_names(cfg, component, animation) expected = cfg.frames(animation) if len(inputs) != expected: raise ValueError(f"{animation} requires {expected} direction board(s), got {len(inputs)}") boards = [remove_key(Image.open(path)) for path in inputs] grid = board_cells(cfg, boards) for direction, row in enumerate(grid): for frame, cell in enumerate(row): if cell.getchannel("A").getbbox() is None: raise ValueError(f"empty cell: {cfg.directions[direction]} frame {frame}") is_body = cfg.slot(component) == "body" cal = load_calibration(ws) if calibrate and not is_body: raise ValueError("--calibrate is only allowed on a body layer: it sets the shared scale") if cal is None or calibrate: if not is_body: raise ValueError("no _calibration.yaml: ingest the body layer first — equipment " "must reuse the body's scale, not fit itself") cal = measure_calibration(cfg, grid, f"{component}/{animation}") ws.calibration.parent.mkdir(parents=True, exist_ok=True) ws.calibration.write_text(cal.to_yaml(), encoding="utf-8", newline="\n") print(f"wrote {ws.calibration.relative_to(ws.root).as_posix()}") sheet = Image.new("RGBA", cfg.sheet_size(animation), (0, 0, 0, 0)) for direction, row in enumerate(grid): for frame, cell in enumerate(row): paste_cell(cfg, sheet, direction * expected + frame, place_cell(cfg, cal, cell)) target = ws.source_path(component, animation) target.parent.mkdir(parents=True, exist_ok=True) sheet.save(target) return target # --------------------------------------------------------------------------- # prompt # --------------------------------------------------------------------------- def prompt_for(cfg: Config, component: str, animation: str) -> str: check_names(cfg, component, animation) meta = cfg.components[component] frames = cfg.frames(animation) elevation = cfg.camera.get("elevation_deg", 26.565) layer_rule = ("render only the unarmored fitted base body; hands remain visible" if meta["slot"] == "body" else "render only this equipment layer on an otherwise invisible body; preserve every occluded gap") return f"""Production sprite source for the existing sleek dark-sci-fi agent paper-doll. Component: {component} ({meta['style']}). Animation: {animation}, {frames} frame(s) per direction. {layer_rule}. Use one fixed orthographic isometric camera, {elevation:g} degrees downward, identical in every board — the normalizer does NOT re-fit layers to each other. Directions in exact order: {', '.join(cfg.directions)}. Exact same skeleton, pose timing, scale, lighting and foot pivot in every component. Realistic lean adult proportions, fitted near-future covert-operations design; no bulky power armor, space marine, astronaut, chibi, Among Us, medieval clothing or cape. Fixed dim world-space upper-left light. For EACH animation frame produce a separate strict 4 columns x 2 rows direction board. One complete direction per equal cell, flat uniform #ff00ff background, no shadow, text, grid, labels or extra objects. Keep the figure at the SAME place and size inside every cell: equipment is mapped by the body's calibration, so a cell-relative shift moves the gear off the body. Leave generous padding. Output is source art; the normalizer maps the whole cell into a {cfg.width}x{cfg.height} frame with pivot ({cfg.pivot_x},{cfg.pivot_y}).""" # --------------------------------------------------------------------------- # sync # --------------------------------------------------------------------------- SYNC_BANNER = [ "# Собрано `python tools/agent_assets.py sync`.", "# Записи с backend: import перегенерируются по PNG в assets/sources/agents/sleek/.", "# Записи с любым другим backend (например blender) sync СОХРАНЯЕТ как есть:", "# правь их руками здесь, это единственное место, где такая правка переживает sync.", ] def existing_specs(ws: Workspace) -> dict: if not ws.manifest.is_file(): return {} raw = yaml.safe_load(ws.manifest.read_text(encoding="utf-8")) or {} return {key: value for key, value in raw.items() if isinstance(value, dict) and "backend" in value} def manual_specs(ws: Workspace) -> dict: """Ручные спеки — всё, что не `import`: их sync не трогает.""" return {key: value for key, value in existing_specs(ws).items() if value.get("backend") != "import"} def write_consolidated_header(ws: Workspace) -> None: """Generate runtime IDs for multi-animation SpriteForge paper-doll layers.""" body = fnv1a("agent_body_sleek") armor = fnv1a("agent_armor_vest") weapon = fnv1a("agent_weapon_rifle") lines = [ "/* Generated by tools/agent_assets.py sync (consolidated Blender assets). */", "#pragma once", "#include ", '#include "render/anim.h"', '#include "sim/items.h"', "", f"constexpr uint32_t AGENT_BODY_ASSET = UINT32_C(0x{body:08X});", "", "constexpr uint32_t AgentBodyAsset(AnimKind) { return AGENT_BODY_ASSET; }", "", "constexpr uint32_t AgentArmorAsset(ItemId id, AnimKind = AnimKind::IDLE) {", " switch (id) {", " case ItemId::JACKET: case ItemId::VEST: case ItemId::PLATE: case ItemId::SHROUD:", f" return UINT32_C(0x{armor:08X});", " default: return 0;", " }", "}", "", "constexpr uint32_t AgentWeaponAsset(ItemId id, AnimKind = AnimKind::IDLE) {", " switch (id) {", " case ItemId::RIFLE: case ItemId::PISTOL: case ItemId::SHOTGUN:", " case ItemId::SMG: case ItemId::MAGNUM: case ItemId::BOW:", " case ItemId::SABER: case ItemId::KNIFE: case ItemId::CLEAVER:", f" return UINT32_C(0x{weapon:08X});", " default: return 0;", " }", "}", "", ] ws.header.parent.mkdir(parents=True, exist_ok=True) ws.header.write_text("\n".join(lines), encoding="utf-8", newline="\n") def sync(ws: Workspace, cfg: Config) -> None: manual = manual_specs(ws) lines = list(SYNC_BANNER) + ["version: 1", "defaults:", f" dirs: {len(cfg.directions)}", f" pivot: \"{cfg.pivot_x},{cfg.pivot_y}\"", ""] for aid, spec in manual.items(): lines.append(yaml.safe_dump({aid: spec}, sort_keys=False, allow_unicode=True, default_flow_style=False).rstrip("\n")) lines.append("") generated = 0 for component in cfg.components: for animation, anim in cfg.animations.items(): aid = asset_id(component, animation) if aid in manual: continue path = ws.source_path(component, animation) if not path.is_file(): continue rel = Path(os.path.relpath(path, ws.manifest.parent)).as_posix() slot = cfg.slot(component) tags = f"agent, {slot}, {animation}" if slot in {"armor", "weapon"}: tags += ", paperdoll_layer" lines += [f"{aid}:", " backend: import", f" source: {rel}", f" fps: {anim['fps']}", " params: {frame_width: %d, frame_height: %d, frames: %d}" % (cfg.width, cfg.height, cfg.frames(animation) * len(cfg.directions)), f" tags: [{tags}]", ""] generated += 1 ws.manifest.parent.mkdir(parents=True, exist_ok=True) ws.manifest.write_text("\n".join(lines), encoding="utf-8", newline="\n") # A project may keep legacy PNG imports while migrating, but once the # production Blender manifest exists runtime addressing must use its single # multi-clip asset per paper-doll layer. if (ws.root / "assets" / "manifests" / "agents.blender.yaml").is_file(): write_consolidated_header(ws) print(f"wrote {ws.manifest.relative_to(ws.root).as_posix()} " f"({generated} import asset(s), {len(manual)} hand-written spec(s) preserved)") print(f"wrote {ws.header.relative_to(ws.root).as_posix()} (consolidated Blender IDs)") return body = "agent_body_sleek_idle" armor = {"JACKET": "jacket", "VEST": "vest", "PLATE": "plate", "SHROUD": "shroud"} weapon = {"RIFLE": "rifle", "PISTOL": "pistol", "SHOTGUN": "shotgun", "SMG": "rifle", "MAGNUM": "pistol", "BOW": "bow", "SABER": "sword", "KNIFE": "dagger", "CLEAVER": "sword"} def anim_switch(prefix: str, indent: str) -> list[str]: return [f"{indent}case AnimKind::{animation.upper()}: return UINT32_C(0x{fnv1a(prefix+'_'+animation):08X});" for animation in ("idle", "walk", "attack")] h = ["/* Generated by tools/agent_assets.py sync. */", "#pragma once", "#include ", "#include \"render/anim.h\"", "#include \"sim/items.h\"", "", f"constexpr uint32_t AGENT_BODY_ASSET = UINT32_C(0x{fnv1a(body):08X});", "", "constexpr uint32_t AgentBodyAsset(AnimKind anim) {", " switch (anim) {"] h += anim_switch("agent_body_sleek", " ") h += [" default: return AGENT_BODY_ASSET;", " }", "}", "", "constexpr uint32_t AgentArmorAsset(ItemId id, AnimKind anim = AnimKind::IDLE) {", " switch (id) {"] for key, name in armor.items(): h += [f" case ItemId::{key}:", " switch (anim) {"] h += anim_switch(f"agent_armor_{name}", " ") h += [" default: return 0;", " }"] h += [" default: return 0;", " }", "}", "", "constexpr uint32_t AgentWeaponAsset(ItemId id, AnimKind anim = AnimKind::IDLE) {", " switch (id) {"] for key, name in weapon.items(): h += [f" case ItemId::{key}:", " switch (anim) {"] h += anim_switch(f"agent_weapon_{name}", " ") h += [" default: return 0;", " }"] h += [" default: return 0;", " }", "}", ""] ws.header.parent.mkdir(parents=True, exist_ok=True) ws.header.write_text("\n".join(h), encoding="utf-8", newline="\n") print(f"wrote {ws.manifest.relative_to(ws.root).as_posix()} " f"({generated} import asset(s), {len(manual)} hand-written spec(s) preserved)") print(f"wrote {ws.header.relative_to(ws.root).as_posix()}") # --------------------------------------------------------------------------- # validate # --------------------------------------------------------------------------- def mirror(cfg: Config, mask: Image.Image) -> Image.Image: """Отражение вокруг колонки pivot_x, а не вокруг центра холста. Разница в полпикселя, но именно она и решает: холст 70 широкий, а pivot стоит на 35 — центр холста 34.5, и зеркало вокруг него сдвигало бы силуэт. """ flipped = mask.convert("L").transform(mask.size, Image.AFFINE, (-1, 0, 2 * cfg.pivot_x, 0, 1, 0), resample=Image.Resampling.NEAREST) return flipped.point(lambda v: 255 if v >= 128 else 0).convert("1") def iou(a: Image.Image, b: Image.Image) -> float: intersection = sum(ImageChops.logical_and(a, b).convert("L").point(lambda v: 1 if v else 0).getdata()) union = sum(ImageChops.logical_or(a, b).convert("L").point(lambda v: 1 if v else 0).getdata()) return intersection / union if union else 1.0 def union_masks(cfg: Config, sheet: Image.Image, animation: str) -> list[Image.Image]: """По одной маске на направление: объединение всех кадров цикла. Сравнивать покадрово нельзя — в середине шага у зеркальных направлений вперёд вынесена разная нога, и честное отражение дало бы ложную тревогу. """ frames = cfg.frames(animation) out = [] for direction in range(len(cfg.directions)): merged = Image.new("1", (cfg.width, cfg.height), 0) for frame in range(frames): merged = ImageChops.logical_or(merged, mask_of(cell_at(cfg, sheet, direction * frames + frame))) out.append(merged) return out def bbox_of(cell: Image.Image): return mask_of(cell).convert("L").getbbox() @dataclass class Report: errors: list[str] warnings: list[str] def fail(self, message: str) -> None: self.errors.append(message) def warn(self, message: str) -> None: self.warnings.append(message) def check_sheet(cfg: Config, path: Path, animation: str, report: Report) -> Image.Image | None: if not path.is_file(): report.fail(f"missing: {path.name}") return None sheet = Image.open(path).convert("RGBA") expected = cfg.sheet_size(animation) if sheet.size != expected: report.fail(f"{path.name}: canvas {sheet.size}, expected {expected} " f"({len(cfg.directions)} directions x {cfg.frames(animation)} frame(s) of " f"{cfg.width}x{cfg.height})") return None if sheet.getextrema()[3][0] != 0: report.fail(f"{path.name}: no transparent pixels — the key was not removed") for index in range(cfg.cells(animation)): if bbox_of(cell_at(cfg, sheet, index)) is None: direction = cfg.directions[index // cfg.frames(animation)] report.fail(f"{path.name}: empty cell {index} ({direction} frame {index % cfg.frames(animation)})") return sheet def check_body_pivot(cfg: Config, name: str, sheet: Image.Image, animation: str, report: Report) -> None: for index in range(cfg.cells(animation)): box = bbox_of(cell_at(cfg, sheet, index)) if box is None: continue if not (cfg.pivot_y - PIVOT_BOTTOM_SLACK <= box[3] <= cfg.height): report.fail(f"{name}: cell {index} stands on y={box[3]}, pivot_y is {cfg.pivot_y}") # A dynamic attack may extend one arm far to the side; bbox centre is # not the character origin. The static idle pose remains a useful # authoring check, while every animation still has to stand on pivot_y. if animation == "idle": center = (box[0] + box[2]) / 2.0 if abs(center - cfg.pivot_x) > PIVOT_CENTER_SLACK: report.fail(f"{name}: cell {index} centred at x={center:.1f}, pivot_x is {cfg.pivot_x}") def check_layer_alignment(cfg: Config, name: str, sheet: Image.Image, body: Image.Image, animation: str, slot: str, report: Report) -> None: """Экипировка обязана лежать внутри силуэта тела плюс небольшой запас. Так ловится ровно та поломка, ради которой переписан ingest: слой, который подогнали по собственному bbox, вылезает за тело сразу во всех ячейках. """ for index in range(cfg.cells(animation)): gear = bbox_of(cell_at(cfg, sheet, index)) host = bbox_of(cell_at(cfg, body, index)) if gear is None or host is None: continue if slot == "weapon": # Blades and bows legitimately extend well outside the body. What # the broken per-bbox fitter produced was a layer inflated to # almost the entire 70x90 cell, independent of attachment point. if gear[2] - gear[0] >= cfg.width - 8 or gear[3] - gear[1] >= cfg.height - 10: report.fail(f"{name}: cell {index} bbox {gear} fills almost the whole frame " "— weapon was fitted on its own") continue limits = (host[0] - EQUIP_MARGIN, host[1] - EQUIP_MARGIN, host[2] + EQUIP_MARGIN, host[3] + EQUIP_MARGIN) if gear[0] < limits[0] or gear[1] < limits[1] or gear[2] > limits[2] or gear[3] > limits[3]: report.fail(f"{name}: cell {index} bbox {gear} escapes the body {host} " f"by more than {EQUIP_MARGIN}px — layer was fitted on its own") def check_direction_order(cfg: Config, name: str, sheet: Image.Image, animation: str, report: Report) -> None: """Порядок направлений — насколько его вообще видно текстом. Каталог обещает юг, юго-запад, запад, ... — значит пары (ЮЗ,ЮВ), (З,В), (СЗ,СВ) обязаны совпадать при отражении вокруг pivot_x, а юг и север — различаться. Это не доказательство порядка, но любую перестановку соседей оно ловит. """ names = cfg.directions if len(names) != 8: return masks = union_masks(cfg, sheet, animation) for left, right in ((1, 7), (2, 6), (3, 5)): score = iou(masks[left], mirror(cfg, masks[right])) if score < MIRROR_IOU_MIN: report.warn(f"{name}: {names[left]} and {names[right]} are not mirror images " f"(IoU {score:.2f} < {MIRROR_IOU_MIN}) — direction order or camera differs") for index in (0, 4): score = iou(masks[index], mirror(cfg, masks[index])) if score < MIRROR_IOU_MIN: report.warn(f"{name}: {names[index]} is not symmetric about pivot_x " f"(IoU {score:.2f}) — that cell may not face the camera") # Side-on cardinals should normally be no wider than their neighbouring # diagonals. This catches the common board-order swap that mirror-pair # checks alone cannot see for simple/symmetric silhouettes. if animation == "idle": widths = [] for mask in masks: box = mask.convert("L").getbbox() widths.append(0 if box is None else box[2] - box[0]) for side, before, after in ((2, 1, 3), (6, 5, 7)): if widths[side] > max(widths[before], widths[after]): report.warn(f"{name}: {names[side]} is wider than both adjacent diagonals " "— direction order may be shuffled") similarities = [iou(masks[0], masks[index]) for index in range(1, 8)] if all(score > DISTINCT_IOU_MAX for score in similarities): report.warn(f"{name}: all eight directions have the same silhouette " "— the board may hold one direction eight times") def check_manifest(ws: Workspace, cfg: Config, report: Report) -> None: if not ws.manifest.is_file(): return raw = yaml.safe_load(ws.manifest.read_text(encoding="utf-8")) or {} defaults = raw.get("defaults", {}) if defaults.get("dirs") != len(cfg.directions): report.fail(f"manifest defaults.dirs is {defaults.get('dirs')}, catalog has {len(cfg.directions)}") if defaults.get("pivot") != f"{cfg.pivot_x},{cfg.pivot_y}": report.fail(f"manifest defaults.pivot is {defaults.get('pivot')!r}, " f"catalog has '{cfg.pivot_x},{cfg.pivot_y}'") for aid, spec in raw.items(): if not isinstance(spec, dict) or spec.get("backend") != "import": continue params = spec.get("params", {}) if params.get("frame_width") != cfg.width or params.get("frame_height") != cfg.height: report.fail(f"manifest {aid}: frame {params.get('frame_width')}x{params.get('frame_height')}, " f"catalog has {cfg.width}x{cfg.height}") def validate(ws: Workspace, cfg: Config, complete: bool = False, strict: bool = False) -> int: report = Report([], []) check_manifest(ws, cfg, report) bodies = [c for c in cfg.components if cfg.slot(c) == "body"] for animation in cfg.animations: sheets = {} for component in cfg.components: path = ws.source_path(component, animation) if not (complete or path.is_file()): continue sheet = check_sheet(cfg, path, animation, report) if sheet is None: continue sheets[component] = sheet name = path.name if cfg.slot(component) == "body": check_body_pivot(cfg, name, sheet, animation, report) check_direction_order(cfg, name, sheet, animation, report) body = next((sheets[c] for c in bodies if c in sheets), None) if body is not None: for component, sheet in sheets.items(): if cfg.slot(component) != "body": check_layer_alignment(cfg, ws.source_path(component, animation).name, sheet, body, animation, cfg.slot(component), report) elif sheets: report.warn(f"{animation}: no body layer — cross-layer alignment unchecked") for warning in report.warnings: print(f"{'ERROR' if strict else 'WARN'}: {warning}") for error in report.errors: print(f"ERROR: {error}") failures = len(report.errors) + (len(report.warnings) if strict else 0) print(f"agent assets: {len(report.errors)} error(s), {len(report.warnings)} warning(s)") return 1 if failures else 0 # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main() -> int: ws = WORKSPACE cfg = load_config(ws) parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) sub = parser.add_subparsers(dest="command", required=True) prompt = sub.add_parser("prompt", help="art brief for a raster generator") prompt.add_argument("component"); prompt.add_argument("animation") ingest_p = sub.add_parser("ingest", help="normalize 4x2 chroma-key boards into a sheet") ingest_p.add_argument("component"); ingest_p.add_argument("animation") ingest_p.add_argument("inputs", nargs="+", type=Path) ingest_p.add_argument("--calibrate", action="store_true", help="re-measure the shared scale from this body layer") init = sub.add_parser("blender-init", help="create the rig .blend with Blender") init.add_argument("--blender"); init.add_argument("--force", action="store_true") render = sub.add_parser("blender-render", help="render one component+animation into a sheet") render.add_argument("component"); render.add_argument("animation") render.add_argument("--blender"); render.add_argument("--blend", type=Path) render.add_argument("--supersample", type=int, default=4) render.add_argument("--keep-renders", type=Path) render_all = sub.add_parser("blender-render-all", help="batch-render catalog components") render_all.add_argument("components", nargs="*", help="default: every catalog component") render_all.add_argument("--animations", nargs="+", choices=list(cfg.animations), default=list(cfg.animations)) render_all.add_argument("--blender"); render_all.add_argument("--blend", type=Path) render_all.add_argument("--supersample", type=int, default=2) render_all.add_argument("--force", action="store_true", help="replace existing sheets") sub.add_parser("sync", help="regenerate the SpriteForge manifest and the C++ header") val = sub.add_parser("validate", help="check canvas, pivot, layer alignment, direction order") val.add_argument("--complete", action="store_true", help="missing layers are errors") val.add_argument("--strict", action="store_true", help="warnings are errors") args = parser.parse_args() if args.command == "prompt": print(prompt_for(cfg, args.component, args.animation)); return 0 if args.command == "ingest": print(ingest(ws, cfg, args.component, args.animation, args.inputs, args.calibrate)) sync(ws, cfg); return 0 if args.command == "blender-init": blender_init(ws, cfg, args.blender, args.force); return 0 if args.command == "blender-render": print(blender_render(ws, cfg, args.component, args.animation, args.blender, args.blend, args.supersample, args.keep_renders)) sync(ws, cfg); return 0 if args.command == "blender-render-all": components = args.components or list(cfg.components) for component in components: if component not in cfg.components: raise ValueError(f"unknown component: {component}") done = skipped = 0 for component in components: for animation in args.animations: target = ws.source_path(component, animation) if target.is_file() and not args.force: print(f"skip {target.relative_to(ws.root).as_posix()}") skipped += 1 continue print(blender_render(ws, cfg, component, animation, args.blender, args.blend, args.supersample)) done += 1 sync(ws, cfg) print(f"batch render: {done} rendered, {skipped} cached") return 0 if args.command == "sync": sync(ws, cfg); return 0 return validate(ws, cfg, args.complete, args.strict) if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, ValueError, KeyError) as error: print(f"ERROR: {error}", file=sys.stderr); raise SystemExit(1)