Всё, из чего собираются спрайты, и ничего собранного. Источник истины —
assets/manifests и assets/*/catalog.yaml; PNG, .sfa и contact sheet остаются
за бортом, потому что воспроизводятся командой (docs/12-assets.md).
assets/manifests что и как рендерить: окружение, твари, агенты, предметы
assets/agents паперdoll-риг восьми направлений, каталог снаряжения
assets/monsters пять морфологий, каждая своим ригом (docs/14-monsters.md)
assets/sources исходные развёртки и импорты
assets/art_masters мастер-развёртки, по которым сверяется стиль
tools/ сборка ассетов, покрытие, генераторы, blender-скрипты
generated/ заголовки с константами SF_ASSET_*: без них игра не
соберётся, поэтому они в репозитории, хотя и производные
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
905 lines
44 KiB
Python
905 lines
44 KiB
Python
"""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 <cstdint>", '#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 <cstdint>",
|
||
"#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)
|