"""studio.rigging.rigid_piece — rigid-piece backward-warp engine (Session 3.5). Each piece rotates rigidly around its bone joint's position by that joint's world rotation (computed via the same FK pass the LBS engine uses). Pixels are sampled by backward warp: for each output pixel, the inverse transform maps to a source pixel; if the source pixel belongs to this piece in the rest parts.png, its color is copied. Why backward-warp instead of forward-splat: - No gaps when a piece under-samples its output region after rotation. - z-order is the natural occlusion model — paint back-to-front, higher z wins. - Nearest-neighbor membership check matches sprite-art pixel discipline. """ from __future__ import annotations from typing import Iterable, Mapping, Tuple import numpy as np from pixel_cursor.rigging import JOINT_INDEX, Skeleton from .deform import forward_kinematics from .parts import PartsMask def deform_sprite_rigid( image: np.ndarray, rest: Skeleton, parts: PartsMask, *, local_rotations: Mapping[str, float] | None = None, include_kinds: Tuple[str, ...] = ("body",), exclude_pieces: Iterable[str] = (), ) -> Tuple[np.ndarray, np.ndarray]: """Render a posed configuration via rigid-piece backward warp. Args: image: source sprite, shape (H, W, 3 or 4) uint8. rest: rest-pose skeleton (image_shape must equal image's H, W). parts: piece-mask sidecar (id_map shape must equal image's H, W). local_rotations: optional dict mapping joint_name -> local rotation (rad). include_kinds: piece.kind values to render. Default ('body',) — attachments are handled by the composite layer (Session 4). exclude_pieces: piece names to skip (e.g., ('sword',) for swordless render). Returns: (rgba (H, W, 4) uint8, mask (H, W) bool) The output is composed back-to-front by z; higher z overwrites lower. Every output pixel is a pixel-perfect copy of exactly one source pixel (or fully transparent) — this is the 'rigid' invariant. """ H, W = parts.image_shape if image.shape[:2] != (H, W): raise ValueError( f"image shape {image.shape[:2]} != parts.image_shape {(H, W)}" ) if rest.image_shape != (H, W): raise ValueError( f"rest.image_shape {rest.image_shape} != parts.image_shape {(H, W)}" ) if image.ndim == 3 and image.shape[-1] == 4: rgba = image elif image.ndim == 3 and image.shape[-1] == 3: rgba = np.concatenate( [image, np.full((H, W, 1), 255, dtype=np.uint8)], axis=-1 ) elif image.ndim == 2: gray = image rgba = np.stack( [gray, gray, gray, np.full_like(gray, 255)], axis=-1 ) else: raise ValueError(f"unsupported image shape {image.shape}") deformed_pos, world_rot = forward_kinematics(rest, local_rotations or {}) out_rgba = np.zeros((H, W, 4), dtype=np.uint8) out_mask = np.zeros((H, W), dtype=bool) yy, xx = np.mgrid[0:H, 0:W].astype(np.float32) exclude = set(exclude_pieces) selected = [ p for p in parts.pieces if p.kind in include_kinds and p.name not in exclude ] selected.sort(key=lambda p: p.z) for piece in selected: bone_idx = JOINT_INDEX[piece.bone] pivot_rest_y = float(rest.positions[bone_idx, 0]) pivot_rest_x = float(rest.positions[bone_idx, 1]) pivot_posed_y = float(deformed_pos[bone_idx, 0]) pivot_posed_x = float(deformed_pos[bone_idx, 1]) theta = float(world_rot[bone_idx]) c = np.cos(-theta) s = np.sin(-theta) dy = yy - pivot_posed_y dx = xx - pivot_posed_x src_y = pivot_rest_y + c * dy - s * dx src_x = pivot_rest_x + s * dy + c * dx sy_i = np.round(src_y).astype(np.int32) sx_i = np.round(src_x).astype(np.int32) in_bounds = (sy_i >= 0) & (sy_i < H) & (sx_i >= 0) & (sx_i < W) sy_safe = np.clip(sy_i, 0, H - 1) sx_safe = np.clip(sx_i, 0, W - 1) membership = parts.id_map[sy_safe, sx_safe] == piece.piece_id src_alpha = rgba[sy_safe, sx_safe, 3] > 0 valid = in_bounds & membership & src_alpha if not valid.any(): continue out_rgba[valid] = rgba[sy_safe[valid], sx_safe[valid]] out_mask |= valid return out_rgba, out_mask __all__ = ["deform_sprite_rigid"]