File size: 8,974 Bytes
7faaef2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
"""studio.rigging.parts — piece-mask sidecar (Phase 3 / Session 3.5).

Each sprite gets two artifacts alongside its joints sidecar:

    <sprite>.png            (the sprite image)
    <sprite>.joints.json    (joint annotations, Session 2)
    <sprite>.parts.png      (uint8 grayscale; pixel = piece_id; 0 = background)
    <sprite>.parts.json     (piece index, bone binding, z-order, kind)

The parts.png is mode='L' so every editor opens and round-trips it cleanly. A
separate <sprite>.parts.preview.png with a color palette is generated for human
inspection; it is NOT load-bearing.

JSON format (versioned, forwards-compatible):

    {
        "schema_version": 1,
        "image": "goblin.png",
        "image_shape": [256, 256],
        "pieces": {
            "1":  {"name": "torso",       "bone": "neck",       "z": 1, "kind": "body"},
            ...
            "14": {"name": "sword",       "bone": "r_wrist",    "z": 9, "kind": "attachment"}
        }
    }

Piece IDs are 1-indexed. ID 0 is reserved for background (no piece).

`bone` is the joint name the piece pivots around. Under forward kinematics, the
piece rotates rigidly around that joint's position by that joint's world
rotation. `z` is the render order — higher draws on top. `kind` is "body" or
"attachment"; the default rigid-piece engine renders body-only.
"""
from __future__ import annotations

import colorsys
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Optional, Tuple

import numpy as np
from PIL import Image

from pixel_cursor.rigging import JOINT_INDEX, JOINT_NAMES


SCHEMA_VERSION: int = 1


CANONICAL_HUMANOID_PIECES: Tuple[Tuple[str, str, int, str], ...] = (
    # (name, bone (joint name), z-order, kind)
    ("torso",       "neck",       1, "body"),
    ("l_thigh",     "l_hip",      2, "body"),
    ("r_thigh",     "r_hip",      2, "body"),
    ("l_shin",      "l_knee",     3, "body"),
    ("r_shin",      "r_knee",     3, "body"),
    ("l_foot",      "l_ankle",    4, "body"),
    ("r_foot",      "r_ankle",    4, "body"),
    ("l_upper_arm", "l_shoulder", 5, "body"),
    ("r_upper_arm", "r_shoulder", 5, "body"),
    ("l_forearm",   "l_elbow",    6, "body"),
    ("r_forearm",   "r_elbow",    6, "body"),
    ("l_hand",      "l_wrist",    7, "body"),
    ("r_hand",      "r_wrist",    7, "body"),
    ("head",        "head",       8, "body"),
)


@dataclass(frozen=True)
class Piece:
    piece_id: int
    name: str
    bone: str
    z: int
    kind: str


@dataclass(frozen=True)
class PartsMask:
    id_map: np.ndarray
    pieces: Tuple[Piece, ...]
    image_shape: Tuple[int, int]

    def piece_by_name(self, name: str) -> Optional[Piece]:
        for p in self.pieces:
            if p.name == name:
                return p
        return None

    def piece_by_id(self, piece_id: int) -> Optional[Piece]:
        for p in self.pieces:
            if p.piece_id == piece_id:
                return p
        return None

    @property
    def body_pieces(self) -> Tuple[Piece, ...]:
        return tuple(p for p in self.pieces if p.kind == "body")

    @property
    def attachment_pieces(self) -> Tuple[Piece, ...]:
        return tuple(p for p in self.pieces if p.kind == "attachment")


def parts_png_path_for(image_path: Path | str) -> Path:
    p = Path(image_path)
    return p.with_name(p.stem + ".parts.png")


def parts_json_path_for(image_path: Path | str) -> Path:
    p = Path(image_path)
    return p.with_name(p.stem + ".parts.json")


def parts_preview_path_for(image_path: Path | str) -> Path:
    p = Path(image_path)
    return p.with_name(p.stem + ".parts.preview.png")


def load_parts(image_path: Path | str) -> Optional[PartsMask]:
    """Return the PartsMask for image_path, or None if either sidecar is missing.

    Raises ValueError if the JSON exists but is malformed or has a higher
    schema_version than we support.
    """
    png_path = parts_png_path_for(image_path)
    json_path = parts_json_path_for(image_path)
    if not png_path.exists() or not json_path.exists():
        return None

    data = json.loads(json_path.read_text())
    version = int(data.get("schema_version", 1))
    if version > SCHEMA_VERSION:
        raise ValueError(
            f"parts sidecar schema_version {version} > supported {SCHEMA_VERSION} "
            f"({json_path})"
        )

    shape_field = data["image_shape"]
    H, W = int(shape_field[0]), int(shape_field[1])

    img = Image.open(png_path)
    if img.mode != "L":
        img = img.convert("L")
    arr = np.asarray(img, dtype=np.uint8)
    if arr.shape != (H, W):
        raise ValueError(
            f"parts.png shape {arr.shape} != image_shape {(H, W)} ({png_path})"
        )

    pieces_field = data.get("pieces", {})
    pieces = []
    for pid_str, entry in pieces_field.items():
        pid = int(pid_str)
        if pid == 0:
            raise ValueError(
                f"piece_id 0 is reserved for background ({json_path})"
            )
        if pid < 0 or pid > 255:
            raise ValueError(
                f"piece_id {pid} out of uint8 range ({json_path})"
            )
        bone = entry["bone"]
        if bone not in JOINT_INDEX:
            raise ValueError(
                f"piece '{entry.get('name', pid)}' bone '{bone}' is not a known "
                f"joint ({json_path}); known: {list(JOINT_NAMES)}"
            )
        pieces.append(Piece(
            piece_id=pid,
            name=str(entry["name"]),
            bone=bone,
            z=int(entry.get("z", 0)),
            kind=str(entry.get("kind", "body")),
        ))

    return PartsMask(
        id_map=arr,
        pieces=tuple(pieces),
        image_shape=(H, W),
    )


def save_parts(
    image_path: Path | str,
    id_map: np.ndarray,
    pieces: Tuple[Piece, ...],
    *,
    image_name: Optional[str] = None,
) -> Tuple[Path, Path]:
    """Write <sprite>.parts.png + <sprite>.parts.json. Returns (png_path, json_path)."""
    if id_map.ndim != 2:
        raise ValueError(f"id_map must be 2D, got {id_map.shape}")
    if id_map.dtype != np.uint8:
        raise ValueError(f"id_map must be uint8, got {id_map.dtype}")
    png_path = parts_png_path_for(image_path)
    json_path = parts_json_path_for(image_path)
    name = image_name if image_name is not None else Path(image_path).name

    Image.fromarray(id_map, mode="L").save(png_path)

    payload = {
        "schema_version": SCHEMA_VERSION,
        "image": name,
        "image_shape": [int(id_map.shape[0]), int(id_map.shape[1])],
        "pieces": {
            str(p.piece_id): {
                "name": p.name,
                "bone": p.bone,
                "z": p.z,
                "kind": p.kind,
            }
            for p in pieces
        },
    }
    json_path.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n")
    return png_path, json_path


def save_preview(
    image_path: Path | str,
    parts: PartsMask,
) -> Path:
    """Write a color-coded RGBA preview alongside the parts sidecar.

    Pure operator convenience — not load-bearing. Background is transparent.
    """
    H, W = parts.image_shape
    rgba = np.zeros((H, W, 4), dtype=np.uint8)
    palette = _build_preview_palette(parts.pieces)
    for piece in parts.pieces:
        mask = parts.id_map == piece.piece_id
        if not mask.any():
            continue
        rgba[mask] = palette[piece.piece_id]
    out_path = parts_preview_path_for(image_path)
    Image.fromarray(rgba, mode="RGBA").save(out_path)
    return out_path


def _build_preview_palette(
    pieces: Tuple[Piece, ...],
) -> Dict[int, Tuple[int, int, int, int]]:
    palette: Dict[int, Tuple[int, int, int, int]] = {}
    n = max(1, len(pieces))
    for i, p in enumerate(pieces):
        hue = (i / n) % 1.0
        sat = 0.65
        val = 0.95 if p.kind == "body" else 0.55
        r, g, b = colorsys.hsv_to_rgb(hue, sat, val)
        palette[p.piece_id] = (int(r * 255), int(g * 255), int(b * 255), 220)
    return palette


def canonical_humanoid_pieces(
    extras: Tuple[Tuple[str, str, int, str], ...] = (),
) -> Tuple[Piece, ...]:
    """Return the canonical 13 humanoid pieces plus any extras (attachments).

    extras: iterable of (name, bone, z, kind) tuples to append. Piece IDs are
    assigned sequentially starting from 1.
    """
    pieces = []
    next_id = 1
    for name, bone, z, kind in CANONICAL_HUMANOID_PIECES:
        pieces.append(Piece(piece_id=next_id, name=name, bone=bone, z=z, kind=kind))
        next_id += 1
    for name, bone, z, kind in extras:
        pieces.append(Piece(piece_id=next_id, name=name, bone=bone, z=z, kind=kind))
        next_id += 1
    return tuple(pieces)


__all__ = [
    "SCHEMA_VERSION",
    "CANONICAL_HUMANOID_PIECES",
    "Piece",
    "PartsMask",
    "parts_png_path_for",
    "parts_json_path_for",
    "parts_preview_path_for",
    "load_parts",
    "save_parts",
    "save_preview",
    "canonical_humanoid_pieces",
]