"""Character reference: topology and part names exported from a master character. The reference JSON lets every character file be compared with the master character without opening it: - ``parts``: inner mouth / eye object names, with the character name replaced by ``{asset}`` (e.g. ``GEO_{asset}_Eye_L``). - ``body``: vertex/edge/face counts and the face vertex indices of the body mesh, used to compare vertex order. """ from __future__ import annotations import base64 import hashlib import json import os import zlib import bpy import numpy as np from .. import config from .mesh import polygon_arrays FORMAT_VERSION = 1 def templated(name, asset): """Replace the ``asset`` token of an underscore separated name by ``{asset}``.""" return "_".join("{asset}" if token == asset else token for token in name.split("_")) def part_objects(objects): keywords = [k.lower() for k in config.CHAR_PART_KEYWORDS] return [ob for ob in objects if any(k in ob.name.lower() for k in keywords)] def body_name(asset): return config.CHAR_BODY_NAME.format(asset=asset) def _encode(array): return base64.b64encode(zlib.compress(np.ascontiguousarray(array, dtype=np.int32).tobytes())).decode("ascii") def _decode(text): return np.frombuffer(zlib.decompress(base64.b64decode(text)), dtype=np.int32) def topology_signature(mesh): _, totals, loop_verts = polygon_arrays(mesh) digest = hashlib.sha1(totals.tobytes() + loop_verts.tobytes()).hexdigest() return { "vertices": len(mesh.vertices), "edges": len(mesh.edges), "faces": len(mesh.polygons), "hash": digest, "loop_totals": _encode(totals), "loop_vertices": _encode(loop_verts), } def decode_topology(body): return _decode(body["loop_totals"]), _decode(body["loop_vertices"]) def reference_path(prefs=None): if prefs is not None and getattr(prefs, "char_reference_path", ""): return bpy.path.abspath(prefs.char_reference_path) return os.environ.get("WTD_CHAR_REFERENCE", "") or config.CHAR_REFERENCE_PATH _cache: dict[str, tuple[float, dict]] = {} def load_reference(path): """Load a reference JSON, cached by modification time. None if missing.""" if not path or not os.path.isfile(path): return None mtime = os.path.getmtime(path) cached = _cache.get(path) if cached and cached[0] == mtime: return cached[1] with open(path, encoding="utf-8") as fh: data = json.load(fh) _cache[path] = (mtime, data) return data def export_reference(scene, asset, path): """Write the reference of the character ``asset`` found in ``scene``.""" body = scene.objects.get(body_name(asset)) if body is None or body.type != "MESH": raise ValueError(f"Body mesh '{body_name(asset)}' not found in the scene") if body.mode == "EDIT": body.update_from_editmode() parts = sorted({templated(ob.name, asset) for ob in part_objects(scene.objects)}) data = { "format_version": FORMAT_VERSION, "source_asset": asset, "source_file": bpy.data.filepath, "parts": parts, "body": topology_signature(body.data), } with open(path, "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2) _cache.pop(path, None) return data