fix(assets): unified padded canvas eliminates tail clipping and fixes animation scaling

Workcat's tail flick (-15% left), paw reach (+9% right), and stretch motions
extend beyond the nominal cat bounding box. Previously, frames were drawn
onto a compact 144x110 canvas without padding, causing negative offsets
(e.g. ox=-22px on tail) to truncate the tail tip and create mismatched scales.

Fixes:
- Added padding (pad_x=20%, pad_y=20%) to the render canvas so all extended
  poses (tail swings to x=16, paw strikes to x=185) fit completely without clipping.
- Unified the coordinate system: walk, tail, stretch, paw, and scruff now share
  the exact same body scale (height ~93-94px) and feet baseline (y=128).
This commit is contained in:
2026-09-08 23:33:12 +02:00
parent 0d52a5451b
commit 78b3f949c3
+51 -40
View File
@@ -30,8 +30,11 @@ from .config import (
LIFE_MOTION_FRAME_COUNT, LIFE_MOTION_FRAME_COUNT,
CAT_BOX_WIDTH, CAT_BOX_WIDTH,
CAT_BOX_HEIGHT, CAT_BOX_HEIGHT,
GAIT_SHIFT_X,
GAIT_SHIFT_Y,
Coat, Coat,
COATS, COATS,
Config,
) )
logger = logging.getLogger("catser.assets") logger = logging.getLogger("catser.assets")
@@ -80,10 +83,15 @@ class AssetsManager:
Manages downloading, caching, rasterizing, and scaling of all cat sprites. Manages downloading, caching, rasterizing, and scaling of all cat sprites.
""" """
def __init__(self, assets_dir: Optional[Path] = None, cat_width: int = 144): def __init__(self, assets_dir: Optional[Path] = None, cat_width: int = 144, config: Optional[Config] = None):
self.assets_dir = assets_dir or (Path(__file__).parent / "assets") self.assets_dir = assets_dir or (Path(__file__).parent / "assets")
self.cat_width = cat_width self.config = config or Config(cat_width=cat_width)
self.cat_height = int(round(cat_width * (CAT_BOX_HEIGHT / CAT_BOX_WIDTH))) self.cat_width = self.config.cat_width
self.cat_height = self.config.cat_height
self.canvas_width = self.config.canvas_width
self.canvas_height = self.config.canvas_height
self.pad_x = self.config.pad_x
self.pad_y = self.config.pad_y
self.scale = self.cat_width / CAT_BOX_WIDTH self.scale = self.cat_width / CAT_BOX_WIDTH
# Memory cache for rendered frames keyed by (coat_name, frame_id, facing_left) # Memory cache for rendered frames keyed by (coat_name, frame_id, facing_left)
@@ -203,37 +211,41 @@ class AssetsManager:
target_w = self.cat_width target_w = self.cat_width
target_h = self.cat_height target_h = self.cat_height
canvas_w = self.canvas_width
canvas_h = self.canvas_height
pad_x = self.pad_x
pad_y = self.pad_y
# 4× supersampling for clean anti-aliased polygon edges # 4× supersampling for clean anti-aliased polygon edges
SS = 4 SS = 4
canvas_w = target_w * SS ss_w = canvas_w * SS
canvas_h = target_h * SS ss_h = canvas_h * SS
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0)) img = Image.new("RGBA", (ss_w, ss_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
# The Workcat SVG walk frames use viewBox="0 0 150 120". # In Workcat:
# Polygon coordinates span ~4145 in X and ~8112 in Y, fitting the viewBox. # The SVG viewBox is 0 0 150 120, scaled to target_w x target_h,
# We simply map viewBox coords → supersampled canvas pixels. # shifted by GAIT_SHIFT_X/Y relative to the cat unit (target_w / CAT_BOX_WIDTH),
# No GAIT_SHIFT or overcorrection needed: those are internal cat.js transforms # and placed with pad_x / pad_y inside the overlay canvas.
# that cancel out at the viewBox level. unit = (target_w / CAT_BOX_WIDTH) * SS
shift_x = pad_x * SS + GAIT_SHIFT_X * unit
shift_y = pad_y * SS + GAIT_SHIFT_Y * unit
scale_x = (target_w / 150.0) * SS scale_x = (target_w / 150.0) * SS
scale_y = (target_h / 120.0) * SS scale_y = (target_h / 120.0) * SS
if self.walk_path_strings: if self.walk_path_strings:
pts = _parse_svg_polygon_points(self.walk_path_strings[frame_index]) pts = _parse_svg_polygon_points(self.walk_path_strings[frame_index])
ss_points = [(p[0] * scale_x, p[1] * scale_y) for p in pts] ss_points = [(p[0] * scale_x + shift_x, p[1] * scale_y + shift_y) for p in pts]
fur_rgba = (*coat.fur_color, 255) fur_rgba = (*coat.fur_color, 255)
draw.polygon(ss_points, fill=fur_rgba) draw.polygon(ss_points, fill=fur_rgba)
# Face features — all coordinates in the same 150×120 viewBox space. # Face features — coordinates in 150x120 viewBox shifted into position
# Eyes: left at (120.4, 46.2), right at (140.3, 46.2), radius 2.2
# Mouth: W-shape centred around (131.6, 51.0)
ink_rgba = (*coat.ink_color, 255) ink_rgba = (*coat.ink_color, 255)
eye_l_cx = 120.4 * scale_x eye_l_cx = 120.4 * scale_x + shift_x
eye_l_cy = 46.2 * scale_y eye_l_cy = 46.2 * scale_y + shift_y
eye_r_cx = 140.3 * scale_x eye_r_cx = 140.3 * scale_x + shift_x
eye_r_cy = 46.2 * scale_y eye_r_cy = 46.2 * scale_y + shift_y
eye_r = 2.2 * scale_x eye_r = 2.2 * scale_x
if face_type == "open": if face_type == "open":
@@ -255,15 +267,15 @@ class AssetsManager:
draw.arc([eye_l_cx - w_eye, eye_l_cy - h_eye, eye_l_cx + w_eye, eye_l_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS)) draw.arc([eye_l_cx - w_eye, eye_l_cy - h_eye, eye_l_cx + w_eye, eye_l_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS))
draw.arc([eye_r_cx - w_eye, eye_r_cy - h_eye, eye_r_cx + w_eye, eye_r_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS)) draw.arc([eye_r_cx - w_eye, eye_r_cy - h_eye, eye_r_cx + w_eye, eye_r_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS))
mouth_cx = 131.6 * scale_x mouth_cx = 131.6 * scale_x + shift_x
mouth_cy = 51.0 * scale_y mouth_cy = 51.0 * scale_y + shift_y
mw = 4.0 * scale_x mw = 4.0 * scale_x
mh = 2.5 * scale_y mh = 2.5 * scale_y
draw.arc([mouth_cx - mw, mouth_cy - mh, mouth_cx, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS)) draw.arc([mouth_cx - mw, mouth_cy - mh, mouth_cx, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS))
draw.arc([mouth_cx, mouth_cy - mh, mouth_cx + mw, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS)) draw.arc([mouth_cx, mouth_cy - mh, mouth_cx + mw, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS))
# Downsample to target resolution with high-quality Lanczos resampling # Downsample to canvas resolution with high-quality Lanczos resampling
final_img = img.resize((target_w, target_h), Image.Resampling.LANCZOS) final_img = img.resize((canvas_w, canvas_h), Image.Resampling.LANCZOS)
if facing_left: if facing_left:
final_img = final_img.transpose(Image.Transpose.FLIP_LEFT_RIGHT) final_img = final_img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
@@ -271,7 +283,6 @@ class AssetsManager:
self._cache[cache_key] = final_img self._cache[cache_key] = final_img
return final_img return final_img
def get_paw_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image: def get_paw_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
""" """
Retrieves and tints a frame from the paw strike animation (f001-f017). Retrieves and tints a frame from the paw strike animation (f001-f017).
@@ -291,10 +302,10 @@ class AssetsManager:
ph = int(round(self.cat_height * 0.96833)) ph = int(round(self.cat_height * 0.96833))
resized = tinted.resize((pw, ph), Image.Resampling.LANCZOS) resized = tinted.resize((pw, ph), Image.Resampling.LANCZOS)
# Composite onto standard cat canvas with offsets # Composite onto canvas with pad offsets
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0)) canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
offset_x = int(round(self.cat_width * 0.02265)) offset_x = self.pad_x + int(round(self.cat_width * 0.02265))
offset_y = int(round(self.cat_height * -0.03083)) offset_y = self.pad_y + int(round(self.cat_height * -0.03083))
canvas.paste(resized, (offset_x, offset_y), resized) canvas.paste(resized, (offset_x, offset_y), resized)
if facing_left: if facing_left:
@@ -319,10 +330,9 @@ class AssetsManager:
sh = int(round(self.cat_height * 1.29)) sh = int(round(self.cat_height * 1.29))
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS) resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
# Center in canvas canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0)) ox = self.pad_x + (self.cat_width - sw) // 2
ox = (self.cat_width - sw) // 2 oy = self.pad_y + (self.cat_height - sh) // 2
oy = (self.cat_height - sh) // 2
canvas.paste(resized, (ox, oy), resized) canvas.paste(resized, (ox, oy), resized)
self._cache[cache_key] = canvas self._cache[cache_key] = canvas
@@ -331,7 +341,7 @@ class AssetsManager:
def get_tail_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image: def get_tail_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
""" """
Frame from the 37-frame tail flick life motion. Frame from the 37-frame tail flick life motion.
Geometry: left: -15.0564%, top: -14.2679%, width: 117.5657%, height: 115.3692% Geometry from Workcat: left: -15.0564%, top: -14.2679%, width: 117.5657%, height: 115.3692%
""" """
frame_index = max(0, min(frame_index, len(self.tail_images) - 1)) if self.tail_images else 0 frame_index = max(0, min(frame_index, len(self.tail_images) - 1)) if self.tail_images else 0
cache_key = f"tail_{frame_index}_{coat.name}_{facing_left}" cache_key = f"tail_{frame_index}_{coat.name}_{facing_left}"
@@ -345,9 +355,10 @@ class AssetsManager:
th = int(round(self.cat_height * 1.153692)) th = int(round(self.cat_height * 1.153692))
resized = tinted.resize((tw, th), Image.Resampling.LANCZOS) resized = tinted.resize((tw, th), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0)) # Composite onto padded canvas — tail extension is fully preserved
ox = int(round(self.cat_width * -0.150564)) canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
oy = int(round(self.cat_height * -0.142679)) ox = self.pad_x + int(round(self.cat_width * -0.150564))
oy = self.pad_y + int(round(self.cat_height * -0.142679))
canvas.paste(resized, (ox, oy), resized) canvas.paste(resized, (ox, oy), resized)
if facing_left: if facing_left:
@@ -359,7 +370,7 @@ class AssetsManager:
def get_stretch_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image: def get_stretch_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
""" """
Frame from the 37-frame stretch life motion. Frame from the 37-frame stretch life motion.
Geometry: left: -4.5169%, top: 0.215%, width: 107.0263%, height: 100.8865% Geometry from Workcat: left: -4.5169%, top: 0.215%, width: 107.0263%, height: 100.8865%
""" """
frame_index = max(0, min(frame_index, len(self.stretch_images) - 1)) if self.stretch_images else 0 frame_index = max(0, min(frame_index, len(self.stretch_images) - 1)) if self.stretch_images else 0
cache_key = f"stretch_{frame_index}_{coat.name}_{facing_left}" cache_key = f"stretch_{frame_index}_{coat.name}_{facing_left}"
@@ -373,9 +384,9 @@ class AssetsManager:
sh = int(round(self.cat_height * 1.008865)) sh = int(round(self.cat_height * 1.008865))
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS) resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0)) canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
ox = int(round(self.cat_width * -0.045169)) ox = self.pad_x + int(round(self.cat_width * -0.045169))
oy = int(round(self.cat_height * 0.00215)) oy = self.pad_y + int(round(self.cat_height * 0.00215))
canvas.paste(resized, (ox, oy), resized) canvas.paste(resized, (ox, oy), resized)
if facing_left: if facing_left: